kiro-mobile-bridge 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -21
- package/package.json +1 -1
- package/src/public/index.html +1162 -1623
- package/src/routes/api.js +358 -0
- package/src/server.js +253 -2575
- package/src/services/cdp.js +156 -0
- package/src/services/click.js +282 -0
- package/src/services/message.js +206 -0
- package/src/services/snapshot.js +331 -0
- package/src/utils/hash.js +22 -0
- package/src/utils/network.js +20 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome DevTools Protocol (CDP) connection service
|
|
3
|
+
*/
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import { WebSocket } from 'ws';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetch JSON from a CDP endpoint
|
|
9
|
+
* @param {number} port - The port to fetch from
|
|
10
|
+
* @param {string} path - The path to fetch (default: /json/list)
|
|
11
|
+
* @returns {Promise<any>} - Parsed JSON response
|
|
12
|
+
*/
|
|
13
|
+
export function fetchCDPTargets(port, path = '/json/list') {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const url = `http://127.0.0.1:${port}${path}`;
|
|
16
|
+
|
|
17
|
+
const req = http.get(url, { timeout: 2000 }, (res) => {
|
|
18
|
+
let data = '';
|
|
19
|
+
res.on('data', chunk => data += chunk);
|
|
20
|
+
res.on('end', () => {
|
|
21
|
+
try {
|
|
22
|
+
resolve(JSON.parse(data));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
req.on('error', (err) => reject(new Error(`Failed to fetch ${url}: ${err.message}`)));
|
|
30
|
+
req.on('timeout', () => {
|
|
31
|
+
req.destroy();
|
|
32
|
+
reject(new Error(`Timeout fetching ${url}`));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a CDP connection to a target
|
|
39
|
+
* @param {string} wsUrl - WebSocket debugger URL
|
|
40
|
+
* @returns {Promise<CDPConnection>} - CDP connection object
|
|
41
|
+
*/
|
|
42
|
+
export function connectToCDP(wsUrl) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const ws = new WebSocket(wsUrl);
|
|
45
|
+
let idCounter = 1;
|
|
46
|
+
const pendingCalls = new Map();
|
|
47
|
+
const contexts = [];
|
|
48
|
+
let rootContextId = null;
|
|
49
|
+
let isConnected = false;
|
|
50
|
+
|
|
51
|
+
ws.on('message', (rawMsg) => {
|
|
52
|
+
try {
|
|
53
|
+
const msg = JSON.parse(rawMsg.toString());
|
|
54
|
+
|
|
55
|
+
if (msg.method === 'Runtime.executionContextCreated') {
|
|
56
|
+
const ctx = msg.params.context;
|
|
57
|
+
contexts.push(ctx);
|
|
58
|
+
if (rootContextId === null || ctx.auxData?.isDefault) {
|
|
59
|
+
rootContextId = ctx.id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (msg.method === 'Runtime.executionContextDestroyed') {
|
|
64
|
+
const ctxId = msg.params.executionContextId;
|
|
65
|
+
const idx = contexts.findIndex(c => c.id === ctxId);
|
|
66
|
+
if (idx !== -1) contexts.splice(idx, 1);
|
|
67
|
+
if (rootContextId === ctxId) {
|
|
68
|
+
rootContextId = contexts.length > 0 ? contexts[0].id : null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (msg.method === 'Runtime.executionContextsCleared') {
|
|
73
|
+
contexts.length = 0;
|
|
74
|
+
rootContextId = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (msg.id !== undefined && pendingCalls.has(msg.id)) {
|
|
78
|
+
const { resolve: res, reject: rej } = pendingCalls.get(msg.id);
|
|
79
|
+
pendingCalls.delete(msg.id);
|
|
80
|
+
if (msg.error) {
|
|
81
|
+
rej(new Error(`CDP Error: ${msg.error.message} (code: ${msg.error.code})`));
|
|
82
|
+
} else {
|
|
83
|
+
res(msg.result);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error('[CDP] Failed to parse message:', e.message);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
ws.on('open', async () => {
|
|
92
|
+
isConnected = true;
|
|
93
|
+
console.log(`[CDP] Connected to ${wsUrl}`);
|
|
94
|
+
|
|
95
|
+
const cdp = {
|
|
96
|
+
ws,
|
|
97
|
+
contexts,
|
|
98
|
+
get rootContextId() { return rootContextId; },
|
|
99
|
+
|
|
100
|
+
call(method, params = {}) {
|
|
101
|
+
return new Promise((res, rej) => {
|
|
102
|
+
if (!isConnected) {
|
|
103
|
+
rej(new Error('CDP connection is closed'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const id = idCounter++;
|
|
108
|
+
pendingCalls.set(id, { resolve: res, reject: rej });
|
|
109
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
110
|
+
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (pendingCalls.has(id)) {
|
|
113
|
+
pendingCalls.delete(id);
|
|
114
|
+
rej(new Error(`CDP call timeout: ${method}`));
|
|
115
|
+
}
|
|
116
|
+
}, 10000);
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
close() {
|
|
121
|
+
isConnected = false;
|
|
122
|
+
for (const [, { reject }] of pendingCalls) {
|
|
123
|
+
reject(new Error('CDP connection closed'));
|
|
124
|
+
}
|
|
125
|
+
pendingCalls.clear();
|
|
126
|
+
ws.terminate();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await cdp.call('Runtime.enable', {});
|
|
132
|
+
await new Promise(r => setTimeout(r, 300));
|
|
133
|
+
console.log(`[CDP] Runtime enabled, found ${contexts.length} context(s)`);
|
|
134
|
+
resolve(cdp);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
cdp.close();
|
|
137
|
+
reject(err);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
ws.on('error', (err) => {
|
|
142
|
+
console.error(`[CDP] WebSocket error: ${err.message}`);
|
|
143
|
+
isConnected = false;
|
|
144
|
+
reject(err);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
ws.on('close', () => {
|
|
148
|
+
console.log('[CDP] Connection closed');
|
|
149
|
+
isConnected = false;
|
|
150
|
+
for (const [, { reject }] of pendingCalls) {
|
|
151
|
+
reject(new Error('CDP connection closed'));
|
|
152
|
+
}
|
|
153
|
+
pendingCalls.clear();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Click service - handles UI element clicks via CDP
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Click an element in the Kiro UI via CDP
|
|
7
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
8
|
+
* @param {object} clickInfo - Element identification info
|
|
9
|
+
* @returns {Promise<{success: boolean, matchMethod?: string, error?: string}>}
|
|
10
|
+
*/
|
|
11
|
+
export async function clickElement(cdp, clickInfo) {
|
|
12
|
+
const script = `(function() {
|
|
13
|
+
let targetDoc = document;
|
|
14
|
+
const activeFrame = document.getElementById('active-frame');
|
|
15
|
+
if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
|
|
16
|
+
|
|
17
|
+
const info = ${JSON.stringify(clickInfo)};
|
|
18
|
+
let element = null;
|
|
19
|
+
let matchMethod = '';
|
|
20
|
+
const isTabClick = info.isTab || info.role === 'tab';
|
|
21
|
+
const isCloseButton = info.isCloseButton || (info.ariaLabel && info.ariaLabel.toLowerCase() === 'close');
|
|
22
|
+
const isToggle = info.isToggle || info.role === 'switch';
|
|
23
|
+
|
|
24
|
+
// Handle send button
|
|
25
|
+
if (info.isSendButton && !element) {
|
|
26
|
+
const sendSelectors = ['button[data-variant="submit"]', 'svg.lucide-arrow-right', 'button[type="submit"]', 'button[aria-label*="send" i]'];
|
|
27
|
+
for (const sel of sendSelectors) {
|
|
28
|
+
try {
|
|
29
|
+
const el = targetDoc.querySelector(sel);
|
|
30
|
+
if (el) {
|
|
31
|
+
element = el.closest('button') || el;
|
|
32
|
+
if (element && !element.disabled) { matchMethod = 'send-button'; break; }
|
|
33
|
+
}
|
|
34
|
+
} catch(e) {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Handle toggle/switch
|
|
39
|
+
if (isToggle && !element) {
|
|
40
|
+
if (info.toggleId) {
|
|
41
|
+
element = targetDoc.getElementById(info.toggleId);
|
|
42
|
+
if (element) matchMethod = 'toggle-id';
|
|
43
|
+
}
|
|
44
|
+
if (!element && info.text) {
|
|
45
|
+
const toggles = targetDoc.querySelectorAll('.kiro-toggle-switch, [role="switch"]');
|
|
46
|
+
for (const t of toggles) {
|
|
47
|
+
const label = t.querySelector('label');
|
|
48
|
+
if (label && label.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
|
|
49
|
+
element = t.querySelector('input') || t;
|
|
50
|
+
matchMethod = 'toggle-label';
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Handle close button
|
|
58
|
+
if (isCloseButton && !element) {
|
|
59
|
+
const closeButtons = targetDoc.querySelectorAll('[aria-label="close"], .kiro-tabs-item-close, [class*="close"]');
|
|
60
|
+
if (info.parentTabLabel) {
|
|
61
|
+
const searchLabel = info.parentTabLabel.trim().toLowerCase();
|
|
62
|
+
for (const btn of closeButtons) {
|
|
63
|
+
const parentTab = btn.closest('[role="tab"]');
|
|
64
|
+
if (parentTab) {
|
|
65
|
+
const labelEl = parentTab.querySelector('.kiro-tabs-item-label, [class*="label"]');
|
|
66
|
+
const tabLabel = labelEl ? labelEl.textContent.trim().toLowerCase() : '';
|
|
67
|
+
if (tabLabel.includes(searchLabel) || searchLabel.includes(tabLabel)) {
|
|
68
|
+
element = btn;
|
|
69
|
+
matchMethod = 'close-button-by-tab';
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!element && closeButtons.length > 0) {
|
|
76
|
+
for (const btn of closeButtons) {
|
|
77
|
+
const parentTab = btn.closest('[role="tab"]');
|
|
78
|
+
if (parentTab && parentTab.getAttribute('aria-selected') === 'true') {
|
|
79
|
+
element = btn;
|
|
80
|
+
matchMethod = 'close-button-selected';
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Handle tab click
|
|
88
|
+
if (isTabClick && !element) {
|
|
89
|
+
const allTabs = targetDoc.querySelectorAll('[role="tab"]');
|
|
90
|
+
const searchText = (info.tabLabel || info.text || '').trim().toLowerCase();
|
|
91
|
+
for (const tab of allTabs) {
|
|
92
|
+
const labelEl = tab.querySelector('.kiro-tabs-item-label, [class*="label"]');
|
|
93
|
+
const tabText = labelEl ? labelEl.textContent.trim().toLowerCase() : tab.textContent.trim().toLowerCase();
|
|
94
|
+
if (searchText && (tabText.includes(searchText) || searchText.includes(tabText))) {
|
|
95
|
+
element = tab;
|
|
96
|
+
matchMethod = 'tab-label';
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle file link
|
|
103
|
+
if (info.isFileLink && info.filePath && !element) {
|
|
104
|
+
const fileName = info.filePath.split('/').pop().split('\\\\').pop();
|
|
105
|
+
const fileSelectors = ['a[href*="' + fileName + '"]', '[data-path*="' + fileName + '"]', 'code', 'span', '[class*="file"]'];
|
|
106
|
+
for (const selector of fileSelectors) {
|
|
107
|
+
const candidates = targetDoc.querySelectorAll(selector);
|
|
108
|
+
for (const el of candidates) {
|
|
109
|
+
const text = (el.textContent || '').trim();
|
|
110
|
+
if (text.includes(info.filePath) || text.includes(fileName)) {
|
|
111
|
+
element = el;
|
|
112
|
+
matchMethod = 'file-link';
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (element) break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Try by aria-label
|
|
121
|
+
if (info.ariaLabel && !element && !isCloseButton) {
|
|
122
|
+
try {
|
|
123
|
+
const candidates = targetDoc.querySelectorAll('[aria-label="' + info.ariaLabel.replace(/"/g, '\\\\"') + '"]');
|
|
124
|
+
for (const c of candidates) {
|
|
125
|
+
const label = (c.getAttribute('aria-label') || '').toLowerCase();
|
|
126
|
+
if (!label.includes('close')) { element = c; matchMethod = 'aria-label'; break; }
|
|
127
|
+
}
|
|
128
|
+
} catch(e) {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Handle history/session list items
|
|
132
|
+
if (info.isHistoryItem && !element) {
|
|
133
|
+
const searchText = (info.text || '').trim().toLowerCase();
|
|
134
|
+
|
|
135
|
+
// Strategy 1: Find all items that look like history entries (contain dates)
|
|
136
|
+
const datePattern = /\\d{1,2}\\/\\d{1,2}\\/\\d{4}|\\d{1,2}:\\d{2}:\\d{2}/;
|
|
137
|
+
const allDivs = targetDoc.querySelectorAll('div, li, article');
|
|
138
|
+
const historyItems = [];
|
|
139
|
+
|
|
140
|
+
for (const item of allDivs) {
|
|
141
|
+
if (item.children.length > 15) continue; // Skip large containers
|
|
142
|
+
const text = item.textContent || '';
|
|
143
|
+
if (datePattern.test(text) && text.length > 20 && text.length < 500) {
|
|
144
|
+
historyItems.push(item);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Find the one matching our search text
|
|
149
|
+
for (const item of historyItems) {
|
|
150
|
+
const itemText = (item.textContent || '').trim().toLowerCase();
|
|
151
|
+
if (searchText && (itemText.includes(searchText) || searchText.includes(itemText.substring(0, 50)))) {
|
|
152
|
+
element = item;
|
|
153
|
+
matchMethod = 'history-item-date';
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Strategy 2: If not found by text, try standard selectors
|
|
159
|
+
if (!element) {
|
|
160
|
+
const historySelectors = [
|
|
161
|
+
'[role="listitem"]',
|
|
162
|
+
'[role="option"]',
|
|
163
|
+
'[class*="history"] > *',
|
|
164
|
+
'[class*="session"] > *',
|
|
165
|
+
'[class*="conversation"] > *',
|
|
166
|
+
'[class*="list-item"]'
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
for (const selector of historySelectors) {
|
|
170
|
+
try {
|
|
171
|
+
const items = targetDoc.querySelectorAll(selector);
|
|
172
|
+
for (const item of items) {
|
|
173
|
+
const itemText = (item.textContent || '').trim().toLowerCase();
|
|
174
|
+
if (searchText && (itemText.includes(searchText) || searchText.includes(itemText.substring(0, 50)))) {
|
|
175
|
+
element = item;
|
|
176
|
+
matchMethod = 'history-item-selector';
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (element) break;
|
|
181
|
+
} catch(e) {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Strategy 3: Find ANY element with matching text that has cursor:pointer
|
|
186
|
+
if (!element && searchText) {
|
|
187
|
+
const allElements = targetDoc.querySelectorAll('*');
|
|
188
|
+
for (const item of allElements) {
|
|
189
|
+
if (item.children.length > 10) continue;
|
|
190
|
+
const itemText = (item.textContent || '').trim().toLowerCase();
|
|
191
|
+
const firstLine = itemText.split('\\n')[0];
|
|
192
|
+
if (firstLine.includes(searchText) || searchText.includes(firstLine.substring(0, 30))) {
|
|
193
|
+
const style = window.getComputedStyle(item);
|
|
194
|
+
if (style.cursor === 'pointer') {
|
|
195
|
+
element = item;
|
|
196
|
+
matchMethod = 'history-item-pointer';
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Try by text content
|
|
205
|
+
if (info.text && info.text.trim() && !element) {
|
|
206
|
+
const searchText = info.text.trim();
|
|
207
|
+
const allElements = targetDoc.querySelectorAll('button, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], a, [tabindex="0"], [class*="cursor-pointer"]');
|
|
208
|
+
for (const el of allElements) {
|
|
209
|
+
if (!isCloseButton) {
|
|
210
|
+
const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
|
|
211
|
+
if (ariaLabel.includes('close')) continue;
|
|
212
|
+
}
|
|
213
|
+
const elText = (el.textContent || '').trim();
|
|
214
|
+
if (elText === searchText || elText.includes(searchText) || (elText.length >= 10 && searchText.includes(elText))) {
|
|
215
|
+
element = el;
|
|
216
|
+
matchMethod = 'text-content';
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!element) return { found: false, error: 'Element not found' };
|
|
223
|
+
|
|
224
|
+
// For history items, click the item itself - NOT child buttons (which might be delete buttons!)
|
|
225
|
+
let clickTarget = element;
|
|
226
|
+
|
|
227
|
+
// DO NOT click child buttons for history items - they are likely delete/close buttons
|
|
228
|
+
// Just click the main item element directly
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
// Try standard click first
|
|
232
|
+
clickTarget.click();
|
|
233
|
+
return { found: true, clicked: true, matchMethod };
|
|
234
|
+
} catch (e) {
|
|
235
|
+
try {
|
|
236
|
+
// Try full mouse event sequence
|
|
237
|
+
const rect = clickTarget.getBoundingClientRect();
|
|
238
|
+
const centerX = rect.left + rect.width / 2;
|
|
239
|
+
const centerY = rect.top + rect.height / 2;
|
|
240
|
+
|
|
241
|
+
const mouseOpts = {
|
|
242
|
+
bubbles: true,
|
|
243
|
+
cancelable: true,
|
|
244
|
+
view: window,
|
|
245
|
+
clientX: centerX,
|
|
246
|
+
clientY: centerY
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
clickTarget.dispatchEvent(new MouseEvent('mousedown', mouseOpts));
|
|
250
|
+
clickTarget.dispatchEvent(new MouseEvent('mouseup', mouseOpts));
|
|
251
|
+
clickTarget.dispatchEvent(new MouseEvent('click', mouseOpts));
|
|
252
|
+
|
|
253
|
+
return { found: true, clicked: true, matchMethod: matchMethod + '-dispatch' };
|
|
254
|
+
} catch (e2) {
|
|
255
|
+
return { found: true, clicked: false, error: 'Click failed: ' + e2.message };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
})()`;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
262
|
+
expression: script,
|
|
263
|
+
contextId: cdp.rootContextId,
|
|
264
|
+
returnByValue: true
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const elementInfo = result.result?.value;
|
|
268
|
+
if (!elementInfo?.found) {
|
|
269
|
+
console.log('[Click] Element not found:', clickInfo.ariaLabel || clickInfo.text);
|
|
270
|
+
return { success: false, error: 'Element not found' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (elementInfo.clicked) {
|
|
274
|
+
console.log('[Click] Clicked via', elementInfo.matchMethod);
|
|
275
|
+
return { success: true, matchMethod: elementInfo.matchMethod };
|
|
276
|
+
}
|
|
277
|
+
return { success: false, error: elementInfo.error || 'Click failed' };
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error('[Click] CDP error:', err.message);
|
|
280
|
+
return { success: false, error: err.message };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message injection service - sends messages to Kiro chat via CDP
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create script to inject message into chat input
|
|
7
|
+
* @param {string} messageText - Message to inject
|
|
8
|
+
* @returns {string} - JavaScript expression
|
|
9
|
+
*/
|
|
10
|
+
function createInjectScript(messageText) {
|
|
11
|
+
const escaped = messageText.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
12
|
+
|
|
13
|
+
return `(async () => {
|
|
14
|
+
const text = '${escaped}';
|
|
15
|
+
let targetDoc = document;
|
|
16
|
+
const activeFrame = document.getElementById('active-frame');
|
|
17
|
+
if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
|
|
18
|
+
|
|
19
|
+
// Find the CHAT INPUT editor specifically (not any contenteditable in the page)
|
|
20
|
+
// Look for the input area at the bottom of the chat, not message bubbles
|
|
21
|
+
let editor = null;
|
|
22
|
+
|
|
23
|
+
// Strategy 1: Find by common chat input container patterns
|
|
24
|
+
const inputContainerSelectors = [
|
|
25
|
+
'[class*="chat-input"]',
|
|
26
|
+
'[class*="message-input"]',
|
|
27
|
+
'[class*="composer"]',
|
|
28
|
+
'[class*="input-area"]',
|
|
29
|
+
'[class*="InputArea"]',
|
|
30
|
+
'form[class*="chat"]',
|
|
31
|
+
'[data-testid*="input"]'
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const containerSel of inputContainerSelectors) {
|
|
35
|
+
const container = targetDoc.querySelector(containerSel);
|
|
36
|
+
if (container) {
|
|
37
|
+
const editorInContainer = container.querySelector('.tiptap.ProseMirror[contenteditable="true"], [data-lexical-editor="true"][contenteditable="true"], [contenteditable="true"], textarea');
|
|
38
|
+
if (editorInContainer && editorInContainer.offsetParent !== null) {
|
|
39
|
+
editor = editorInContainer;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Strategy 2: Find TipTap/ProseMirror editors that are visible and near a submit button
|
|
46
|
+
if (!editor) {
|
|
47
|
+
const allEditors = [...targetDoc.querySelectorAll('.tiptap.ProseMirror[contenteditable="true"]')].filter(el => el.offsetParent !== null);
|
|
48
|
+
for (const ed of allEditors) {
|
|
49
|
+
// Check if there's a submit button nearby (sibling or in same parent form)
|
|
50
|
+
const parent = ed.closest('form') || ed.parentElement?.parentElement?.parentElement;
|
|
51
|
+
if (parent) {
|
|
52
|
+
const hasSubmit = parent.querySelector('button[data-variant="submit"], button[type="submit"], svg.lucide-arrow-right');
|
|
53
|
+
if (hasSubmit) { editor = ed; break; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Fallback to last visible TipTap editor
|
|
57
|
+
if (!editor && allEditors.length > 0) editor = allEditors.at(-1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Strategy 3: Find Lexical editors
|
|
61
|
+
if (!editor) {
|
|
62
|
+
const lexicalEditors = [...targetDoc.querySelectorAll('[data-lexical-editor="true"][contenteditable="true"]')].filter(el => el.offsetParent !== null);
|
|
63
|
+
for (const ed of lexicalEditors) {
|
|
64
|
+
const parent = ed.closest('form') || ed.parentElement?.parentElement?.parentElement;
|
|
65
|
+
if (parent) {
|
|
66
|
+
const hasSubmit = parent.querySelector('button[data-variant="submit"], button[type="submit"]');
|
|
67
|
+
if (hasSubmit) { editor = ed; break; }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!editor && lexicalEditors.length > 0) editor = lexicalEditors.at(-1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Strategy 4: Generic contenteditable (last resort)
|
|
74
|
+
if (!editor) {
|
|
75
|
+
const editables = [...targetDoc.querySelectorAll('[contenteditable="true"]')].filter(el => el.offsetParent !== null);
|
|
76
|
+
editor = editables.at(-1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Strategy 5: Textarea fallback
|
|
80
|
+
if (!editor) {
|
|
81
|
+
const textareas = [...targetDoc.querySelectorAll('textarea')].filter(el => el.offsetParent !== null);
|
|
82
|
+
editor = textareas.at(-1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!editor) return { ok: false, error: 'editor_not_found' };
|
|
86
|
+
|
|
87
|
+
const isTextarea = editor.tagName.toLowerCase() === 'textarea';
|
|
88
|
+
const isProseMirror = editor.classList.contains('ProseMirror') || editor.classList.contains('tiptap');
|
|
89
|
+
const isLexical = editor.hasAttribute('data-lexical-editor');
|
|
90
|
+
|
|
91
|
+
// Focus the editor first
|
|
92
|
+
editor.focus();
|
|
93
|
+
await new Promise(r => setTimeout(r, 50));
|
|
94
|
+
|
|
95
|
+
if (isTextarea) {
|
|
96
|
+
// Textarea: simple value assignment
|
|
97
|
+
editor.value = text;
|
|
98
|
+
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
99
|
+
} else if (isProseMirror) {
|
|
100
|
+
// ProseMirror/TipTap: Clear and insert via DOM manipulation
|
|
101
|
+
// This works because TipTap syncs DOM changes to its state
|
|
102
|
+
editor.innerHTML = '';
|
|
103
|
+
const p = targetDoc.createElement('p');
|
|
104
|
+
p.textContent = text;
|
|
105
|
+
editor.appendChild(p);
|
|
106
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
|
|
107
|
+
} else if (isLexical) {
|
|
108
|
+
// Lexical: Must use execCommand to properly update internal state
|
|
109
|
+
// Lexical listens to beforeinput/input events from execCommand
|
|
110
|
+
|
|
111
|
+
// First, select all content
|
|
112
|
+
const selection = targetDoc.getSelection();
|
|
113
|
+
const range = targetDoc.createRange();
|
|
114
|
+
range.selectNodeContents(editor);
|
|
115
|
+
selection.removeAllRanges();
|
|
116
|
+
selection.addRange(range);
|
|
117
|
+
|
|
118
|
+
// Delete existing content
|
|
119
|
+
targetDoc.execCommand('delete', false, null);
|
|
120
|
+
|
|
121
|
+
// Wait for Lexical to process the deletion
|
|
122
|
+
await new Promise(r => setTimeout(r, 30));
|
|
123
|
+
|
|
124
|
+
// Insert new text using execCommand (Lexical intercepts this)
|
|
125
|
+
const inserted = targetDoc.execCommand('insertText', false, text);
|
|
126
|
+
|
|
127
|
+
if (!inserted) {
|
|
128
|
+
// Fallback: Try setting textContent and dispatching events
|
|
129
|
+
editor.textContent = text;
|
|
130
|
+
editor.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: text }));
|
|
131
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
// Generic contenteditable
|
|
135
|
+
const selection = targetDoc.getSelection();
|
|
136
|
+
const range = targetDoc.createRange();
|
|
137
|
+
range.selectNodeContents(editor);
|
|
138
|
+
selection.removeAllRanges();
|
|
139
|
+
selection.addRange(range);
|
|
140
|
+
|
|
141
|
+
targetDoc.execCommand('delete', false, null);
|
|
142
|
+
|
|
143
|
+
let inserted = false;
|
|
144
|
+
try { inserted = !!targetDoc.execCommand('insertText', false, text); } catch (e) {}
|
|
145
|
+
|
|
146
|
+
if (!inserted) {
|
|
147
|
+
editor.textContent = text;
|
|
148
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Wait for editor state to sync
|
|
153
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
154
|
+
await new Promise(r => setTimeout(r, 100));
|
|
155
|
+
|
|
156
|
+
// Find and click submit button
|
|
157
|
+
const submitButton = targetDoc.querySelector('button[data-variant="submit"]:not([disabled])') ||
|
|
158
|
+
targetDoc.querySelector('svg.lucide-arrow-right')?.closest('button:not([disabled])') ||
|
|
159
|
+
targetDoc.querySelector('button[type="submit"]:not([disabled])') ||
|
|
160
|
+
targetDoc.querySelector('button[aria-label*="send" i]:not([disabled])');
|
|
161
|
+
|
|
162
|
+
if (submitButton) {
|
|
163
|
+
submitButton.click();
|
|
164
|
+
return { ok: true, method: 'click_submit' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Fallback: Enter key
|
|
168
|
+
editor.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13 }));
|
|
169
|
+
return { ok: true, method: 'enter_key' };
|
|
170
|
+
})()`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Inject a message into the chat via CDP
|
|
175
|
+
* @param {CDPConnection} cdp - CDP connection
|
|
176
|
+
* @param {string} message - Message text
|
|
177
|
+
* @returns {Promise<{success: boolean, method?: string, error?: string}>}
|
|
178
|
+
*/
|
|
179
|
+
export async function injectMessage(cdp, message) {
|
|
180
|
+
if (!cdp.rootContextId) {
|
|
181
|
+
return { success: false, error: 'No execution context available' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const result = await cdp.call('Runtime.evaluate', {
|
|
186
|
+
expression: createInjectScript(message),
|
|
187
|
+
contextId: cdp.rootContextId,
|
|
188
|
+
returnByValue: true,
|
|
189
|
+
awaitPromise: true
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (result.exceptionDetails) {
|
|
193
|
+
return { success: false, error: result.exceptionDetails.exception?.description || 'Unknown error' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const value = result.result?.value;
|
|
197
|
+
if (value?.ok) {
|
|
198
|
+
console.log(`[Inject] Message sent via ${value.method}`);
|
|
199
|
+
return { success: true, method: value.method };
|
|
200
|
+
}
|
|
201
|
+
return { success: false, error: value?.error || 'Injection failed' };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error('[Inject] CDP call failed:', err.message);
|
|
204
|
+
return { success: false, error: err.message };
|
|
205
|
+
}
|
|
206
|
+
}
|