playwriter 0.0.89 → 0.0.103
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/dist/aria-snapshot.d.ts +7 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +76 -18
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +94 -0
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/bippy.js +1 -1
- package/dist/browser-config.d.ts +12 -1
- package/dist/browser-config.d.ts.map +1 -1
- package/dist/browser-config.js +101 -38
- package/dist/browser-config.js.map +1 -1
- package/dist/browser-launch.d.ts +17 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +44 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +34 -10
- package/dist/cdp-relay.js.map +1 -1
- package/dist/channel-owner-inspect.test.d.ts +2 -0
- package/dist/channel-owner-inspect.test.d.ts.map +1 -0
- package/dist/channel-owner-inspect.test.js +75 -0
- package/dist/channel-owner-inspect.test.js.map +1 -0
- package/dist/chrome-discovery.d.ts +65 -0
- package/dist/chrome-discovery.d.ts.map +1 -0
- package/dist/chrome-discovery.js +165 -0
- package/dist/chrome-discovery.js.map +1 -0
- package/dist/chrome-discovery.test.d.ts +2 -0
- package/dist/chrome-discovery.test.d.ts.map +1 -0
- package/dist/chrome-discovery.test.js +41 -0
- package/dist/chrome-discovery.test.js.map +1 -0
- package/dist/cli.js +357 -53
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +44 -11
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +121 -55
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +29 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension/background.js +1704 -0
- package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
- package/dist/extension/icons/icon-black-128.png +0 -0
- package/dist/extension/icons/icon-black-16.png +0 -0
- package/dist/extension/icons/icon-black-32.png +0 -0
- package/dist/extension/icons/icon-black-48.png +0 -0
- package/dist/extension/icons/icon-gray-128.png +0 -0
- package/dist/extension/icons/icon-gray-16.png +0 -0
- package/dist/extension/icons/icon-gray-32.png +0 -0
- package/dist/extension/icons/icon-gray-48.png +0 -0
- package/dist/extension/icons/icon-green-128.png +0 -0
- package/dist/extension/icons/icon-green-16.png +0 -0
- package/dist/extension/icons/icon-green-32.png +0 -0
- package/dist/extension/icons/icon-green-48.png +0 -0
- package/dist/extension/icons/playwriter-icon-black.png +0 -0
- package/dist/extension/icons/playwriter-icon-gray-disabled.png +0 -0
- package/dist/extension/icons/playwriter-icon-gray-disabled.svg +1 -0
- package/dist/extension/manifest.json +40 -0
- package/dist/extension/offscreen.js +214 -0
- package/dist/extension/src/offscreen.html +9 -0
- package/dist/extension/src/prism-bash.min.js +1 -0
- package/dist/extension/src/prism.min.js +1 -0
- package/dist/extension/src/welcome.html +399 -0
- package/dist/kitty-graphics.d.ts +22 -0
- package/dist/kitty-graphics.d.ts.map +1 -0
- package/dist/kitty-graphics.js +71 -0
- package/dist/kitty-graphics.js.map +1 -0
- package/dist/kitty-graphics.test.d.ts +2 -0
- package/dist/kitty-graphics.test.d.ts.map +1 -0
- package/dist/kitty-graphics.test.js +125 -0
- package/dist/kitty-graphics.test.js.map +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +76 -12
- package/dist/mcp.js.map +1 -1
- package/dist/package-paths.d.ts +3 -0
- package/dist/package-paths.d.ts.map +1 -0
- package/dist/package-paths.js +29 -0
- package/dist/package-paths.js.map +1 -0
- package/dist/prompt.md +4 -2
- package/dist/readability.js +1 -1
- package/dist/relay-core.test.js +14 -4
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +95 -1
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/scoped-fs.d.ts +5 -4
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js +11 -9
- package/dist/scoped-fs.js.map +1 -1
- package/dist/scoped-fs.test.d.ts +5 -0
- package/dist/scoped-fs.test.d.ts.map +1 -0
- package/dist/scoped-fs.test.js +70 -0
- package/dist/scoped-fs.test.js.map +1 -0
- package/dist/screen-recording.d.ts +3 -6
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +7 -21
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +3 -4
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/test-utils.d.ts +3 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +24 -12
- package/dist/test-utils.js.map +1 -1
- package/package.json +6 -5
- package/src/aria-snapshot.test.ts +110 -0
- package/src/aria-snapshot.ts +86 -19
- package/src/aria-snapshots/contenteditable-interactive.txt +7 -0
- package/src/aria-snapshots/contenteditable-raw.txt +14 -0
- package/src/aria-snapshots/github-interactive.txt +0 -2
- package/src/aria-snapshots/github-raw.txt +0 -4
- package/src/aria-snapshots/hackernews-interactive.txt +240 -237
- package/src/aria-snapshots/hackernews-raw.txt +262 -258
- package/src/aria-snapshots/prosemirror-interactive.txt +63 -0
- package/src/aria-snapshots/prosemirror-raw.txt +141 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/browser-config.ts +143 -37
- package/src/browser-launch.ts +75 -0
- package/src/cdp-relay.ts +46 -11
- package/src/channel-owner-inspect.test.ts +94 -0
- package/src/chrome-discovery.test.ts +50 -0
- package/src/chrome-discovery.ts +211 -0
- package/src/cli.ts +432 -59
- package/src/executor.ts +171 -72
- package/src/executor.unit.test.ts +32 -1
- package/src/kitty-graphics.test.ts +139 -0
- package/src/kitty-graphics.ts +79 -0
- package/src/mcp.ts +82 -12
- package/src/package-paths.ts +36 -0
- package/src/relay-core.test.ts +14 -4
- package/src/relay-navigation.test.ts +106 -1
- package/src/scoped-fs.test.ts +77 -0
- package/src/scoped-fs.ts +12 -9
- package/src/screen-recording.ts +7 -22
- package/src/skill.md +32 -2
- package/src/snapshot-tools.test.ts +3 -4
- package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
- package/src/test-utils.ts +25 -11
|
@@ -0,0 +1,1704 @@
|
|
|
1
|
+
//#region ../node_modules/.pnpm/zustand@5.0.8_@types+react@19.2.7_react@19.2.3/node_modules/zustand/esm/vanilla.mjs
|
|
2
|
+
var createStoreImpl = (createState) => {
|
|
3
|
+
let state;
|
|
4
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
5
|
+
const setState = (partial, replace) => {
|
|
6
|
+
const nextState = typeof partial === "function" ? partial(state) : partial;
|
|
7
|
+
if (!Object.is(nextState, state)) {
|
|
8
|
+
const previousState = state;
|
|
9
|
+
state = (replace != null ? replace : typeof nextState !== "object" || nextState === null) ? nextState : Object.assign({}, state, nextState);
|
|
10
|
+
listeners.forEach((listener) => listener(state, previousState));
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const getState = () => state;
|
|
14
|
+
const getInitialState = () => initialState;
|
|
15
|
+
const subscribe = (listener) => {
|
|
16
|
+
listeners.add(listener);
|
|
17
|
+
return () => listeners.delete(listener);
|
|
18
|
+
};
|
|
19
|
+
const api = {
|
|
20
|
+
setState,
|
|
21
|
+
getState,
|
|
22
|
+
getInitialState,
|
|
23
|
+
subscribe
|
|
24
|
+
};
|
|
25
|
+
const initialState = state = createState(setState, getState, api);
|
|
26
|
+
return api;
|
|
27
|
+
};
|
|
28
|
+
var createStore = ((createState) => createState ? createStoreImpl(createState) : createStoreImpl);
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region ../playwriter/src/ghost-browser.ts
|
|
31
|
+
/**
|
|
32
|
+
* Handles ghost-browser commands in the extension.
|
|
33
|
+
* Calls the appropriate chrome.* API and returns the result.
|
|
34
|
+
*
|
|
35
|
+
* @param params - Command parameters (namespace, method, args)
|
|
36
|
+
* @param chromeApi - The chrome object (passed to avoid global dependency)
|
|
37
|
+
* @returns Result object with success/error status
|
|
38
|
+
*/
|
|
39
|
+
async function handleGhostBrowserCommand(params, chromeApi) {
|
|
40
|
+
const { namespace, method, args } = params;
|
|
41
|
+
try {
|
|
42
|
+
const api = chromeApi[namespace];
|
|
43
|
+
if (!api) return {
|
|
44
|
+
success: false,
|
|
45
|
+
error: `chrome.${namespace} not available (not running in Ghost Browser?)`
|
|
46
|
+
};
|
|
47
|
+
const fn = api[method];
|
|
48
|
+
if (typeof fn !== "function") {
|
|
49
|
+
if (method in api) return {
|
|
50
|
+
success: true,
|
|
51
|
+
result: api[method]
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
error: `chrome.${namespace}.${method} is not a function or property`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
success: true,
|
|
60
|
+
result: await new Promise((resolve, reject) => {
|
|
61
|
+
fn.call(api, ...args, (result) => {
|
|
62
|
+
if (chromeApi.runtime.lastError) reject(new Error(chromeApi.runtime.lastError.message));
|
|
63
|
+
else resolve(result);
|
|
64
|
+
});
|
|
65
|
+
})
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: error.message
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/recording.ts
|
|
76
|
+
var activeRecordings = /* @__PURE__ */ new Map();
|
|
77
|
+
var offscreenDocumentCreating = null;
|
|
78
|
+
/**
|
|
79
|
+
* Get the active recordings map (for cleanup on tab disconnect).
|
|
80
|
+
*/
|
|
81
|
+
function getActiveRecordings() {
|
|
82
|
+
return activeRecordings;
|
|
83
|
+
}
|
|
84
|
+
async function ensureOffscreenDocument() {
|
|
85
|
+
if ((await chrome.runtime.getContexts({
|
|
86
|
+
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
|
|
87
|
+
documentUrls: [chrome.runtime.getURL("src/offscreen.html")]
|
|
88
|
+
})).length > 0) return;
|
|
89
|
+
if (offscreenDocumentCreating) return offscreenDocumentCreating;
|
|
90
|
+
offscreenDocumentCreating = chrome.offscreen.createDocument({
|
|
91
|
+
url: "src/offscreen.html",
|
|
92
|
+
reasons: [chrome.offscreen.Reason.USER_MEDIA],
|
|
93
|
+
justification: "Screen recording via chrome.tabCapture"
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
await offscreenDocumentCreating;
|
|
97
|
+
} finally {
|
|
98
|
+
offscreenDocumentCreating = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function resolveTabIdFromSessionId(sessionId) {
|
|
102
|
+
if (!sessionId) {
|
|
103
|
+
for (const [tabId, tab] of store.getState().tabs) if (tab.state === "connected") return tabId;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
return getTabBySessionId(sessionId)?.tabId;
|
|
107
|
+
}
|
|
108
|
+
function updateTabRecordingState(tabId, isRecording) {
|
|
109
|
+
store.setState((state) => {
|
|
110
|
+
const newTabs = new Map(state.tabs);
|
|
111
|
+
const existing = newTabs.get(tabId);
|
|
112
|
+
if (existing) newTabs.set(tabId, {
|
|
113
|
+
...existing,
|
|
114
|
+
isRecording
|
|
115
|
+
});
|
|
116
|
+
return { tabs: newTabs };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function handleStartRecording(params) {
|
|
120
|
+
const tabId = resolveTabIdFromSessionId(params.sessionId);
|
|
121
|
+
if (!tabId) return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: "No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record."
|
|
124
|
+
};
|
|
125
|
+
if (activeRecordings.has(tabId)) return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: "Recording already in progress for this tab"
|
|
128
|
+
};
|
|
129
|
+
const tabInfo = store.getState().tabs.get(tabId);
|
|
130
|
+
if (!tabInfo || tabInfo.state !== "connected") return {
|
|
131
|
+
success: false,
|
|
132
|
+
error: "Tab is not connected"
|
|
133
|
+
};
|
|
134
|
+
logger.debug("Starting recording for tab:", tabId, "params:", params);
|
|
135
|
+
try {
|
|
136
|
+
await ensureOffscreenDocument();
|
|
137
|
+
const streamId = await new Promise((resolve, reject) => {
|
|
138
|
+
chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => {
|
|
139
|
+
if (chrome.runtime.lastError) {
|
|
140
|
+
const errorMsg = chrome.runtime.lastError.message || "Unknown error";
|
|
141
|
+
if (errorMsg.includes("Extension has not been invoked") || errorMsg.includes("activeTab")) reject(/* @__PURE__ */ new Error(`${errorMsg}. Click the Playwriter extension icon on this tab to enable recording.`));
|
|
142
|
+
else reject(new Error(errorMsg));
|
|
143
|
+
} else if (!id) reject(/* @__PURE__ */ new Error("Failed to get media stream ID"));
|
|
144
|
+
else resolve(id);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
logger.debug("Got stream ID for tab:", tabId, "streamId:", streamId.substring(0, 20) + "...");
|
|
148
|
+
const result = await chrome.runtime.sendMessage({
|
|
149
|
+
action: "startRecording",
|
|
150
|
+
tabId,
|
|
151
|
+
streamId,
|
|
152
|
+
frameRate: params.frameRate ?? 30,
|
|
153
|
+
videoBitsPerSecond: params.videoBitsPerSecond ?? 25e5,
|
|
154
|
+
audioBitsPerSecond: params.audioBitsPerSecond ?? 128e3,
|
|
155
|
+
audio: params.audio ?? false
|
|
156
|
+
});
|
|
157
|
+
if (!result.success) return {
|
|
158
|
+
success: false,
|
|
159
|
+
error: result.error || "Failed to start recording in offscreen document"
|
|
160
|
+
};
|
|
161
|
+
const startedAt = result.startedAt || Date.now();
|
|
162
|
+
activeRecordings.set(tabId, {
|
|
163
|
+
tabId,
|
|
164
|
+
startedAt
|
|
165
|
+
});
|
|
166
|
+
updateTabRecordingState(tabId, true);
|
|
167
|
+
logger.debug("Recording started for tab:", tabId, "mimeType:", result.mimeType);
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
tabId,
|
|
171
|
+
startedAt
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
175
|
+
logger.error("Failed to start recording:", error);
|
|
176
|
+
return {
|
|
177
|
+
success: false,
|
|
178
|
+
error: errorMessage
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function handleStopRecording(params) {
|
|
183
|
+
const tabId = resolveTabIdFromSessionId(params.sessionId);
|
|
184
|
+
if (!tabId) return {
|
|
185
|
+
success: false,
|
|
186
|
+
error: "No connected tab found"
|
|
187
|
+
};
|
|
188
|
+
const recording = activeRecordings.get(tabId);
|
|
189
|
+
if (!recording) return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: "No active recording for this tab"
|
|
192
|
+
};
|
|
193
|
+
logger.debug("Stopping recording for tab:", tabId);
|
|
194
|
+
try {
|
|
195
|
+
const result = await chrome.runtime.sendMessage({
|
|
196
|
+
action: "stopRecording",
|
|
197
|
+
tabId
|
|
198
|
+
});
|
|
199
|
+
if (!result.success) return {
|
|
200
|
+
success: false,
|
|
201
|
+
error: result.error || "Failed to stop recording in offscreen document"
|
|
202
|
+
};
|
|
203
|
+
const duration = result.duration || Date.now() - recording.startedAt;
|
|
204
|
+
activeRecordings.delete(tabId);
|
|
205
|
+
updateTabRecordingState(tabId, false);
|
|
206
|
+
logger.debug("Recording stopped for tab:", tabId, "duration:", duration);
|
|
207
|
+
return {
|
|
208
|
+
success: true,
|
|
209
|
+
tabId,
|
|
210
|
+
duration
|
|
211
|
+
};
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
214
|
+
logger.error("Failed to stop recording:", error);
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
error: errorMessage
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function handleIsRecording(params) {
|
|
222
|
+
const tabId = resolveTabIdFromSessionId(params.sessionId);
|
|
223
|
+
if (!tabId) return { isRecording: false };
|
|
224
|
+
const recording = activeRecordings.get(tabId);
|
|
225
|
+
if (!recording) return {
|
|
226
|
+
isRecording: false,
|
|
227
|
+
tabId
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
return {
|
|
231
|
+
isRecording: (await chrome.runtime.sendMessage({
|
|
232
|
+
action: "isRecording",
|
|
233
|
+
tabId
|
|
234
|
+
})).isRecording,
|
|
235
|
+
tabId,
|
|
236
|
+
startedAt: recording.startedAt
|
|
237
|
+
};
|
|
238
|
+
} catch {
|
|
239
|
+
return {
|
|
240
|
+
isRecording: false,
|
|
241
|
+
tabId
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function handleCancelRecording(params) {
|
|
246
|
+
const tabId = resolveTabIdFromSessionId(params.sessionId);
|
|
247
|
+
if (!tabId) return {
|
|
248
|
+
success: false,
|
|
249
|
+
error: "No connected tab found"
|
|
250
|
+
};
|
|
251
|
+
if (!activeRecordings.get(tabId)) return { success: true };
|
|
252
|
+
logger.debug("Cancelling recording for tab:", tabId);
|
|
253
|
+
try {
|
|
254
|
+
await chrome.runtime.sendMessage({
|
|
255
|
+
action: "cancelRecording",
|
|
256
|
+
tabId
|
|
257
|
+
});
|
|
258
|
+
activeRecordings.delete(tabId);
|
|
259
|
+
updateTabRecordingState(tabId, false);
|
|
260
|
+
if (connectionManager.ws?.readyState === WebSocket.OPEN) sendMessage({
|
|
261
|
+
method: "recordingCancelled",
|
|
262
|
+
params: { tabId }
|
|
263
|
+
});
|
|
264
|
+
return { success: true };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
267
|
+
logger.error("Failed to cancel recording:", error);
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
error: errorMessage
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Clean up recordings when tab is disconnected.
|
|
276
|
+
*/
|
|
277
|
+
async function cleanupRecordingForTab(tabId) {
|
|
278
|
+
if (activeRecordings.get(tabId)) {
|
|
279
|
+
logger.debug("Cleaning up recording for disconnected tab:", tabId);
|
|
280
|
+
try {
|
|
281
|
+
await chrome.runtime.sendMessage({
|
|
282
|
+
action: "cancelRecording",
|
|
283
|
+
tabId
|
|
284
|
+
});
|
|
285
|
+
} catch (e) {
|
|
286
|
+
logger.debug("Error cleaning up recording:", e);
|
|
287
|
+
}
|
|
288
|
+
activeRecordings.delete(tabId);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/background.ts
|
|
293
|
+
var RELAY_HOST = "127.0.0.1";
|
|
294
|
+
var RELAY_PORT = 19988;
|
|
295
|
+
function sleep(ms) {
|
|
296
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
297
|
+
}
|
|
298
|
+
async function detectBrowserName() {
|
|
299
|
+
if (chrome.ghostPublicAPI) return "Ghost";
|
|
300
|
+
const brands = navigator.userAgentData?.brands;
|
|
301
|
+
if (brands && brands.length > 0) {
|
|
302
|
+
const brandNames = brands.map((brand) => {
|
|
303
|
+
return brand.brand.trim().toLowerCase();
|
|
304
|
+
});
|
|
305
|
+
if (brandNames.some((brand) => brand === "brave")) return "Brave";
|
|
306
|
+
if (brandNames.some((brand) => brand === "microsoft edge")) return "Edge";
|
|
307
|
+
if (brandNames.some((brand) => brand === "opera")) return "Opera";
|
|
308
|
+
if (brandNames.some((brand) => brand === "vivaldi")) return "Vivaldi";
|
|
309
|
+
if (brandNames.some((brand) => brand === "google chrome")) return "Chrome";
|
|
310
|
+
if (brandNames.some((brand) => brand === "chromium")) return "Chromium";
|
|
311
|
+
}
|
|
312
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
313
|
+
if (ua.includes("edg/")) return "Edge";
|
|
314
|
+
if (ua.includes("opr/")) return "Opera";
|
|
315
|
+
if (ua.includes("vivaldi")) return "Vivaldi";
|
|
316
|
+
if (ua.includes("brave")) return "Brave";
|
|
317
|
+
if (ua.includes("chrome")) return "Chrome";
|
|
318
|
+
return "Chromium";
|
|
319
|
+
}
|
|
320
|
+
var identityPromise = null;
|
|
321
|
+
var tabSessionScope = (() => {
|
|
322
|
+
const values = new Uint32Array(2);
|
|
323
|
+
crypto.getRandomValues(values);
|
|
324
|
+
return Array.from(values).map((value) => {
|
|
325
|
+
return value.toString(36);
|
|
326
|
+
}).join("");
|
|
327
|
+
})();
|
|
328
|
+
async function getExtensionIdentity() {
|
|
329
|
+
if (identityPromise) return identityPromise;
|
|
330
|
+
identityPromise = (async () => {
|
|
331
|
+
const browser = await detectBrowserName();
|
|
332
|
+
try {
|
|
333
|
+
const info = await chrome.identity.getProfileUserInfo({ accountStatus: "ANY" });
|
|
334
|
+
return {
|
|
335
|
+
browser,
|
|
336
|
+
email: info.email || "",
|
|
337
|
+
id: info.id || ""
|
|
338
|
+
};
|
|
339
|
+
} catch {
|
|
340
|
+
return {
|
|
341
|
+
browser,
|
|
342
|
+
email: "",
|
|
343
|
+
id: ""
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
return identityPromise;
|
|
348
|
+
}
|
|
349
|
+
var TAB_GROUP_COLOR = "green";
|
|
350
|
+
var TAB_GROUP_TITLE = "playwriter";
|
|
351
|
+
var childSessions = /* @__PURE__ */ new Map();
|
|
352
|
+
var nextSessionId = 1;
|
|
353
|
+
var tabGroupQueue = Promise.resolve();
|
|
354
|
+
var autoAttachParams = null;
|
|
355
|
+
var recordingChunkBuffer = [];
|
|
356
|
+
/**
|
|
357
|
+
* Flush buffered recording chunks to the WebSocket.
|
|
358
|
+
* Called when WebSocket becomes ready.
|
|
359
|
+
*/
|
|
360
|
+
function flushRecordingChunkBuffer(ws) {
|
|
361
|
+
if (recordingChunkBuffer.length === 0) return;
|
|
362
|
+
logger.debug(`Flushing ${recordingChunkBuffer.length} buffered recording chunks`);
|
|
363
|
+
while (recordingChunkBuffer.length > 0) {
|
|
364
|
+
const { tabId, data, final } = recordingChunkBuffer.shift();
|
|
365
|
+
ws.send(JSON.stringify({
|
|
366
|
+
method: "recordingData",
|
|
367
|
+
params: {
|
|
368
|
+
tabId,
|
|
369
|
+
final
|
|
370
|
+
}
|
|
371
|
+
}));
|
|
372
|
+
if (data && !final) {
|
|
373
|
+
const buffer = new Uint8Array(data);
|
|
374
|
+
ws.send(buffer);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
var ConnectionManager = class {
|
|
379
|
+
ws = null;
|
|
380
|
+
connectionPromise = null;
|
|
381
|
+
preserveTabsOnDetach = false;
|
|
382
|
+
async ensureConnection() {
|
|
383
|
+
if (this.ws?.readyState === WebSocket.OPEN) return;
|
|
384
|
+
if (store.getState().connectionState === "extension-replaced") throw new Error("Another Playwriter extension is already connected");
|
|
385
|
+
if (this.connectionPromise) return this.connectionPromise;
|
|
386
|
+
const GLOBAL_TIMEOUT_MS = 15e3;
|
|
387
|
+
this.connectionPromise = Promise.race([this.connect(), new Promise((_, reject) => {
|
|
388
|
+
setTimeout(() => {
|
|
389
|
+
reject(/* @__PURE__ */ new Error("Connection timeout (global)"));
|
|
390
|
+
}, GLOBAL_TIMEOUT_MS);
|
|
391
|
+
})]);
|
|
392
|
+
try {
|
|
393
|
+
await this.connectionPromise;
|
|
394
|
+
} finally {
|
|
395
|
+
this.connectionPromise = null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async connect() {
|
|
399
|
+
logger.debug(`Waiting for server at http://${RELAY_HOST}:${RELAY_PORT}...`);
|
|
400
|
+
const maxAttempts = 5;
|
|
401
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) try {
|
|
402
|
+
await fetch(`http://${RELAY_HOST}:${RELAY_PORT}`, {
|
|
403
|
+
method: "HEAD",
|
|
404
|
+
signal: AbortSignal.timeout(2e3)
|
|
405
|
+
});
|
|
406
|
+
logger.debug("Server is available");
|
|
407
|
+
break;
|
|
408
|
+
} catch {
|
|
409
|
+
if (attempt === maxAttempts - 1) throw new Error("Server not available");
|
|
410
|
+
logger.debug(`Server not available, retrying... (attempt ${attempt + 1}/${maxAttempts})`);
|
|
411
|
+
await sleep(1e3);
|
|
412
|
+
}
|
|
413
|
+
const identity = await getExtensionIdentity();
|
|
414
|
+
const relayUrl = new URL(`ws://${RELAY_HOST}:${RELAY_PORT}/extension`);
|
|
415
|
+
if (identity.browser) relayUrl.searchParams.set("browser", identity.browser);
|
|
416
|
+
if (identity.email) relayUrl.searchParams.set("email", identity.email);
|
|
417
|
+
if (identity.id) relayUrl.searchParams.set("id", identity.id);
|
|
418
|
+
relayUrl.searchParams.set("v", "0.0.103");
|
|
419
|
+
logger.debug("Creating WebSocket connection to:", relayUrl);
|
|
420
|
+
const socket = new WebSocket(relayUrl.toString());
|
|
421
|
+
await new Promise((resolve, reject) => {
|
|
422
|
+
let settled = false;
|
|
423
|
+
const timeout = setTimeout(() => {
|
|
424
|
+
if (settled) return;
|
|
425
|
+
settled = true;
|
|
426
|
+
logger.debug("WebSocket connection TIMEOUT after 5 seconds");
|
|
427
|
+
try {
|
|
428
|
+
socket.close();
|
|
429
|
+
} catch {}
|
|
430
|
+
reject(/* @__PURE__ */ new Error("Connection timeout"));
|
|
431
|
+
}, 5e3);
|
|
432
|
+
socket.onopen = () => {
|
|
433
|
+
if (settled) return;
|
|
434
|
+
settled = true;
|
|
435
|
+
logger.debug("WebSocket connected");
|
|
436
|
+
clearTimeout(timeout);
|
|
437
|
+
flushRecordingChunkBuffer(socket);
|
|
438
|
+
resolve();
|
|
439
|
+
};
|
|
440
|
+
socket.onerror = (error) => {
|
|
441
|
+
logger.debug("WebSocket error during connection:", error);
|
|
442
|
+
if (settled) return;
|
|
443
|
+
settled = true;
|
|
444
|
+
clearTimeout(timeout);
|
|
445
|
+
reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
|
|
446
|
+
};
|
|
447
|
+
socket.onclose = (event) => {
|
|
448
|
+
logger.debug("WebSocket closed during connection:", {
|
|
449
|
+
code: event.code,
|
|
450
|
+
reason: event.reason
|
|
451
|
+
});
|
|
452
|
+
if (settled) return;
|
|
453
|
+
settled = true;
|
|
454
|
+
clearTimeout(timeout);
|
|
455
|
+
if (event.code === 4002 || event.reason === "Extension Already In Use") reject(/* @__PURE__ */ new Error("Extension Already In Use"));
|
|
456
|
+
else reject(/* @__PURE__ */ new Error(`WebSocket closed: ${event.reason || event.code}`));
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
this.ws = socket;
|
|
460
|
+
this.ws.onmessage = async (event) => {
|
|
461
|
+
let message;
|
|
462
|
+
try {
|
|
463
|
+
message = JSON.parse(event.data);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
logger.debug("Error parsing message:", error);
|
|
466
|
+
sendMessage({ error: {
|
|
467
|
+
code: -32700,
|
|
468
|
+
message: `Error parsing message: ${error.message}`
|
|
469
|
+
} });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (message.method === "ping") {
|
|
473
|
+
sendMessage({ method: "pong" });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (message.method === "createInitialTab") {
|
|
477
|
+
try {
|
|
478
|
+
logger.debug("Creating initial tab for Playwright client");
|
|
479
|
+
const tab = await chrome.tabs.create({
|
|
480
|
+
url: "about:blank",
|
|
481
|
+
active: false
|
|
482
|
+
});
|
|
483
|
+
if (tab.id) {
|
|
484
|
+
setTabConnecting(tab.id);
|
|
485
|
+
const { targetInfo, sessionId } = await attachTab(tab.id, { skipAttachedEvent: true });
|
|
486
|
+
logger.debug("Initial tab created and connected:", tab.id, "sessionId:", sessionId);
|
|
487
|
+
sendMessage({
|
|
488
|
+
id: message.id,
|
|
489
|
+
result: {
|
|
490
|
+
success: true,
|
|
491
|
+
tabId: tab.id,
|
|
492
|
+
sessionId,
|
|
493
|
+
targetInfo
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
} else throw new Error("Failed to create tab - no tab ID returned");
|
|
497
|
+
} catch (error) {
|
|
498
|
+
logger.debug("Failed to create initial tab:", error);
|
|
499
|
+
sendMessage({
|
|
500
|
+
id: message.id,
|
|
501
|
+
error: error.message
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (message.method === "startRecording") {
|
|
507
|
+
try {
|
|
508
|
+
const result = await handleStartRecording(message.params);
|
|
509
|
+
sendMessage({
|
|
510
|
+
id: message.id,
|
|
511
|
+
result
|
|
512
|
+
});
|
|
513
|
+
} catch (error) {
|
|
514
|
+
logger.error("Failed to start recording:", error);
|
|
515
|
+
sendMessage({
|
|
516
|
+
id: message.id,
|
|
517
|
+
result: {
|
|
518
|
+
success: false,
|
|
519
|
+
error: error.message
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (message.method === "stopRecording") {
|
|
526
|
+
try {
|
|
527
|
+
const result = await handleStopRecording(message.params);
|
|
528
|
+
sendMessage({
|
|
529
|
+
id: message.id,
|
|
530
|
+
result
|
|
531
|
+
});
|
|
532
|
+
} catch (error) {
|
|
533
|
+
logger.error("Failed to stop recording:", error);
|
|
534
|
+
sendMessage({
|
|
535
|
+
id: message.id,
|
|
536
|
+
result: {
|
|
537
|
+
success: false,
|
|
538
|
+
error: error.message
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (message.method === "isRecording") {
|
|
545
|
+
try {
|
|
546
|
+
const result = await handleIsRecording(message.params);
|
|
547
|
+
sendMessage({
|
|
548
|
+
id: message.id,
|
|
549
|
+
result
|
|
550
|
+
});
|
|
551
|
+
} catch (error) {
|
|
552
|
+
logger.error("Failed to check recording status:", error);
|
|
553
|
+
sendMessage({
|
|
554
|
+
id: message.id,
|
|
555
|
+
result: { isRecording: false }
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (message.method === "cancelRecording") {
|
|
561
|
+
try {
|
|
562
|
+
const result = await handleCancelRecording(message.params);
|
|
563
|
+
sendMessage({
|
|
564
|
+
id: message.id,
|
|
565
|
+
result
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {
|
|
568
|
+
logger.error("Failed to cancel recording:", error);
|
|
569
|
+
sendMessage({
|
|
570
|
+
id: message.id,
|
|
571
|
+
result: {
|
|
572
|
+
success: false,
|
|
573
|
+
error: error.message
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (message.method === "ghost-browser") {
|
|
580
|
+
const params = message.params;
|
|
581
|
+
const result = await handleGhostBrowserCommand(params, chrome);
|
|
582
|
+
if (!result.success) logger.error("Ghost Browser API error:", result.error);
|
|
583
|
+
if (result.success && params.namespace === "ghostPublicAPI" && params.method === "openTab") {
|
|
584
|
+
const tabId = result.result;
|
|
585
|
+
if (tabId) {
|
|
586
|
+
logger.debug("Auto-connecting Ghost Browser tab:", tabId);
|
|
587
|
+
setTabConnecting(tabId);
|
|
588
|
+
await sleep(100);
|
|
589
|
+
await attachTab(tabId);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
sendMessage({
|
|
593
|
+
id: message.id,
|
|
594
|
+
result
|
|
595
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const response = { id: message.id };
|
|
599
|
+
try {
|
|
600
|
+
response.result = await handleCommand(message);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
logger.debug("Error handling command:", error);
|
|
603
|
+
response.error = error.message;
|
|
604
|
+
}
|
|
605
|
+
sendMessage(response);
|
|
606
|
+
};
|
|
607
|
+
this.ws.onclose = (event) => {
|
|
608
|
+
this.handleClose(event.reason, event.code);
|
|
609
|
+
};
|
|
610
|
+
this.ws.onerror = (event) => {
|
|
611
|
+
logger.debug("WebSocket error:", event);
|
|
612
|
+
};
|
|
613
|
+
chrome.debugger.onEvent.addListener(onDebuggerEvent);
|
|
614
|
+
chrome.debugger.onDetach.addListener(onDebuggerDetach);
|
|
615
|
+
logger.debug("Connection established");
|
|
616
|
+
}
|
|
617
|
+
handleClose(reason, code) {
|
|
618
|
+
try {
|
|
619
|
+
const mem = performance.memory;
|
|
620
|
+
if (mem) {
|
|
621
|
+
const formatMB = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
|
|
622
|
+
logger.warn(`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`);
|
|
623
|
+
}
|
|
624
|
+
} catch {}
|
|
625
|
+
logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || "none"} stack=${getCallStack()}`);
|
|
626
|
+
chrome.debugger.onEvent.removeListener(onDebuggerEvent);
|
|
627
|
+
chrome.debugger.onDetach.removeListener(onDebuggerDetach);
|
|
628
|
+
const isExtensionReplaced = reason === "Extension Replaced" || code === 4001;
|
|
629
|
+
const isExtensionInUse = reason === "Extension Already In Use" || code === 4002;
|
|
630
|
+
this.preserveTabsOnDetach = !(isExtensionReplaced || isExtensionInUse);
|
|
631
|
+
const { tabs } = store.getState();
|
|
632
|
+
for (const [tabId] of tabs) chrome.debugger.detach({ tabId }).catch((err) => {
|
|
633
|
+
logger.debug("Error detaching from tab:", tabId, err.message);
|
|
634
|
+
});
|
|
635
|
+
childSessions.clear();
|
|
636
|
+
this.ws = null;
|
|
637
|
+
if (isExtensionReplaced) {
|
|
638
|
+
logger.debug("Disconnected: another Playwriter extension connected (this one was idle)");
|
|
639
|
+
store.setState({
|
|
640
|
+
tabs: /* @__PURE__ */ new Map(),
|
|
641
|
+
connectionState: "extension-replaced",
|
|
642
|
+
errorText: "Another Playwriter extension took over the connection"
|
|
643
|
+
});
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (isExtensionInUse) {
|
|
647
|
+
logger.debug("Rejected: another Playwriter extension is actively in use");
|
|
648
|
+
store.setState({
|
|
649
|
+
tabs: /* @__PURE__ */ new Map(),
|
|
650
|
+
connectionState: "extension-replaced",
|
|
651
|
+
errorText: "Another Playwriter extension is actively in use"
|
|
652
|
+
});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
store.setState((state) => {
|
|
656
|
+
const newTabs = new Map(state.tabs);
|
|
657
|
+
for (const [tabId, tab] of newTabs) newTabs.set(tabId, {
|
|
658
|
+
...tab,
|
|
659
|
+
state: "connecting"
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
tabs: newTabs,
|
|
663
|
+
connectionState: "idle",
|
|
664
|
+
errorText: void 0
|
|
665
|
+
};
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
async maintainLoop() {
|
|
669
|
+
while (true) {
|
|
670
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
671
|
+
await sleep(1e3);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (store.getState().connectionState === "extension-replaced") {
|
|
675
|
+
try {
|
|
676
|
+
const data = await (await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, {
|
|
677
|
+
method: "GET",
|
|
678
|
+
signal: AbortSignal.timeout(2e3)
|
|
679
|
+
})).json();
|
|
680
|
+
if (!data.connected || data.activeTargets === 0) {
|
|
681
|
+
store.setState({
|
|
682
|
+
connectionState: "idle",
|
|
683
|
+
errorText: void 0
|
|
684
|
+
});
|
|
685
|
+
logger.debug("Extension slot is free (connected:", data.connected, "activeTargets:", data.activeTargets, "), cleared error state");
|
|
686
|
+
} else logger.debug("Extension slot still taken (activeTargets:", data.activeTargets, "), will retry...");
|
|
687
|
+
} catch {
|
|
688
|
+
logger.debug("Server not available, will retry...");
|
|
689
|
+
}
|
|
690
|
+
await sleep(3e3);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const currentTabs = store.getState().tabs;
|
|
694
|
+
if (Array.from(currentTabs.values()).some((t) => t.state === "connected")) store.setState((state) => {
|
|
695
|
+
const newTabs = new Map(state.tabs);
|
|
696
|
+
for (const [tabId, tab] of newTabs) if (tab.state === "connected") newTabs.set(tabId, {
|
|
697
|
+
...tab,
|
|
698
|
+
state: "connecting"
|
|
699
|
+
});
|
|
700
|
+
return { tabs: newTabs };
|
|
701
|
+
});
|
|
702
|
+
try {
|
|
703
|
+
await this.ensureConnection();
|
|
704
|
+
store.setState({ connectionState: "connected" });
|
|
705
|
+
const tabsToReattach = Array.from(store.getState().tabs.entries()).filter(([_, tab]) => tab.state === "connecting").map(([tabId]) => tabId);
|
|
706
|
+
for (const tabId of tabsToReattach) {
|
|
707
|
+
const currentTab = store.getState().tabs.get(tabId);
|
|
708
|
+
if (!currentTab || currentTab.state !== "connecting") {
|
|
709
|
+
logger.debug("Skipping reattach, tab state changed:", tabId, currentTab?.state);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
await chrome.tabs.get(tabId);
|
|
714
|
+
await attachTab(tabId);
|
|
715
|
+
logger.debug("Successfully re-attached tab:", tabId);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
logger.debug("Failed to re-attach tab:", tabId, error.message);
|
|
718
|
+
store.setState((state) => {
|
|
719
|
+
const newTabs = new Map(state.tabs);
|
|
720
|
+
newTabs.delete(tabId);
|
|
721
|
+
return { tabs: newTabs };
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
this.preserveTabsOnDetach = false;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
logger.debug("Connection attempt failed:", error.message);
|
|
728
|
+
if (error.message === "Extension Already In Use") store.setState({
|
|
729
|
+
connectionState: "extension-replaced",
|
|
730
|
+
errorText: "Another Playwriter extension is actively in use"
|
|
731
|
+
});
|
|
732
|
+
else store.setState({ connectionState: "idle" });
|
|
733
|
+
}
|
|
734
|
+
await sleep(3e3);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
var connectionManager = new ConnectionManager();
|
|
739
|
+
var store = createStore(() => ({
|
|
740
|
+
tabs: /* @__PURE__ */ new Map(),
|
|
741
|
+
connectionState: "idle",
|
|
742
|
+
currentTabId: void 0,
|
|
743
|
+
errorText: void 0
|
|
744
|
+
}));
|
|
745
|
+
globalThis.toggleExtensionForActiveTab = toggleExtensionForActiveTab;
|
|
746
|
+
globalThis.disconnectEverything = disconnectEverything;
|
|
747
|
+
globalThis.getExtensionState = () => store.getState();
|
|
748
|
+
var MAX_LOG_STRING_LENGTH = 2e3;
|
|
749
|
+
function truncateLogString(value) {
|
|
750
|
+
if (value.length <= MAX_LOG_STRING_LENGTH) return value;
|
|
751
|
+
return `${value.slice(0, MAX_LOG_STRING_LENGTH)}…[truncated ${value.length - MAX_LOG_STRING_LENGTH} chars]`;
|
|
752
|
+
}
|
|
753
|
+
function safeSerialize(arg) {
|
|
754
|
+
if (arg === void 0) return "undefined";
|
|
755
|
+
if (arg === null) return "null";
|
|
756
|
+
if (typeof arg === "function") return `[Function: ${arg.name || "anonymous"}]`;
|
|
757
|
+
if (typeof arg === "symbol") return String(arg);
|
|
758
|
+
if (typeof arg === "string") return truncateLogString(arg);
|
|
759
|
+
if (arg instanceof Error) return truncateLogString(arg.stack || arg.message || String(arg));
|
|
760
|
+
if (typeof arg === "object") try {
|
|
761
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
762
|
+
return truncateLogString(JSON.stringify(arg, (key, value) => {
|
|
763
|
+
if (typeof value === "object" && value !== null) {
|
|
764
|
+
if (seen.has(value)) return "[Circular]";
|
|
765
|
+
seen.add(value);
|
|
766
|
+
if (value instanceof Map) return {
|
|
767
|
+
dataType: "Map",
|
|
768
|
+
value: Array.from(value.entries())
|
|
769
|
+
};
|
|
770
|
+
if (value instanceof Set) return {
|
|
771
|
+
dataType: "Set",
|
|
772
|
+
value: Array.from(value.values())
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
return value;
|
|
776
|
+
}));
|
|
777
|
+
} catch {
|
|
778
|
+
return truncateLogString(String(arg));
|
|
779
|
+
}
|
|
780
|
+
return truncateLogString(String(arg));
|
|
781
|
+
}
|
|
782
|
+
function sendLog(level, args) {
|
|
783
|
+
sendMessage({
|
|
784
|
+
method: "log",
|
|
785
|
+
params: {
|
|
786
|
+
level,
|
|
787
|
+
args: args.map(safeSerialize)
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
var logger = {
|
|
792
|
+
log: (...args) => {
|
|
793
|
+
console.log(...args);
|
|
794
|
+
sendLog("log", args);
|
|
795
|
+
},
|
|
796
|
+
debug: (...args) => {
|
|
797
|
+
console.debug(...args);
|
|
798
|
+
sendLog("debug", args);
|
|
799
|
+
},
|
|
800
|
+
info: (...args) => {
|
|
801
|
+
console.info(...args);
|
|
802
|
+
sendLog("info", args);
|
|
803
|
+
},
|
|
804
|
+
warn: (...args) => {
|
|
805
|
+
console.warn(...args);
|
|
806
|
+
sendLog("warn", args);
|
|
807
|
+
},
|
|
808
|
+
error: (...args) => {
|
|
809
|
+
console.error(...args);
|
|
810
|
+
sendLog("error", args);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
function getCallStack() {
|
|
814
|
+
return ((/* @__PURE__ */ new Error()).stack || "").split("\n").slice(2, 6).join(" <- ").replace(/\s+/g, " ");
|
|
815
|
+
}
|
|
816
|
+
self.addEventListener("error", (event) => {
|
|
817
|
+
const stack = event.error?.stack || `${event.message} at ${event.filename}:${event.lineno}:${event.colno}`;
|
|
818
|
+
logger.error("Uncaught error:", stack);
|
|
819
|
+
});
|
|
820
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
821
|
+
const reason = event.reason;
|
|
822
|
+
const stack = reason?.stack || String(reason);
|
|
823
|
+
logger.error("Unhandled promise rejection:", stack);
|
|
824
|
+
});
|
|
825
|
+
var messageCount = 0;
|
|
826
|
+
function sendMessage(message) {
|
|
827
|
+
if (connectionManager.ws?.readyState === WebSocket.OPEN) try {
|
|
828
|
+
connectionManager.ws.send(JSON.stringify(message));
|
|
829
|
+
if (++messageCount % 100 === 0) checkMemory();
|
|
830
|
+
} catch (error) {
|
|
831
|
+
console.debug("ERROR sending message:", error, "message type:", message.method || "response");
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
async function syncTabGroup() {
|
|
835
|
+
try {
|
|
836
|
+
const { connectionState } = store.getState();
|
|
837
|
+
const isRelayConnected = connectionState === "connected";
|
|
838
|
+
const connectedTabIds = Array.from(store.getState().tabs.entries()).filter(([_, info]) => info.state === "connected" || info.state === "connecting" && isRelayConnected).map(([tabId]) => tabId);
|
|
839
|
+
const existingGroups = await chrome.tabGroups.query({ title: TAB_GROUP_TITLE });
|
|
840
|
+
if (connectedTabIds.length === 0) {
|
|
841
|
+
for (const group of existingGroups) {
|
|
842
|
+
const tabIdsToUngroup = (await chrome.tabs.query({ groupId: group.id })).map((t) => t.id).filter((id) => id !== void 0);
|
|
843
|
+
if (tabIdsToUngroup.length > 0) await chrome.tabs.ungroup(tabIdsToUngroup);
|
|
844
|
+
logger.debug("Cleared playwriter group:", group.id);
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
let groupId = existingGroups[0]?.id;
|
|
849
|
+
if (existingGroups.length > 1) {
|
|
850
|
+
const [keep, ...duplicates] = existingGroups;
|
|
851
|
+
groupId = keep.id;
|
|
852
|
+
for (const group of duplicates) {
|
|
853
|
+
const tabIdsToUngroup = (await chrome.tabs.query({ groupId: group.id })).map((t) => t.id).filter((id) => id !== void 0);
|
|
854
|
+
if (tabIdsToUngroup.length > 0) await chrome.tabs.ungroup(tabIdsToUngroup);
|
|
855
|
+
logger.debug("Removed duplicate playwriter group:", group.id);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const tabsInGroup = (await chrome.tabs.query({})).filter((t) => t.groupId === groupId && t.id !== void 0);
|
|
859
|
+
const tabIdsInGroup = new Set(tabsInGroup.map((t) => t.id));
|
|
860
|
+
const tabsToAdd = connectedTabIds.filter((id) => !tabIdsInGroup.has(id));
|
|
861
|
+
const tabsToRemove = Array.from(tabIdsInGroup).filter((id) => !connectedTabIds.includes(id));
|
|
862
|
+
if (tabsToRemove.length > 0) try {
|
|
863
|
+
await chrome.tabs.ungroup(tabsToRemove);
|
|
864
|
+
logger.debug("Removed tabs from group:", tabsToRemove);
|
|
865
|
+
} catch (e) {
|
|
866
|
+
logger.debug("Failed to ungroup tabs:", tabsToRemove, e.message);
|
|
867
|
+
}
|
|
868
|
+
if (tabsToAdd.length > 0) if (groupId === void 0) {
|
|
869
|
+
const newGroupId = await chrome.tabs.group({ tabIds: tabsToAdd });
|
|
870
|
+
await chrome.tabGroups.update(newGroupId, {
|
|
871
|
+
title: TAB_GROUP_TITLE,
|
|
872
|
+
color: TAB_GROUP_COLOR
|
|
873
|
+
});
|
|
874
|
+
logger.debug("Created tab group:", newGroupId, "with tabs:", tabsToAdd);
|
|
875
|
+
} else {
|
|
876
|
+
await chrome.tabs.group({
|
|
877
|
+
tabIds: tabsToAdd,
|
|
878
|
+
groupId
|
|
879
|
+
});
|
|
880
|
+
await chrome.tabGroups.update(groupId, {
|
|
881
|
+
title: TAB_GROUP_TITLE,
|
|
882
|
+
color: TAB_GROUP_COLOR
|
|
883
|
+
});
|
|
884
|
+
logger.debug("Added tabs to existing group:", tabsToAdd);
|
|
885
|
+
}
|
|
886
|
+
else if (groupId !== void 0) await chrome.tabGroups.update(groupId, {
|
|
887
|
+
title: TAB_GROUP_TITLE,
|
|
888
|
+
color: TAB_GROUP_COLOR
|
|
889
|
+
});
|
|
890
|
+
} catch (error) {
|
|
891
|
+
logger.debug("Failed to sync tab group:", error.message);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function getTabBySessionId(sessionId) {
|
|
895
|
+
for (const [tabId, tab] of store.getState().tabs) if (tab.sessionId === sessionId) return {
|
|
896
|
+
tabId,
|
|
897
|
+
tab
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function getTabByTargetId(targetId) {
|
|
901
|
+
for (const [tabId, tab] of store.getState().tabs) if (tab.targetId === targetId) return {
|
|
902
|
+
tabId,
|
|
903
|
+
tab
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function emitChildDetachesForTab(tabId) {
|
|
907
|
+
Array.from(childSessions.entries()).filter(([_, parentTab]) => parentTab.tabId === tabId).forEach(([childSessionId, parentTab]) => {
|
|
908
|
+
sendMessage({
|
|
909
|
+
method: "forwardCDPEvent",
|
|
910
|
+
params: {
|
|
911
|
+
method: "Target.detachedFromTarget",
|
|
912
|
+
params: parentTab.targetId ? {
|
|
913
|
+
sessionId: childSessionId,
|
|
914
|
+
targetId: parentTab.targetId
|
|
915
|
+
} : { sessionId: childSessionId }
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
logger.debug("Cleaning up child session:", childSessionId, "for tab:", tabId);
|
|
919
|
+
childSessions.delete(childSessionId);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
function getTabForCommand(msg) {
|
|
923
|
+
const sessionId = msg.params.sessionId;
|
|
924
|
+
if (sessionId) {
|
|
925
|
+
const found = getTabBySessionId(sessionId);
|
|
926
|
+
if (found) return found;
|
|
927
|
+
const child = childSessions.get(sessionId);
|
|
928
|
+
if (child) {
|
|
929
|
+
const tab = store.getState().tabs.get(child.tabId);
|
|
930
|
+
if (tab) return {
|
|
931
|
+
tabId: child.tabId,
|
|
932
|
+
tab
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
const paramsSessionId = msg.params.params && "sessionId" in msg.params.params && typeof msg.params.params.sessionId === "string" ? msg.params.params.sessionId : void 0;
|
|
937
|
+
if (paramsSessionId) {
|
|
938
|
+
const found = getTabBySessionId(paramsSessionId);
|
|
939
|
+
if (found) return found;
|
|
940
|
+
const child = childSessions.get(paramsSessionId);
|
|
941
|
+
if (child) {
|
|
942
|
+
const tab = store.getState().tabs.get(child.tabId);
|
|
943
|
+
if (tab) return {
|
|
944
|
+
tabId: child.tabId,
|
|
945
|
+
tab
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const targetId = msg.params.params && "targetId" in msg.params.params && typeof msg.params.params.targetId === "string" ? msg.params.params.targetId : void 0;
|
|
950
|
+
if (targetId) return getTabByTargetId(targetId);
|
|
951
|
+
}
|
|
952
|
+
async function handleCommand(msg) {
|
|
953
|
+
if (msg.method !== "forwardCDPCommand") return;
|
|
954
|
+
const resolved = getTabForCommand(msg);
|
|
955
|
+
let targetTabId = resolved?.tabId;
|
|
956
|
+
let targetTab = resolved?.tab;
|
|
957
|
+
const debuggee = targetTabId ? { tabId: targetTabId } : void 0;
|
|
958
|
+
if (msg.params.method === "Target.setAutoAttach" && !msg.params.sessionId) {
|
|
959
|
+
const params = msg.params.params;
|
|
960
|
+
if (!params) return {};
|
|
961
|
+
autoAttachParams = params;
|
|
962
|
+
const connectedTabIds = Array.from(store.getState().tabs.entries()).filter(([_, info]) => info.state === "connected").map(([tabId]) => tabId);
|
|
963
|
+
await Promise.all(connectedTabIds.map(async (tabId) => {
|
|
964
|
+
try {
|
|
965
|
+
await chrome.debugger.sendCommand({ tabId }, "Target.setAutoAttach", params);
|
|
966
|
+
} catch (error) {
|
|
967
|
+
logger.debug("Failed to set auto-attach for tab:", tabId, error);
|
|
968
|
+
}
|
|
969
|
+
}));
|
|
970
|
+
return {};
|
|
971
|
+
}
|
|
972
|
+
switch (msg.params.method) {
|
|
973
|
+
case "Runtime.enable": {
|
|
974
|
+
if (!debuggee) throw new Error(`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`);
|
|
975
|
+
const runtimeSession = {
|
|
976
|
+
...debuggee,
|
|
977
|
+
sessionId: msg.params.sessionId !== targetTab?.sessionId ? msg.params.sessionId : void 0
|
|
978
|
+
};
|
|
979
|
+
try {
|
|
980
|
+
await chrome.debugger.sendCommand(runtimeSession, "Runtime.disable");
|
|
981
|
+
await sleep(50);
|
|
982
|
+
} catch (e) {
|
|
983
|
+
logger.debug("Error disabling Runtime (ignoring):", e);
|
|
984
|
+
}
|
|
985
|
+
return await chrome.debugger.sendCommand(runtimeSession, "Runtime.enable", msg.params.params);
|
|
986
|
+
}
|
|
987
|
+
case "Target.createTarget": {
|
|
988
|
+
const url = msg.params.params?.url || "about:blank";
|
|
989
|
+
logger.debug("Creating new tab with URL:", url);
|
|
990
|
+
const tab = await chrome.tabs.create({
|
|
991
|
+
url,
|
|
992
|
+
active: false
|
|
993
|
+
});
|
|
994
|
+
if (!tab.id) throw new Error("Failed to create tab");
|
|
995
|
+
setTabConnecting(tab.id);
|
|
996
|
+
logger.debug("Created tab:", tab.id, "waiting for it to load...");
|
|
997
|
+
await sleep(100);
|
|
998
|
+
const { targetInfo } = await attachTab(tab.id);
|
|
999
|
+
return { targetId: targetInfo.targetId };
|
|
1000
|
+
}
|
|
1001
|
+
case "Target.closeTarget":
|
|
1002
|
+
if (!targetTabId) {
|
|
1003
|
+
logger.log(`Target not found: ${msg.params.params?.targetId}`);
|
|
1004
|
+
return { success: false };
|
|
1005
|
+
}
|
|
1006
|
+
await chrome.tabs.remove(targetTabId);
|
|
1007
|
+
return { success: true };
|
|
1008
|
+
}
|
|
1009
|
+
if (!debuggee || !targetTab) {
|
|
1010
|
+
if (msg.params.method === "Target.detachFromTarget") return {};
|
|
1011
|
+
throw new Error(`No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId} params: ${JSON.stringify(msg.params.params || null)}`);
|
|
1012
|
+
}
|
|
1013
|
+
logger.debug("CDP command:", msg.params.method, "for tab:", targetTabId);
|
|
1014
|
+
const debuggerSession = {
|
|
1015
|
+
...debuggee,
|
|
1016
|
+
sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : void 0
|
|
1017
|
+
};
|
|
1018
|
+
return await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params);
|
|
1019
|
+
}
|
|
1020
|
+
function onDebuggerEvent(source, method, params) {
|
|
1021
|
+
const tab = source.tabId ? store.getState().tabs.get(source.tabId) : void 0;
|
|
1022
|
+
if (!tab) return;
|
|
1023
|
+
logger.debug("Forwarding CDP event:", method, "from tab:", source.tabId);
|
|
1024
|
+
if (method === "Target.attachedToTarget" && params?.sessionId) {
|
|
1025
|
+
const targetUrl = params.targetInfo?.url;
|
|
1026
|
+
if (isRestrictedUrl(targetUrl)) {
|
|
1027
|
+
logger.debug("Ignoring restricted child target:", targetUrl, "sessionId:", params.sessionId, "for tab:", source.tabId);
|
|
1028
|
+
if (source.tabId) chrome.debugger.sendCommand({ tabId: source.tabId }, "Target.detachFromTarget", { sessionId: params.sessionId }).catch((e) => {
|
|
1029
|
+
logger.debug("Failed to detach restricted child target (expected):", e);
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
logger.debug("Child target attached:", params.sessionId, "for tab:", source.tabId);
|
|
1034
|
+
const targetId = params.targetInfo?.targetId;
|
|
1035
|
+
childSessions.set(params.sessionId, {
|
|
1036
|
+
tabId: source.tabId,
|
|
1037
|
+
targetId
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
if (method === "Target.detachedFromTarget" && params?.sessionId) {
|
|
1041
|
+
const mainTab = getTabBySessionId(params.sessionId);
|
|
1042
|
+
if (mainTab) {
|
|
1043
|
+
logger.debug("Main tab detached via CDP event:", mainTab.tabId, "sessionId:", params.sessionId);
|
|
1044
|
+
store.setState((state) => {
|
|
1045
|
+
const newTabs = new Map(state.tabs);
|
|
1046
|
+
newTabs.delete(mainTab.tabId);
|
|
1047
|
+
return { tabs: newTabs };
|
|
1048
|
+
});
|
|
1049
|
+
emitChildDetachesForTab(mainTab.tabId);
|
|
1050
|
+
} else {
|
|
1051
|
+
logger.debug("Child target detached:", params.sessionId);
|
|
1052
|
+
childSessions.delete(params.sessionId);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
sendMessage({
|
|
1056
|
+
method: "forwardCDPEvent",
|
|
1057
|
+
params: {
|
|
1058
|
+
sessionId: source.sessionId || tab.sessionId,
|
|
1059
|
+
method,
|
|
1060
|
+
params
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
function onDebuggerDetach(source, reason) {
|
|
1065
|
+
const tabId = source.tabId;
|
|
1066
|
+
if (!tabId || !store.getState().tabs.has(tabId)) {
|
|
1067
|
+
logger.debug("Ignoring debugger detach event for untracked tab:", tabId);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (connectionManager.preserveTabsOnDetach) {
|
|
1071
|
+
logger.debug("Ignoring debugger detach during relay reconnect:", tabId, reason);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
logger.warn(`DISCONNECT: onDebuggerDetach tabId=${tabId} reason=${reason}`);
|
|
1075
|
+
const tab = store.getState().tabs.get(tabId);
|
|
1076
|
+
if (tab) sendMessage({
|
|
1077
|
+
method: "forwardCDPEvent",
|
|
1078
|
+
params: {
|
|
1079
|
+
method: "Target.detachedFromTarget",
|
|
1080
|
+
params: {
|
|
1081
|
+
sessionId: tab.sessionId,
|
|
1082
|
+
targetId: tab.targetId
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
emitChildDetachesForTab(tabId);
|
|
1087
|
+
store.setState((state) => {
|
|
1088
|
+
const newTabs = new Map(state.tabs);
|
|
1089
|
+
newTabs.delete(tabId);
|
|
1090
|
+
return { tabs: newTabs };
|
|
1091
|
+
});
|
|
1092
|
+
if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) store.setState({
|
|
1093
|
+
connectionState: "idle",
|
|
1094
|
+
errorText: void 0
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
async function removeRestrictedIframes(tabId) {
|
|
1098
|
+
try {
|
|
1099
|
+
const totalRemoved = (await chrome.scripting.executeScript({
|
|
1100
|
+
target: {
|
|
1101
|
+
tabId,
|
|
1102
|
+
allFrames: true
|
|
1103
|
+
},
|
|
1104
|
+
func: (ownExtIds) => {
|
|
1105
|
+
const roots = [document];
|
|
1106
|
+
document.querySelectorAll("*").forEach((el) => {
|
|
1107
|
+
const shadow = el.shadowRoot;
|
|
1108
|
+
if (shadow) roots.push(shadow);
|
|
1109
|
+
});
|
|
1110
|
+
let removed = 0;
|
|
1111
|
+
for (const root of roots) root.querySelectorAll("iframe").forEach((iframe) => {
|
|
1112
|
+
const src = iframe.src || iframe.getAttribute("src") || "";
|
|
1113
|
+
if (!src.startsWith("chrome-extension://")) return;
|
|
1114
|
+
const extId = src.replace("chrome-extension://", "").split("/")[0];
|
|
1115
|
+
if (ownExtIds.includes(extId)) return;
|
|
1116
|
+
iframe.remove();
|
|
1117
|
+
removed++;
|
|
1118
|
+
});
|
|
1119
|
+
return removed;
|
|
1120
|
+
},
|
|
1121
|
+
args: [OUR_EXTENSION_IDS]
|
|
1122
|
+
})).reduce((sum, r) => sum + (r.result ?? 0), 0);
|
|
1123
|
+
if (totalRemoved > 0) logger.debug(`Removed ${totalRemoved} restricted chrome-extension:// iframe(s) from tab:`, tabId);
|
|
1124
|
+
return totalRemoved;
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
logger.debug("Could not remove restricted iframes (expected on some pages):", e.message);
|
|
1127
|
+
return 0;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async function attachTab(tabId, { skipAttachedEvent = false } = {}) {
|
|
1131
|
+
const debuggee = { tabId };
|
|
1132
|
+
let debuggerAttached = false;
|
|
1133
|
+
try {
|
|
1134
|
+
logger.debug("Attaching debugger to tab:", tabId);
|
|
1135
|
+
const maxAttachAttempts = 3;
|
|
1136
|
+
for (let attempt = 1; attempt <= maxAttachAttempts; attempt++) try {
|
|
1137
|
+
await chrome.debugger.attach(debuggee, "1.3");
|
|
1138
|
+
break;
|
|
1139
|
+
} catch (attachError) {
|
|
1140
|
+
const msg = attachError.message ?? "";
|
|
1141
|
+
if (!(msg.includes("chrome-extension://") || msg.includes("different extension")) || attempt === maxAttachAttempts) throw attachError;
|
|
1142
|
+
logger.debug(`Debugger attach blocked by chrome-extension:// iframe (attempt ${attempt}/${maxAttachAttempts}), removing and retrying:`, tabId);
|
|
1143
|
+
await removeRestrictedIframes(tabId);
|
|
1144
|
+
await sleep(50);
|
|
1145
|
+
}
|
|
1146
|
+
debuggerAttached = true;
|
|
1147
|
+
logger.debug("Debugger attached successfully to tab:", tabId);
|
|
1148
|
+
await chrome.debugger.sendCommand(debuggee, "Page.enable");
|
|
1149
|
+
if (autoAttachParams) try {
|
|
1150
|
+
await chrome.debugger.sendCommand(debuggee, "Target.setAutoAttach", autoAttachParams);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
logger.debug("Failed to apply auto-attach for tab:", tabId, error);
|
|
1153
|
+
}
|
|
1154
|
+
const contextMenuScript = `
|
|
1155
|
+
document.addEventListener('contextmenu', (e) => {
|
|
1156
|
+
window.__playwriter_lastRightClicked = e.target;
|
|
1157
|
+
}, true);
|
|
1158
|
+
`;
|
|
1159
|
+
await chrome.debugger.sendCommand(debuggee, "Page.addScriptToEvaluateOnNewDocument", { source: contextMenuScript });
|
|
1160
|
+
await chrome.debugger.sendCommand(debuggee, "Runtime.evaluate", { expression: contextMenuScript });
|
|
1161
|
+
const targetInfo = (await chrome.debugger.sendCommand(debuggee, "Target.getTargetInfo")).targetInfo;
|
|
1162
|
+
if (!targetInfo.url || targetInfo.url === "" || targetInfo.url === ":") logger.error("WARNING: Target.attachedToTarget will be sent with empty URL! tabId:", tabId, "targetInfo:", JSON.stringify(targetInfo));
|
|
1163
|
+
const attachOrder = nextSessionId;
|
|
1164
|
+
const sessionId = `pw-tab-${tabSessionScope}-${nextSessionId++}`;
|
|
1165
|
+
store.setState((state) => {
|
|
1166
|
+
const newTabs = new Map(state.tabs);
|
|
1167
|
+
newTabs.set(tabId, {
|
|
1168
|
+
sessionId,
|
|
1169
|
+
targetId: targetInfo.targetId,
|
|
1170
|
+
state: "connected",
|
|
1171
|
+
attachOrder
|
|
1172
|
+
});
|
|
1173
|
+
return {
|
|
1174
|
+
tabs: newTabs,
|
|
1175
|
+
connectionState: "connected",
|
|
1176
|
+
errorText: void 0
|
|
1177
|
+
};
|
|
1178
|
+
});
|
|
1179
|
+
if (!skipAttachedEvent) sendMessage({
|
|
1180
|
+
method: "forwardCDPEvent",
|
|
1181
|
+
params: {
|
|
1182
|
+
method: "Target.attachedToTarget",
|
|
1183
|
+
params: {
|
|
1184
|
+
sessionId,
|
|
1185
|
+
targetInfo: {
|
|
1186
|
+
...targetInfo,
|
|
1187
|
+
attached: true
|
|
1188
|
+
},
|
|
1189
|
+
waitingForDebugger: false
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
logger.debug("Tab attached successfully:", tabId, "sessionId:", sessionId, "targetId:", targetInfo.targetId, "url:", targetInfo.url, "skipAttachedEvent:", skipAttachedEvent);
|
|
1194
|
+
return {
|
|
1195
|
+
targetInfo,
|
|
1196
|
+
sessionId
|
|
1197
|
+
};
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
if (debuggerAttached) {
|
|
1200
|
+
logger.debug("Cleaning up debugger after partial attach failure:", tabId);
|
|
1201
|
+
chrome.debugger.detach(debuggee).catch(() => {});
|
|
1202
|
+
}
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function detachTab(tabId, shouldDetachDebugger) {
|
|
1207
|
+
const tab = store.getState().tabs.get(tabId);
|
|
1208
|
+
if (!tab) {
|
|
1209
|
+
logger.debug("detachTab: tab not found in map:", tabId);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
cleanupRecordingForTab(tabId);
|
|
1213
|
+
logger.warn(`DISCONNECT: detachTab tabId=${tabId} shouldDetach=${shouldDetachDebugger} stack=${getCallStack()}`);
|
|
1214
|
+
if (tab.sessionId && tab.targetId) sendMessage({
|
|
1215
|
+
method: "forwardCDPEvent",
|
|
1216
|
+
params: {
|
|
1217
|
+
method: "Target.detachedFromTarget",
|
|
1218
|
+
params: {
|
|
1219
|
+
sessionId: tab.sessionId,
|
|
1220
|
+
targetId: tab.targetId
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
store.setState((state) => {
|
|
1225
|
+
const newTabs = new Map(state.tabs);
|
|
1226
|
+
newTabs.delete(tabId);
|
|
1227
|
+
return { tabs: newTabs };
|
|
1228
|
+
});
|
|
1229
|
+
emitChildDetachesForTab(tabId);
|
|
1230
|
+
if (shouldDetachDebugger) chrome.debugger.detach({ tabId }).catch((err) => {
|
|
1231
|
+
logger.debug("Error detaching debugger from tab:", tabId, err.message);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
async function connectTab(tabId) {
|
|
1235
|
+
try {
|
|
1236
|
+
logger.debug(`Starting connection to tab ${tabId}`);
|
|
1237
|
+
setTabConnecting(tabId);
|
|
1238
|
+
await connectionManager.ensureConnection();
|
|
1239
|
+
await attachTab(tabId);
|
|
1240
|
+
logger.debug(`Successfully connected to tab ${tabId}`);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
logger.debug(`Failed to connect to tab ${tabId}:`, error);
|
|
1243
|
+
const isExtensionInUse = error.message === "Extension Already In Use" || error.message === "Another Playwriter extension is already connected";
|
|
1244
|
+
const isWsError = error.message === "Server not available" || error.message === "Connection timeout" || error.message.startsWith("WebSocket");
|
|
1245
|
+
if (isExtensionInUse) {
|
|
1246
|
+
logger.debug(`Another extension is in use, entering polling mode`);
|
|
1247
|
+
store.setState((state) => {
|
|
1248
|
+
const newTabs = new Map(state.tabs);
|
|
1249
|
+
newTabs.delete(tabId);
|
|
1250
|
+
return {
|
|
1251
|
+
tabs: newTabs,
|
|
1252
|
+
connectionState: "extension-replaced",
|
|
1253
|
+
errorText: "Another Playwriter extension is actively in use"
|
|
1254
|
+
};
|
|
1255
|
+
});
|
|
1256
|
+
} else if (isWsError) logger.debug(`WS connection failed, keeping tab ${tabId} in connecting state for retry`);
|
|
1257
|
+
else store.setState((state) => {
|
|
1258
|
+
const newTabs = new Map(state.tabs);
|
|
1259
|
+
newTabs.set(tabId, {
|
|
1260
|
+
state: "error",
|
|
1261
|
+
errorText: `Error: ${error.message}`
|
|
1262
|
+
});
|
|
1263
|
+
return { tabs: newTabs };
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
function setTabConnecting(tabId) {
|
|
1268
|
+
store.setState((state) => {
|
|
1269
|
+
const newTabs = new Map(state.tabs);
|
|
1270
|
+
const existing = newTabs.get(tabId);
|
|
1271
|
+
newTabs.set(tabId, {
|
|
1272
|
+
...existing,
|
|
1273
|
+
state: "connecting"
|
|
1274
|
+
});
|
|
1275
|
+
return { tabs: newTabs };
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
async function disconnectTab(tabId) {
|
|
1279
|
+
logger.debug(`Disconnecting tab ${tabId}`);
|
|
1280
|
+
const { tabs } = store.getState();
|
|
1281
|
+
if (!tabs.has(tabId)) {
|
|
1282
|
+
logger.debug("Tab not in tabs map, ignoring disconnect");
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
detachTab(tabId, true);
|
|
1286
|
+
}
|
|
1287
|
+
async function toggleExtensionForActiveTab() {
|
|
1288
|
+
const tab = (await chrome.tabs.query({
|
|
1289
|
+
active: true,
|
|
1290
|
+
currentWindow: true
|
|
1291
|
+
}))[0];
|
|
1292
|
+
if (!tab?.id) throw new Error("No active tab found");
|
|
1293
|
+
await onActionClicked(tab);
|
|
1294
|
+
await new Promise((resolve) => {
|
|
1295
|
+
const check = () => {
|
|
1296
|
+
if (store.getState().tabs.get(tab.id)?.state === "connecting") {
|
|
1297
|
+
setTimeout(check, 100);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
resolve();
|
|
1301
|
+
};
|
|
1302
|
+
check();
|
|
1303
|
+
});
|
|
1304
|
+
const state = store.getState();
|
|
1305
|
+
return {
|
|
1306
|
+
isConnected: state.tabs.has(tab.id) && state.tabs.get(tab.id)?.state === "connected",
|
|
1307
|
+
state
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
async function disconnectEverything() {
|
|
1311
|
+
tabGroupQueue = tabGroupQueue.then(async () => {
|
|
1312
|
+
const { tabs } = store.getState();
|
|
1313
|
+
for (const tabId of tabs.keys()) await disconnectTab(tabId);
|
|
1314
|
+
});
|
|
1315
|
+
await tabGroupQueue;
|
|
1316
|
+
}
|
|
1317
|
+
async function resetDebugger() {
|
|
1318
|
+
let targets = await chrome.debugger.getTargets();
|
|
1319
|
+
targets = targets.filter((x) => x.tabId && x.attached);
|
|
1320
|
+
logger.log(`found ${targets.length} existing debugger targets. detaching them before background script starts`);
|
|
1321
|
+
for (const target of targets) await chrome.debugger.detach({ tabId: target.tabId });
|
|
1322
|
+
}
|
|
1323
|
+
var OUR_EXTENSION_IDS = ["jfeammnjpkecdekppnclgkkffahnhfhe", "pebbngnfojnignonigcnkdilknapkgid"];
|
|
1324
|
+
function isRestrictedUrl(url) {
|
|
1325
|
+
if (!url) return false;
|
|
1326
|
+
if (url.startsWith("chrome-extension://")) {
|
|
1327
|
+
const extensionId = url.replace("chrome-extension://", "").split("/")[0];
|
|
1328
|
+
return !OUR_EXTENSION_IDS.includes(extensionId);
|
|
1329
|
+
}
|
|
1330
|
+
return [
|
|
1331
|
+
"chrome://",
|
|
1332
|
+
"devtools://",
|
|
1333
|
+
"edge://",
|
|
1334
|
+
"https://chrome.google.com/",
|
|
1335
|
+
"https://chromewebstore.google.com/"
|
|
1336
|
+
].some((prefix) => url.startsWith(prefix));
|
|
1337
|
+
}
|
|
1338
|
+
var icons = {
|
|
1339
|
+
connected: {
|
|
1340
|
+
path: {
|
|
1341
|
+
"16": "/icons/icon-green-16.png",
|
|
1342
|
+
"32": "/icons/icon-green-32.png",
|
|
1343
|
+
"48": "/icons/icon-green-48.png",
|
|
1344
|
+
"128": "/icons/icon-green-128.png"
|
|
1345
|
+
},
|
|
1346
|
+
title: "Connected - Click to disconnect",
|
|
1347
|
+
badgeText: "",
|
|
1348
|
+
badgeColor: [
|
|
1349
|
+
64,
|
|
1350
|
+
64,
|
|
1351
|
+
64,
|
|
1352
|
+
255
|
|
1353
|
+
]
|
|
1354
|
+
},
|
|
1355
|
+
connecting: {
|
|
1356
|
+
path: {
|
|
1357
|
+
"16": "/icons/icon-gray-16.png",
|
|
1358
|
+
"32": "/icons/icon-gray-32.png",
|
|
1359
|
+
"48": "/icons/icon-gray-48.png",
|
|
1360
|
+
"128": "/icons/icon-gray-128.png"
|
|
1361
|
+
},
|
|
1362
|
+
title: "Waiting for MCP WS server...",
|
|
1363
|
+
badgeText: "...",
|
|
1364
|
+
badgeColor: [
|
|
1365
|
+
64,
|
|
1366
|
+
64,
|
|
1367
|
+
64,
|
|
1368
|
+
255
|
|
1369
|
+
]
|
|
1370
|
+
},
|
|
1371
|
+
idle: {
|
|
1372
|
+
path: {
|
|
1373
|
+
"16": "/icons/icon-black-16.png",
|
|
1374
|
+
"32": "/icons/icon-black-32.png",
|
|
1375
|
+
"48": "/icons/icon-black-48.png",
|
|
1376
|
+
"128": "/icons/icon-black-128.png"
|
|
1377
|
+
},
|
|
1378
|
+
title: "Click to attach debugger",
|
|
1379
|
+
badgeText: "",
|
|
1380
|
+
badgeColor: [
|
|
1381
|
+
64,
|
|
1382
|
+
64,
|
|
1383
|
+
64,
|
|
1384
|
+
255
|
|
1385
|
+
]
|
|
1386
|
+
},
|
|
1387
|
+
restricted: {
|
|
1388
|
+
path: {
|
|
1389
|
+
"16": "/icons/icon-gray-16.png",
|
|
1390
|
+
"32": "/icons/icon-gray-32.png",
|
|
1391
|
+
"48": "/icons/icon-gray-48.png",
|
|
1392
|
+
"128": "/icons/icon-gray-128.png"
|
|
1393
|
+
},
|
|
1394
|
+
title: "Cannot attach to this page",
|
|
1395
|
+
badgeText: "",
|
|
1396
|
+
badgeColor: [
|
|
1397
|
+
64,
|
|
1398
|
+
64,
|
|
1399
|
+
64,
|
|
1400
|
+
255
|
|
1401
|
+
]
|
|
1402
|
+
},
|
|
1403
|
+
extensionReplaced: {
|
|
1404
|
+
path: {
|
|
1405
|
+
"16": "/icons/icon-gray-16.png",
|
|
1406
|
+
"32": "/icons/icon-gray-32.png",
|
|
1407
|
+
"48": "/icons/icon-gray-48.png",
|
|
1408
|
+
"128": "/icons/icon-gray-128.png"
|
|
1409
|
+
},
|
|
1410
|
+
title: "Another Playwriter extension connected - Click to retry",
|
|
1411
|
+
badgeText: "!",
|
|
1412
|
+
badgeColor: [
|
|
1413
|
+
220,
|
|
1414
|
+
38,
|
|
1415
|
+
38,
|
|
1416
|
+
255
|
|
1417
|
+
]
|
|
1418
|
+
},
|
|
1419
|
+
tabError: {
|
|
1420
|
+
path: {
|
|
1421
|
+
"16": "/icons/icon-gray-16.png",
|
|
1422
|
+
"32": "/icons/icon-gray-32.png",
|
|
1423
|
+
"48": "/icons/icon-gray-48.png",
|
|
1424
|
+
"128": "/icons/icon-gray-128.png"
|
|
1425
|
+
},
|
|
1426
|
+
title: "Error",
|
|
1427
|
+
badgeText: "!",
|
|
1428
|
+
badgeColor: [
|
|
1429
|
+
220,
|
|
1430
|
+
38,
|
|
1431
|
+
38,
|
|
1432
|
+
255
|
|
1433
|
+
]
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
async function updateIcons() {
|
|
1437
|
+
const { connectionState, tabs, errorText } = store.getState();
|
|
1438
|
+
const connectedCount = Array.from(tabs.values()).filter((t) => t.state === "connected").length;
|
|
1439
|
+
const allTabs = await chrome.tabs.query({});
|
|
1440
|
+
const tabUrlMap = new Map(allTabs.map((tab) => [tab.id, tab.url]));
|
|
1441
|
+
const allTabIds = [void 0, ...allTabs.map((tab) => tab.id).filter((id) => id !== void 0)];
|
|
1442
|
+
for (const tabId of allTabIds) {
|
|
1443
|
+
const tabInfo = tabId !== void 0 ? tabs.get(tabId) : void 0;
|
|
1444
|
+
const tabUrl = tabId !== void 0 ? tabUrlMap.get(tabId) : void 0;
|
|
1445
|
+
const iconConfig = (() => {
|
|
1446
|
+
if (connectionState === "extension-replaced") return icons.extensionReplaced;
|
|
1447
|
+
if (tabId !== void 0 && isRestrictedUrl(tabUrl)) return icons.restricted;
|
|
1448
|
+
if (tabInfo?.state === "error") return icons.tabError;
|
|
1449
|
+
if (tabInfo?.state === "connecting") return icons.connecting;
|
|
1450
|
+
if (tabInfo?.state === "connected") return icons.connected;
|
|
1451
|
+
return icons.idle;
|
|
1452
|
+
})();
|
|
1453
|
+
const title = (() => {
|
|
1454
|
+
if (connectionState === "extension-replaced" && errorText) return errorText;
|
|
1455
|
+
if (tabInfo?.errorText) return tabInfo.errorText;
|
|
1456
|
+
return iconConfig.title;
|
|
1457
|
+
})();
|
|
1458
|
+
const badgeText = (() => {
|
|
1459
|
+
if (iconConfig === icons.connected || iconConfig === icons.idle || iconConfig === icons.restricted) return connectedCount > 0 ? String(connectedCount) : "";
|
|
1460
|
+
return iconConfig.badgeText;
|
|
1461
|
+
})();
|
|
1462
|
+
chrome.action.setIcon({
|
|
1463
|
+
tabId,
|
|
1464
|
+
path: iconConfig.path
|
|
1465
|
+
});
|
|
1466
|
+
chrome.action.setTitle({
|
|
1467
|
+
tabId,
|
|
1468
|
+
title
|
|
1469
|
+
});
|
|
1470
|
+
if (iconConfig.badgeColor) chrome.action.setBadgeBackgroundColor({
|
|
1471
|
+
tabId,
|
|
1472
|
+
color: iconConfig.badgeColor
|
|
1473
|
+
});
|
|
1474
|
+
chrome.action.setBadgeText({
|
|
1475
|
+
tabId,
|
|
1476
|
+
text: badgeText
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
async function onTabRemoved(tabId) {
|
|
1481
|
+
const { tabs } = store.getState();
|
|
1482
|
+
if (!tabs.has(tabId)) return;
|
|
1483
|
+
logger.debug(`Connected tab ${tabId} was closed, disconnecting`);
|
|
1484
|
+
await disconnectTab(tabId);
|
|
1485
|
+
}
|
|
1486
|
+
async function onTabActivated(activeInfo) {
|
|
1487
|
+
store.setState({ currentTabId: activeInfo.tabId });
|
|
1488
|
+
}
|
|
1489
|
+
async function onActionClicked(tab) {
|
|
1490
|
+
if (!tab.id) {
|
|
1491
|
+
logger.debug("No tab ID available");
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (isRestrictedUrl(tab.url)) {
|
|
1495
|
+
logger.debug("Cannot attach to restricted URL:", tab.url);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const { tabs, connectionState } = store.getState();
|
|
1499
|
+
const tabInfo = tabs.get(tab.id);
|
|
1500
|
+
if (connectionState === "extension-replaced") {
|
|
1501
|
+
logger.debug("Clearing extension-replaced state, attempting to reconnect");
|
|
1502
|
+
store.setState({
|
|
1503
|
+
connectionState: "idle",
|
|
1504
|
+
errorText: void 0
|
|
1505
|
+
});
|
|
1506
|
+
await connectTab(tab.id);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
if (tabInfo?.state === "error") {
|
|
1510
|
+
logger.debug("Tab has error - disconnecting to clear state");
|
|
1511
|
+
await disconnectTab(tab.id);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
if (tabInfo?.state === "connecting") {
|
|
1515
|
+
logger.debug("Tab is already connecting, ignoring click");
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
if (tabInfo?.state === "connected") await disconnectTab(tab.id);
|
|
1519
|
+
else await connectTab(tab.id);
|
|
1520
|
+
}
|
|
1521
|
+
resetDebugger();
|
|
1522
|
+
connectionManager.maintainLoop();
|
|
1523
|
+
chrome.contextMenus.remove("playwriter-pin-element").catch(() => {}).finally(() => {
|
|
1524
|
+
chrome.contextMenus?.create({
|
|
1525
|
+
id: "playwriter-pin-element",
|
|
1526
|
+
title: "Copy Playwriter Element Reference",
|
|
1527
|
+
contexts: ["all"],
|
|
1528
|
+
visible: false
|
|
1529
|
+
});
|
|
1530
|
+
});
|
|
1531
|
+
function updateContextMenuVisibility() {
|
|
1532
|
+
const { currentTabId, tabs } = store.getState();
|
|
1533
|
+
const isConnected = currentTabId !== void 0 && tabs.get(currentTabId)?.state === "connected";
|
|
1534
|
+
chrome.contextMenus?.update("playwriter-pin-element", { visible: isConnected });
|
|
1535
|
+
}
|
|
1536
|
+
chrome.runtime.onInstalled.addListener((details) => {});
|
|
1537
|
+
function serializeTabs(tabs) {
|
|
1538
|
+
return JSON.stringify(Array.from(tabs.entries()));
|
|
1539
|
+
}
|
|
1540
|
+
store.subscribe((state, prevState) => {
|
|
1541
|
+
logger.log(state);
|
|
1542
|
+
updateIcons();
|
|
1543
|
+
updateContextMenuVisibility();
|
|
1544
|
+
if (serializeTabs(state.tabs) !== serializeTabs(prevState.tabs)) tabGroupQueue = tabGroupQueue.then(syncTabGroup).catch((e) => {
|
|
1545
|
+
logger.debug("syncTabGroup error:", e);
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
logger.debug(`Using relay host: ${RELAY_HOST}, port: ${RELAY_PORT}`);
|
|
1549
|
+
var lastMemoryUsage = 0;
|
|
1550
|
+
var lastMemoryCheck = Date.now();
|
|
1551
|
+
var MEMORY_WARNING_THRESHOLD = 50 * 1024 * 1024;
|
|
1552
|
+
var MEMORY_CRITICAL_THRESHOLD = 100 * 1024 * 1024;
|
|
1553
|
+
var MEMORY_GROWTH_THRESHOLD = 10 * 1024 * 1024;
|
|
1554
|
+
function checkMemory() {
|
|
1555
|
+
try {
|
|
1556
|
+
const memory = performance.memory;
|
|
1557
|
+
if (!memory) return;
|
|
1558
|
+
const used = memory.usedJSHeapSize;
|
|
1559
|
+
const total = memory.totalJSHeapSize;
|
|
1560
|
+
const limit = memory.jsHeapSizeLimit;
|
|
1561
|
+
const now = Date.now();
|
|
1562
|
+
const timeDelta = now - lastMemoryCheck;
|
|
1563
|
+
const memoryDelta = used - lastMemoryUsage;
|
|
1564
|
+
const formatMB = (bytes) => (bytes / 1024 / 1024).toFixed(2) + "MB";
|
|
1565
|
+
const growthRate = timeDelta > 0 ? memoryDelta / timeDelta * 1e3 : 0;
|
|
1566
|
+
if (used > MEMORY_CRITICAL_THRESHOLD) logger.error(`MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`);
|
|
1567
|
+
else if (used > MEMORY_WARNING_THRESHOLD) logger.warn(`MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`);
|
|
1568
|
+
else if (memoryDelta > MEMORY_GROWTH_THRESHOLD && timeDelta < 6e4) logger.warn(`MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1e3).toFixed(1)}s (used=${formatMB(used)})`);
|
|
1569
|
+
lastMemoryUsage = used;
|
|
1570
|
+
lastMemoryCheck = now;
|
|
1571
|
+
} catch (e) {}
|
|
1572
|
+
}
|
|
1573
|
+
setInterval(checkMemory, 5e3);
|
|
1574
|
+
checkMemory();
|
|
1575
|
+
chrome.tabs.onRemoved.addListener(onTabRemoved);
|
|
1576
|
+
chrome.tabs.onActivated.addListener(onTabActivated);
|
|
1577
|
+
chrome.action.onClicked.addListener(onActionClicked);
|
|
1578
|
+
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
1579
|
+
updateIcons();
|
|
1580
|
+
if (changeInfo.groupId !== void 0) tabGroupQueue = tabGroupQueue.then(async () => {
|
|
1581
|
+
const groupId = (await chrome.tabGroups.query({ title: TAB_GROUP_TITLE }))[0]?.id;
|
|
1582
|
+
if (groupId === void 0) return;
|
|
1583
|
+
const { tabs } = store.getState();
|
|
1584
|
+
if (changeInfo.groupId === groupId) {
|
|
1585
|
+
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
|
|
1586
|
+
logger.debug("Tab manually added to playwriter group:", tabId);
|
|
1587
|
+
await connectTab(tabId);
|
|
1588
|
+
}
|
|
1589
|
+
} else if (tabs.has(tabId)) {
|
|
1590
|
+
if (tabs.get(tabId)?.state === "connecting") {
|
|
1591
|
+
logger.debug("Tab removed from group while connecting, ignoring:", tabId);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
logger.debug("Tab manually removed from playwriter group:", tabId);
|
|
1595
|
+
await disconnectTab(tabId);
|
|
1596
|
+
}
|
|
1597
|
+
}).catch((e) => {
|
|
1598
|
+
logger.debug("onTabUpdated handler error:", e);
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
chrome.contextMenus?.onClicked.addListener(async (info, tab) => {
|
|
1602
|
+
if (info.menuItemId !== "playwriter-pin-element" || !tab?.id) return;
|
|
1603
|
+
const tabInfo = store.getState().tabs.get(tab.id);
|
|
1604
|
+
if (!tabInfo || tabInfo.state !== "connected") {
|
|
1605
|
+
logger.debug("Tab not connected, ignoring");
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const debuggee = { tabId: tab.id };
|
|
1609
|
+
const count = (tabInfo.pinnedCount || 0) + 1;
|
|
1610
|
+
store.setState((state) => {
|
|
1611
|
+
const newTabs = new Map(state.tabs);
|
|
1612
|
+
const existing = newTabs.get(tab.id);
|
|
1613
|
+
if (existing) newTabs.set(tab.id, {
|
|
1614
|
+
...existing,
|
|
1615
|
+
pinnedCount: count
|
|
1616
|
+
});
|
|
1617
|
+
return { tabs: newTabs };
|
|
1618
|
+
});
|
|
1619
|
+
const name = `playwriterPinnedElem${count}`;
|
|
1620
|
+
const connectedTabs = Array.from(store.getState().tabs.entries()).filter(([_, t]) => t.state === "connected").sort((a, b) => (a[1].attachOrder ?? 0) - (b[1].attachOrder ?? 0));
|
|
1621
|
+
const pageIndex = connectedTabs.findIndex(([id]) => id === tab.id);
|
|
1622
|
+
const hasMultiplePages = connectedTabs.length > 1;
|
|
1623
|
+
try {
|
|
1624
|
+
const result = await chrome.debugger.sendCommand(debuggee, "Runtime.evaluate", {
|
|
1625
|
+
expression: `
|
|
1626
|
+
if (window.__playwriter_lastRightClicked) {
|
|
1627
|
+
window.${name} = window.__playwriter_lastRightClicked;
|
|
1628
|
+
'${name}';
|
|
1629
|
+
} else {
|
|
1630
|
+
throw new Error('No element was right-clicked');
|
|
1631
|
+
}
|
|
1632
|
+
`,
|
|
1633
|
+
returnByValue: true
|
|
1634
|
+
});
|
|
1635
|
+
if (result.exceptionDetails) {
|
|
1636
|
+
logger.error("Failed to pin element:", result.exceptionDetails.text);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const clipboardText = hasMultiplePages ? `globalThis.${name} (page ${pageIndex}, ${tab.url || "unknown url"})` : `globalThis.${name}`;
|
|
1640
|
+
await chrome.debugger.sendCommand(debuggee, "Runtime.evaluate", {
|
|
1641
|
+
expression: `
|
|
1642
|
+
(() => {
|
|
1643
|
+
const el = window.${name};
|
|
1644
|
+
if (!el) return;
|
|
1645
|
+
const orig = el.getAttribute('style') || '';
|
|
1646
|
+
el.setAttribute('style', orig + '; outline: 3px solid #22c55e !important; outline-offset: 2px !important; box-shadow: 0 0 0 3px #22c55e !important;');
|
|
1647
|
+
setTimeout(() => el.setAttribute('style', orig), 300);
|
|
1648
|
+
return navigator.clipboard.writeText(${JSON.stringify(clipboardText)});
|
|
1649
|
+
})()
|
|
1650
|
+
`,
|
|
1651
|
+
awaitPromise: true
|
|
1652
|
+
});
|
|
1653
|
+
logger.debug("Pinned element as:", name);
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
logger.error("Failed to pin element:", error.message);
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
updateIcons();
|
|
1659
|
+
chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
|
|
1660
|
+
if (message.action === "recordingChunk") {
|
|
1661
|
+
const { tabId, data, final } = message;
|
|
1662
|
+
if (connectionManager.ws?.readyState === WebSocket.OPEN) {
|
|
1663
|
+
sendMessage({
|
|
1664
|
+
method: "recordingData",
|
|
1665
|
+
params: {
|
|
1666
|
+
tabId,
|
|
1667
|
+
final
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
if (data && !final) {
|
|
1671
|
+
const buffer = new Uint8Array(data);
|
|
1672
|
+
connectionManager.ws.send(buffer);
|
|
1673
|
+
}
|
|
1674
|
+
} else {
|
|
1675
|
+
logger.debug(`Buffering recording chunk for tab ${tabId} (WebSocket not ready)`);
|
|
1676
|
+
recordingChunkBuffer.push({
|
|
1677
|
+
tabId,
|
|
1678
|
+
data,
|
|
1679
|
+
final
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
if (message.action === "recordingCancelled") {
|
|
1685
|
+
const { tabId } = message;
|
|
1686
|
+
getActiveRecordings().delete(tabId);
|
|
1687
|
+
store.setState((state) => {
|
|
1688
|
+
const newTabs = new Map(state.tabs);
|
|
1689
|
+
const existing = newTabs.get(tabId);
|
|
1690
|
+
if (existing) newTabs.set(tabId, {
|
|
1691
|
+
...existing,
|
|
1692
|
+
isRecording: false
|
|
1693
|
+
});
|
|
1694
|
+
return { tabs: newTabs };
|
|
1695
|
+
});
|
|
1696
|
+
if (connectionManager.ws?.readyState === WebSocket.OPEN) sendMessage({
|
|
1697
|
+
method: "recordingCancelled",
|
|
1698
|
+
params: { tabId }
|
|
1699
|
+
});
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
return false;
|
|
1703
|
+
});
|
|
1704
|
+
//#endregion
|