@inetafrica/open-claudia 3.5.24 → 3.5.26
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/CHANGELOG.md +19 -0
- package/core/browser-control.js +147 -17
- package/core/browser-drive.js +619 -0
- package/core/browser-lock.js +33 -2
- package/core/browser-view.js +90 -5
- package/core/cards.js +18 -0
- package/core/loopback.js +4 -1
- package/core/tool-guard.js +3 -0
- package/package.json +2 -2
- package/test-provider-language.js +8 -1
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// The Browser app's phone and web view (bot apps v2): while a person drives the bot's browser and
|
|
2
|
+
// Chrome runs in its own container (AGENT_BROWSER_CDP), the bot's tab takes the driver's screen.
|
|
3
|
+
// Its size and pixel density, a phone's page layout and touch on a phone, a desktop's on the web.
|
|
4
|
+
// This module's own DevTools connection then carries the driver's whole session:
|
|
5
|
+
// - the picture: a screencast at the driver's size, so text is sharp on their screen;
|
|
6
|
+
// - their input: mouse, keys, touch and typed text, straight to Chrome, in order;
|
|
7
|
+
// - the page's text fields (where they are, what kind), so the driver's own keyboard opens the
|
|
8
|
+
// moment they tap one.
|
|
9
|
+
// Handing back (stop) closes the connection. Chrome drops the size, layout and touch with it, and
|
|
10
|
+
// the window goes back to the size it had.
|
|
11
|
+
//
|
|
12
|
+
// Why the window is resized too: Chrome's screencast pictures the window, so a phone-size view in
|
|
13
|
+
// a desktop-size window comes out boxed in a corner after the next page load (proven 10 Oct on
|
|
14
|
+
// Chrome 149). Order matters: the view first, then the window, or the first pictures stay at 1x.
|
|
15
|
+
// The browser's identity (user agent) never changes: sites the bot or the person signed into
|
|
16
|
+
// must see the same browser after the hand back.
|
|
17
|
+
//
|
|
18
|
+
// Only one drive at a time (the driver lock is single). Nothing here decides who may drive:
|
|
19
|
+
// core/browser-control.js does, and calls start / fit / dispatch / stop.
|
|
20
|
+
|
|
21
|
+
const WebSocket = require("ws");
|
|
22
|
+
const view = require("./browser-view");
|
|
23
|
+
|
|
24
|
+
const QUALITY = 65;
|
|
25
|
+
const MAX_FPS = 30;
|
|
26
|
+
// A drive's picture is at most this many device pixels (a phone at 2x is about 1.3 million).
|
|
27
|
+
const MAX_PIXELS = 1600000;
|
|
28
|
+
const MIN_SIDE = 240;
|
|
29
|
+
const MAX_SIDE = 1920;
|
|
30
|
+
const MAX_DPR = 3;
|
|
31
|
+
const CALL_TIMEOUT_MS = 4000;
|
|
32
|
+
const CONNECT_TIMEOUT_MS = 3000;
|
|
33
|
+
// The text fields are read this long after the page stops changing (and after a tap).
|
|
34
|
+
const FIELDS_SETTLE_MS = 250;
|
|
35
|
+
// After a page load the window is nudged this long after the load's events settle.
|
|
36
|
+
const NUDGE_DELAY_MS = 120;
|
|
37
|
+
const MAX_FIELDS = 60;
|
|
38
|
+
const TEXT_MAX = 500;
|
|
39
|
+
|
|
40
|
+
function defaultDeps() {
|
|
41
|
+
return {
|
|
42
|
+
now: () => Date.now(),
|
|
43
|
+
endpoint: () => view.cdpEndpoint(),
|
|
44
|
+
fetchJson: async (url) => {
|
|
45
|
+
const ctrl = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => ctrl.abort(), CONNECT_TIMEOUT_MS);
|
|
47
|
+
if (timer.unref) timer.unref();
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
50
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
51
|
+
return await res.json();
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
connect: (url) => new WebSocket(url, { perMessageDeflate: false }),
|
|
57
|
+
targetOf: (session) => view.sessionTargetId(session),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let deps = defaultDeps();
|
|
62
|
+
let current = null;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// The driver's screen, checked and capped
|
|
66
|
+
|
|
67
|
+
function round2(n) {
|
|
68
|
+
return Math.round(n * 100) / 100;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// { width, height, dpr, mobile, touch } in CSS pixels, or null when it is not a screen.
|
|
72
|
+
function checkViewport(vp) {
|
|
73
|
+
if (!vp || typeof vp !== "object" || Array.isArray(vp)) return null;
|
|
74
|
+
const width = Math.round(Number(vp.width));
|
|
75
|
+
const height = Math.round(Number(vp.height));
|
|
76
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
|
77
|
+
if (width < MIN_SIDE || height < MIN_SIDE || width > MAX_SIDE || height > MAX_SIDE) return null;
|
|
78
|
+
let dpr = Number(vp.dpr);
|
|
79
|
+
if (!Number.isFinite(dpr) || dpr < 1) dpr = 1;
|
|
80
|
+
dpr = Math.min(dpr, MAX_DPR, Math.sqrt(MAX_PIXELS / (width * height)));
|
|
81
|
+
dpr = Math.max(1, round2(dpr));
|
|
82
|
+
return { width, height, dpr, mobile: vp.mobile === true, touch: vp.touch === true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// One DevTools connection
|
|
87
|
+
|
|
88
|
+
async function openConnection() {
|
|
89
|
+
const endpoint = deps.endpoint();
|
|
90
|
+
if (!endpoint) throw Object.assign(new Error("Chrome is not in its own container"), { code: "unsupported" });
|
|
91
|
+
const version = await deps.fetchJson(`${endpoint}/json/version`);
|
|
92
|
+
const reported = version && typeof version.webSocketDebuggerUrl === "string" ? version.webSocketDebuggerUrl : "";
|
|
93
|
+
let path;
|
|
94
|
+
try {
|
|
95
|
+
path = new URL(reported).pathname;
|
|
96
|
+
} catch (_) {
|
|
97
|
+
path = "";
|
|
98
|
+
}
|
|
99
|
+
if (!/^\/devtools\/browser\/[A-Za-z0-9-]{1,80}$/.test(path)) throw new Error("Chrome gave no DevTools address");
|
|
100
|
+
// Loopback only: the address is built from AGENT_BROWSER_CDP, never from what Chrome reports.
|
|
101
|
+
const ws = deps.connect(`ws://${new URL(endpoint).host}${path}`);
|
|
102
|
+
const conn = { ws, nextId: 0, calls: new Map(), handlers: [], closed: false };
|
|
103
|
+
ws.on("message", (data) => {
|
|
104
|
+
let msg;
|
|
105
|
+
try { msg = JSON.parse(String(data)); } catch (_) { return; }
|
|
106
|
+
if (msg && msg.id && conn.calls.has(msg.id)) {
|
|
107
|
+
const call = conn.calls.get(msg.id);
|
|
108
|
+
conn.calls.delete(msg.id);
|
|
109
|
+
clearTimeout(call.timer);
|
|
110
|
+
if (msg.error) call.reject(new Error(`${call.method}: ${msg.error.message || "failed"}`));
|
|
111
|
+
else call.resolve(msg.result || {});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (msg && msg.method) for (const fn of conn.handlers) fn(msg);
|
|
115
|
+
});
|
|
116
|
+
const fail = () => {
|
|
117
|
+
if (conn.closed) return;
|
|
118
|
+
conn.closed = true;
|
|
119
|
+
for (const call of conn.calls.values()) { clearTimeout(call.timer); call.reject(new Error("DevTools connection closed")); }
|
|
120
|
+
conn.calls.clear();
|
|
121
|
+
for (const fn of conn.handlers) fn({ method: "_closed" });
|
|
122
|
+
};
|
|
123
|
+
ws.on("close", fail);
|
|
124
|
+
ws.on("error", fail);
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
const timer = setTimeout(() => reject(new Error("DevTools connection timed out")), CONNECT_TIMEOUT_MS);
|
|
127
|
+
if (timer.unref) timer.unref();
|
|
128
|
+
ws.on("open", () => { clearTimeout(timer); resolve(); });
|
|
129
|
+
ws.on("error", (e) => { clearTimeout(timer); reject(e instanceof Error ? e : new Error("DevTools connection failed")); });
|
|
130
|
+
});
|
|
131
|
+
return conn;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A call that waits for Chrome's answer.
|
|
135
|
+
function call(conn, method, params = {}, sessionId = null) {
|
|
136
|
+
if (!conn || conn.closed) return Promise.reject(new Error("DevTools connection closed"));
|
|
137
|
+
const id = ++conn.nextId;
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
conn.calls.delete(id);
|
|
141
|
+
reject(new Error(`${method} timed out`));
|
|
142
|
+
}, CALL_TIMEOUT_MS);
|
|
143
|
+
if (timer.unref) timer.unref();
|
|
144
|
+
conn.calls.set(id, { resolve, reject, timer, method });
|
|
145
|
+
try {
|
|
146
|
+
conn.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
147
|
+
} catch (e) {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
conn.calls.delete(id);
|
|
150
|
+
reject(e);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A call whose answer nobody waits for (input, frame acks): sent in order, failures only logged.
|
|
156
|
+
function fire(conn, method, params, sessionId) {
|
|
157
|
+
call(conn, method, params, sessionId).catch((e) => {
|
|
158
|
+
if (!conn.closed) console.error(`[browser] ${e.message}`);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function closeConnection(conn) {
|
|
163
|
+
if (!conn || conn.closed) return;
|
|
164
|
+
conn.closed = true;
|
|
165
|
+
for (const c of conn.calls.values()) { clearTimeout(c.timer); c.reject(new Error("DevTools connection closed")); }
|
|
166
|
+
conn.calls.clear();
|
|
167
|
+
try { conn.ws.close(); } catch (_) { /* already closed */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// The page's text fields: where they are on the driver's screen (CSS pixels of the view, after
|
|
172
|
+
// any zoom), what kind, and whether one has the focus. Never a value.
|
|
173
|
+
|
|
174
|
+
const FIELDS_SCRIPT = `(() => {
|
|
175
|
+
const vv = window.visualViewport || { offsetLeft: 0, offsetTop: 0, scale: 1, width: innerWidth, height: innerHeight };
|
|
176
|
+
const sw = vv.width * vv.scale, sh = vv.height * vv.scale;
|
|
177
|
+
const SKIP = { hidden: 1, checkbox: 1, radio: 1, button: 1, submit: 1, reset: 1, image: 1, file: 1, range: 1, color: 1 };
|
|
178
|
+
const KINDS = { password: 1, email: 1, number: 1, tel: 1, url: 1, search: 1 };
|
|
179
|
+
const attr = (el, n, max) => String(el.getAttribute(n) || "").slice(0, max);
|
|
180
|
+
const out = [];
|
|
181
|
+
let focused = -1;
|
|
182
|
+
const active = document.activeElement;
|
|
183
|
+
const list = document.querySelectorAll('input, textarea, [contenteditable=""], [contenteditable="true"], [role="textbox"], [role="searchbox"], [role="combobox"]');
|
|
184
|
+
for (const el of list) {
|
|
185
|
+
if (out.length >= ${MAX_FIELDS}) break;
|
|
186
|
+
if (el.disabled || el.readOnly) continue;
|
|
187
|
+
let kind = "text";
|
|
188
|
+
if (el.tagName === "INPUT") {
|
|
189
|
+
const t = String(el.type || "text").toLowerCase();
|
|
190
|
+
if (SKIP[t]) continue;
|
|
191
|
+
if (KINDS[t]) kind = t;
|
|
192
|
+
} else if (el.tagName === "TEXTAREA") kind = "multiline";
|
|
193
|
+
const r = el.getBoundingClientRect();
|
|
194
|
+
if (r.width < 4 || r.height < 4) continue;
|
|
195
|
+
const x = (r.left - vv.offsetLeft) * vv.scale, y = (r.top - vv.offsetTop) * vv.scale;
|
|
196
|
+
const w = r.width * vv.scale, h = r.height * vv.scale;
|
|
197
|
+
if (x + w < 0 || y + h < 0 || x > sw || y > sh) continue;
|
|
198
|
+
const st = getComputedStyle(el);
|
|
199
|
+
if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
|
|
200
|
+
if (el === active) focused = out.length;
|
|
201
|
+
out.push({ x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), kind,
|
|
202
|
+
ac: attr(el, "autocomplete", 40), im: attr(el, "inputmode", 16), enter: attr(el, "enterkeyhint", 10) });
|
|
203
|
+
}
|
|
204
|
+
const editable = !!(active && (active.isContentEditable || active.tagName === "TEXTAREA" ||
|
|
205
|
+
(active.tagName === "INPUT" && !SKIP[String(active.type || "text").toLowerCase()])));
|
|
206
|
+
return JSON.stringify({ fields: out, focused, editable });
|
|
207
|
+
})()`;
|
|
208
|
+
|
|
209
|
+
function cleanFields(raw) {
|
|
210
|
+
let parsed;
|
|
211
|
+
try { parsed = JSON.parse(String(raw)); } catch (_) { return null; }
|
|
212
|
+
if (!parsed || !Array.isArray(parsed.fields)) return null;
|
|
213
|
+
const word = (v, max) => (typeof v === "string" ? v.slice(0, max).replace(/[^\w -]/g, "") : "");
|
|
214
|
+
const fields = parsed.fields.slice(0, MAX_FIELDS).filter((f) => f && [f.x, f.y, f.w, f.h].every(Number.isFinite)).map((f) => ({
|
|
215
|
+
x: Math.round(f.x), y: Math.round(f.y), w: Math.round(f.w), h: Math.round(f.h),
|
|
216
|
+
kind: word(f.kind, 12) || "text", ac: word(f.ac, 40), im: word(f.im, 16), enter: word(f.enter, 10),
|
|
217
|
+
}));
|
|
218
|
+
const focused = Number.isInteger(parsed.focused) && parsed.focused >= 0 && parsed.focused < fields.length ? parsed.focused : -1;
|
|
219
|
+
return { fields, focused, editable: parsed.editable === true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scheduleFields(drive, ms = FIELDS_SETTLE_MS) {
|
|
223
|
+
if (!drive || drive.stopped) return;
|
|
224
|
+
clearTimeout(drive.fieldsTimer);
|
|
225
|
+
drive.fieldsTimer = setTimeout(() => readFields(drive), ms);
|
|
226
|
+
if (drive.fieldsTimer.unref) drive.fieldsTimer.unref();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function readFields(drive) {
|
|
230
|
+
if (!drive || drive.stopped) return;
|
|
231
|
+
try {
|
|
232
|
+
const res = await call(drive.conn, "Runtime.evaluate", { expression: FIELDS_SCRIPT, returnByValue: true, silent: true }, drive.sid);
|
|
233
|
+
const value = res && res.result ? res.result.value : null;
|
|
234
|
+
const fields = cleanFields(value);
|
|
235
|
+
if (!fields || drive.stopped) return;
|
|
236
|
+
const key = JSON.stringify(fields);
|
|
237
|
+
if (key === drive.fieldsKey) return;
|
|
238
|
+
drive.fieldsKey = key;
|
|
239
|
+
view.driveFeed.emit("fields", { session: drive.session.name, ...fields });
|
|
240
|
+
} catch (_) {
|
|
241
|
+
// a page in the middle of loading: the next frame reads them again
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Frames
|
|
247
|
+
|
|
248
|
+
function onFrame(drive, params) {
|
|
249
|
+
if (drive.stopped || !params || typeof params.data !== "string") return;
|
|
250
|
+
const md = params.metadata || {};
|
|
251
|
+
const vp = drive.viewport;
|
|
252
|
+
const frame = {
|
|
253
|
+
data: params.data,
|
|
254
|
+
// The driver's page is vp.width x vp.height CSS pixels; the picture is that times vp.dpr.
|
|
255
|
+
metadata: {
|
|
256
|
+
deviceWidth: vp.width,
|
|
257
|
+
deviceHeight: vp.height,
|
|
258
|
+
pageScaleFactor: Number.isFinite(md.pageScaleFactor) ? md.pageScaleFactor : 1,
|
|
259
|
+
scrollOffsetX: Number.isFinite(md.scrollOffsetX) ? md.scrollOffsetX : 0,
|
|
260
|
+
scrollOffsetY: Number.isFinite(md.scrollOffsetY) ? md.scrollOffsetY : 0,
|
|
261
|
+
timestamp: Number.isFinite(md.timestamp) ? Math.round(md.timestamp * 1000) : deps.now(),
|
|
262
|
+
fit: true,
|
|
263
|
+
},
|
|
264
|
+
seq: ++drive.seq,
|
|
265
|
+
at: deps.now(),
|
|
266
|
+
};
|
|
267
|
+
drive.lastFrame = frame;
|
|
268
|
+
view.driveFeed.last = { session: drive.session.name, frame };
|
|
269
|
+
view.driveFeed.emit("frame", { session: drive.session.name, frame });
|
|
270
|
+
// Chrome sends the next picture after this one is acknowledged: at most MAX_FPS a second.
|
|
271
|
+
const wait = Math.max(0, drive.lastAckAt + 1000 / MAX_FPS - deps.now());
|
|
272
|
+
const ack = () => {
|
|
273
|
+
drive.lastAckAt = deps.now();
|
|
274
|
+
if (!drive.stopped) fire(drive.conn, "Page.screencastFrameAck", { sessionId: params.sessionId }, drive.sid);
|
|
275
|
+
};
|
|
276
|
+
if (wait > 0) {
|
|
277
|
+
const t = setTimeout(ack, wait);
|
|
278
|
+
if (t.unref) t.unref();
|
|
279
|
+
} else ack();
|
|
280
|
+
scheduleFields(drive);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// After a page load, Chrome 150 on Linux draws the view at 1x again: the view keeps its density,
|
|
284
|
+
// the picture does not (proven 10 Oct on the canary's Chrome 150.0.7871.124: 2x until the load,
|
|
285
|
+
// 1x after; the same view sent again, a new screencast, or the view cleared and set again change
|
|
286
|
+
// nothing; the window resized by a pixel and back brings 2x back). Chrome 149 on a Mac did the
|
|
287
|
+
// opposite, 1x until the first load. So after every main-frame navigation and load, the window
|
|
288
|
+
// is nudged by a pixel and back.
|
|
289
|
+
function nudgeWindow(drive) {
|
|
290
|
+
if (!drive || drive.stopped || !drive.viewport || drive.windowId === null) return;
|
|
291
|
+
clearTimeout(drive.nudgeTimer);
|
|
292
|
+
drive.nudgeTimer = setTimeout(async () => {
|
|
293
|
+
if (drive.stopped || !drive.viewport) return;
|
|
294
|
+
const vp = drive.viewport;
|
|
295
|
+
const width = Math.round(vp.width * vp.dpr) + drive.insets.width;
|
|
296
|
+
const height = Math.round(vp.height * vp.dpr) + drive.insets.height;
|
|
297
|
+
try {
|
|
298
|
+
await call(drive.conn, "Browser.setWindowBounds", { windowId: drive.windowId, bounds: { width: width + 1, height, windowState: "normal" } });
|
|
299
|
+
await call(drive.conn, "Browser.setWindowBounds", { windowId: drive.windowId, bounds: { width, height, windowState: "normal" } });
|
|
300
|
+
} catch (_) {
|
|
301
|
+
// the next load nudges again
|
|
302
|
+
}
|
|
303
|
+
}, NUDGE_DELAY_MS);
|
|
304
|
+
if (drive.nudgeTimer.unref) drive.nudgeTimer.unref();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// start / fit / stop
|
|
309
|
+
|
|
310
|
+
async function applyViewport(drive, vp) {
|
|
311
|
+
const { conn, sid } = drive;
|
|
312
|
+
// The view first, then the window (the other way round, the first pictures stay at 1x).
|
|
313
|
+
await call(conn, "Emulation.setDeviceMetricsOverride", {
|
|
314
|
+
width: vp.width,
|
|
315
|
+
height: vp.height,
|
|
316
|
+
deviceScaleFactor: vp.dpr,
|
|
317
|
+
mobile: vp.mobile,
|
|
318
|
+
scale: vp.dpr,
|
|
319
|
+
screenWidth: vp.width,
|
|
320
|
+
screenHeight: vp.height,
|
|
321
|
+
}, sid);
|
|
322
|
+
await call(conn, "Browser.setWindowBounds", {
|
|
323
|
+
windowId: drive.windowId,
|
|
324
|
+
bounds: {
|
|
325
|
+
width: Math.round(vp.width * vp.dpr) + drive.insets.width,
|
|
326
|
+
height: Math.round(vp.height * vp.dpr) + drive.insets.height,
|
|
327
|
+
windowState: "normal",
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
await call(conn, "Emulation.setTouchEmulationEnabled", { enabled: vp.touch, ...(vp.touch ? { maxTouchPoints: 5 } : {}) }, sid);
|
|
331
|
+
drive.viewport = vp;
|
|
332
|
+
// A new size needs a new screencast: its picture size is fixed when it starts.
|
|
333
|
+
if (drive.screencasting) await call(conn, "Page.stopScreencast", {}, sid).catch(() => {});
|
|
334
|
+
await call(conn, "Page.startScreencast", {
|
|
335
|
+
format: "jpeg",
|
|
336
|
+
quality: QUALITY,
|
|
337
|
+
maxWidth: Math.round(vp.width * vp.dpr),
|
|
338
|
+
maxHeight: Math.round(vp.height * vp.dpr),
|
|
339
|
+
everyNthFrame: 1,
|
|
340
|
+
}, sid);
|
|
341
|
+
drive.screencasting = true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// session: { name, dir } (an agent-browser session in container mode); viewport: the driver's
|
|
345
|
+
// screen; savedBounds: the window's size before an earlier drive of this same lock (after a bot
|
|
346
|
+
// restart), so the hand back restores that and not the phone size.
|
|
347
|
+
// -> { ok: true, fit, bounds } | { ok: false, error }
|
|
348
|
+
async function start({ session, viewport, savedBounds = null } = {}) {
|
|
349
|
+
const vp = checkViewport(viewport);
|
|
350
|
+
if (!vp) return { ok: false, error: "bad-viewport" };
|
|
351
|
+
if (!session || !session.name) return { ok: false, error: "not-running" };
|
|
352
|
+
if (!deps.endpoint()) return { ok: false, error: "unsupported" };
|
|
353
|
+
const targetId = deps.targetOf(session);
|
|
354
|
+
if (!targetId) return { ok: false, error: "no-tab" };
|
|
355
|
+
if (current) await stop();
|
|
356
|
+
let conn;
|
|
357
|
+
try {
|
|
358
|
+
conn = await openConnection();
|
|
359
|
+
} catch (e) {
|
|
360
|
+
console.error(`[browser] phone/web view unavailable: ${e.message}`);
|
|
361
|
+
return { ok: false, error: e.code === "unsupported" ? "unsupported" : "browser-unavailable" };
|
|
362
|
+
}
|
|
363
|
+
const drive = {
|
|
364
|
+
session: { name: session.name, dir: session.dir || null },
|
|
365
|
+
targetId,
|
|
366
|
+
conn,
|
|
367
|
+
sid: null,
|
|
368
|
+
windowId: null,
|
|
369
|
+
savedBounds: null,
|
|
370
|
+
insets: { width: 0, height: 0 },
|
|
371
|
+
viewport: null,
|
|
372
|
+
screencasting: false,
|
|
373
|
+
stopped: false,
|
|
374
|
+
lastFrame: null,
|
|
375
|
+
lastAckAt: 0,
|
|
376
|
+
seq: 0,
|
|
377
|
+
fieldsTimer: null,
|
|
378
|
+
fieldsKey: "",
|
|
379
|
+
touchBase: null,
|
|
380
|
+
nudgeTimer: null,
|
|
381
|
+
};
|
|
382
|
+
conn.handlers.push((msg) => {
|
|
383
|
+
if (msg.method === "Page.screencastFrame" && msg.sessionId === drive.sid) onFrame(drive, msg.params);
|
|
384
|
+
else if (msg.sessionId === drive.sid && ((msg.method === "Page.frameNavigated" && msg.params && msg.params.frame && !msg.params.frame.parentId) || msg.method === "Page.loadEventFired")) nudgeWindow(drive);
|
|
385
|
+
else if (msg.method === "_closed" || (msg.method === "Target.detachedFromTarget" && msg.params && msg.params.sessionId === drive.sid)) {
|
|
386
|
+
if (!drive.stopped) lost(drive);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
try {
|
|
390
|
+
const attached = await call(conn, "Target.attachToTarget", { targetId, flatten: true });
|
|
391
|
+
drive.sid = attached.sessionId;
|
|
392
|
+
if (!drive.sid) throw new Error("no DevTools session for the tab");
|
|
393
|
+
await call(conn, "Target.activateTarget", { targetId }).catch(() => {});
|
|
394
|
+
const win = await call(conn, "Browser.getWindowForTarget", { targetId });
|
|
395
|
+
drive.windowId = win.windowId;
|
|
396
|
+
drive.savedBounds = savedBounds && Number.isFinite(savedBounds.width) && Number.isFinite(savedBounds.height)
|
|
397
|
+
? { width: Math.round(savedBounds.width), height: Math.round(savedBounds.height) }
|
|
398
|
+
: { width: win.bounds.width, height: win.bounds.height };
|
|
399
|
+
// What the window adds around the page (Chrome's own bars, even headless).
|
|
400
|
+
const sizes = await call(conn, "Runtime.evaluate", {
|
|
401
|
+
expression: "JSON.stringify([outerWidth - innerWidth, outerHeight - innerHeight])",
|
|
402
|
+
returnByValue: true,
|
|
403
|
+
silent: true,
|
|
404
|
+
}, drive.sid);
|
|
405
|
+
try {
|
|
406
|
+
const [w, h] = JSON.parse(sizes.result.value);
|
|
407
|
+
if (Number.isFinite(w) && w >= 0 && w < 400) drive.insets.width = Math.round(w);
|
|
408
|
+
if (Number.isFinite(h) && h >= 0 && h < 400) drive.insets.height = Math.round(h);
|
|
409
|
+
} catch (_) { /* no insets known: none */ }
|
|
410
|
+
await call(conn, "Page.enable", {}, drive.sid);
|
|
411
|
+
await applyViewport(drive, vp);
|
|
412
|
+
} catch (e) {
|
|
413
|
+
console.error(`[browser] phone/web view failed: ${e.message}`);
|
|
414
|
+
await restore(drive);
|
|
415
|
+
return { ok: false, error: "browser-unavailable" };
|
|
416
|
+
}
|
|
417
|
+
current = drive;
|
|
418
|
+
view.driveFeed.active = drive.session.name;
|
|
419
|
+
view.driveFeed.emit("start", { session: drive.session.name, fit: vp });
|
|
420
|
+
scheduleFields(drive, 400);
|
|
421
|
+
console.log(`[browser] driver's view: ${vp.width}x${vp.height} at ${vp.dpr}x${vp.mobile ? ", phone layout" : ""}${vp.touch ? ", touch" : ""}`);
|
|
422
|
+
return { ok: true, fit: vp, bounds: drive.savedBounds };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The driver's screen changed size (a turned phone, a resized panel). -> { ok, fit } | { ok: false, error }
|
|
426
|
+
async function fit(viewport) {
|
|
427
|
+
const drive = current;
|
|
428
|
+
if (!drive || drive.stopped) return { ok: false, error: "not-driving" };
|
|
429
|
+
const vp = checkViewport(viewport);
|
|
430
|
+
if (!vp) return { ok: false, error: "bad-viewport" };
|
|
431
|
+
try {
|
|
432
|
+
await applyViewport(drive, vp);
|
|
433
|
+
} catch (e) {
|
|
434
|
+
console.error(`[browser] resizing the driver's view failed: ${e.message}`);
|
|
435
|
+
return { ok: false, error: "browser-unavailable" };
|
|
436
|
+
}
|
|
437
|
+
scheduleFields(drive, 400);
|
|
438
|
+
view.driveFeed.emit("start", { session: drive.session.name, fit: vp });
|
|
439
|
+
return { ok: true, fit: vp };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Undo everything this drive changed. The emulation goes with the DevTools session anyway; the
|
|
443
|
+
// window size does not.
|
|
444
|
+
async function restore(drive) {
|
|
445
|
+
drive.stopped = true;
|
|
446
|
+
clearTimeout(drive.fieldsTimer);
|
|
447
|
+
clearTimeout(drive.nudgeTimer);
|
|
448
|
+
const { conn, sid } = drive;
|
|
449
|
+
if (conn && !conn.closed) {
|
|
450
|
+
if (sid && drive.screencasting) await call(conn, "Page.stopScreencast", {}, sid).catch(() => {});
|
|
451
|
+
if (sid) await call(conn, "Emulation.clearDeviceMetricsOverride", {}, sid).catch(() => {});
|
|
452
|
+
if (sid) await call(conn, "Emulation.setTouchEmulationEnabled", { enabled: false }, sid).catch(() => {});
|
|
453
|
+
if (drive.windowId !== null && drive.savedBounds) {
|
|
454
|
+
await call(conn, "Browser.setWindowBounds", { windowId: drive.windowId, bounds: { ...drive.savedBounds, windowState: "normal" } }).catch(() => {});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
closeConnection(conn);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The DevTools connection or the tab went away under a drive.
|
|
461
|
+
function lost(drive) {
|
|
462
|
+
if (current === drive) {
|
|
463
|
+
current = null;
|
|
464
|
+
view.driveFeed.active = null;
|
|
465
|
+
view.driveFeed.last = null;
|
|
466
|
+
}
|
|
467
|
+
drive.stopped = true;
|
|
468
|
+
clearTimeout(drive.fieldsTimer);
|
|
469
|
+
clearTimeout(drive.nudgeTimer);
|
|
470
|
+
closeConnection(drive.conn);
|
|
471
|
+
view.driveFeed.emit("stop", { session: drive.session.name, reason: "lost" });
|
|
472
|
+
console.error("[browser] the driver's view lost its connection to Chrome");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function stop() {
|
|
476
|
+
const drive = current;
|
|
477
|
+
if (!drive) return false;
|
|
478
|
+
current = null;
|
|
479
|
+
view.driveFeed.active = null;
|
|
480
|
+
view.driveFeed.last = null;
|
|
481
|
+
await restore(drive);
|
|
482
|
+
view.driveFeed.emit("stop", { session: drive.session.name, reason: "handed-back" });
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// After a bot restart nothing is live, but the window may still be at the driver's size: put it
|
|
487
|
+
// back from the size the lock remembers. Best effort.
|
|
488
|
+
async function restoreWindow(session, bounds) {
|
|
489
|
+
if (current || !bounds || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) return false;
|
|
490
|
+
const targetId = session ? deps.targetOf(session) : null;
|
|
491
|
+
if (!targetId || !deps.endpoint()) return false;
|
|
492
|
+
let conn;
|
|
493
|
+
try {
|
|
494
|
+
conn = await openConnection();
|
|
495
|
+
const win = await call(conn, "Browser.getWindowForTarget", { targetId });
|
|
496
|
+
await call(conn, "Browser.setWindowBounds", { windowId: win.windowId, bounds: { width: Math.round(bounds.width), height: Math.round(bounds.height), windowState: "normal" } });
|
|
497
|
+
return true;
|
|
498
|
+
} catch (_) {
|
|
499
|
+
return false;
|
|
500
|
+
} finally {
|
|
501
|
+
closeConnection(conn);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function active(sessionName) {
|
|
506
|
+
return !!current && !current.stopped && (sessionName === undefined || current.session.name === sessionName);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function lastFrame() {
|
|
510
|
+
return current && current.lastFrame ? { data: current.lastFrame.data, metadata: current.lastFrame.metadata, at: current.lastFrame.at } : null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Input: events already checked by core/browser-control.js checkEvent.
|
|
515
|
+
|
|
516
|
+
// A touch's time on the driver's device (ms, their clock) moved onto this machine's clock, so a
|
|
517
|
+
// swipe's speed (and its fling) survives the trip, in seconds as Chrome wants. A gesture's first
|
|
518
|
+
// touch lands TOUCH_SLACK_MS in the past, so the touches that arrive with it (a batch spans about
|
|
519
|
+
// one round trip) keep their spacing instead of being cut to "now".
|
|
520
|
+
const TOUCH_SLACK_MS = 150;
|
|
521
|
+
function touchTime(drive, ev) {
|
|
522
|
+
const now = deps.now();
|
|
523
|
+
if (!Number.isFinite(ev.t)) return now / 1000;
|
|
524
|
+
if (!drive.touchBase || (ev.eventType === "touchStart" && !drive.touching)) drive.touchBase = { offset: now - TOUCH_SLACK_MS - ev.t };
|
|
525
|
+
const at = ev.t + drive.touchBase.offset;
|
|
526
|
+
// Never in the future, never more than a few seconds behind.
|
|
527
|
+
return Math.min(now, Math.max(now - 5000, at)) / 1000;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// The driver's points are the page's CSS pixels; with the view drawn at its density (Emulation
|
|
531
|
+
// scale), Chrome reads input in the drawn picture's pixels, so every point and scroll is scaled
|
|
532
|
+
// by it (proven 10 Oct on Chrome 149: a tap at CSS (46,143) with scale 2 landed at (23,72)).
|
|
533
|
+
function toCdp(drive, ev) {
|
|
534
|
+
const k = drive.viewport && drive.viewport.dpr > 0 ? drive.viewport.dpr : 1;
|
|
535
|
+
const at = (n) => Math.round(n * k * 100) / 100;
|
|
536
|
+
if (ev.type === "input_mouse") {
|
|
537
|
+
const p = { type: ev.eventType, x: at(ev.x), y: at(ev.y), button: ev.button || "none", clickCount: ev.clickCount || 0, modifiers: ev.modifiers || 0, pointerType: "mouse" };
|
|
538
|
+
if (ev.eventType === "mouseWheel") { p.deltaX = at(ev.deltaX || 0); p.deltaY = at(ev.deltaY || 0); }
|
|
539
|
+
return ["Input.dispatchMouseEvent", p];
|
|
540
|
+
}
|
|
541
|
+
if (ev.type === "input_keyboard") {
|
|
542
|
+
const p = { type: ev.eventType === "keyDown" && !ev.text ? "rawKeyDown" : ev.eventType, modifiers: ev.modifiers || 0 };
|
|
543
|
+
if (ev.key !== undefined) p.key = ev.key;
|
|
544
|
+
if (ev.code !== undefined) p.code = ev.code;
|
|
545
|
+
if (ev.text !== undefined) { p.text = ev.text; p.unmodifiedText = ev.text; }
|
|
546
|
+
if (ev.windowsVirtualKeyCode !== undefined) { p.windowsVirtualKeyCode = ev.windowsVirtualKeyCode; p.nativeVirtualKeyCode = ev.windowsVirtualKeyCode; }
|
|
547
|
+
return ["Input.dispatchKeyEvent", p];
|
|
548
|
+
}
|
|
549
|
+
if (ev.type === "input_touch") {
|
|
550
|
+
const ending = ev.eventType === "touchEnd" || ev.eventType === "touchCancel";
|
|
551
|
+
const p = {
|
|
552
|
+
type: ev.eventType,
|
|
553
|
+
// Chrome wants no points on an end or a cancel.
|
|
554
|
+
touchPoints: ending ? [] : ev.touchPoints.map((t, i) => ({ x: at(t.x), y: at(t.y), id: t.id !== undefined ? t.id : i, radiusX: 1, radiusY: 1, force: 1 })),
|
|
555
|
+
modifiers: ev.modifiers || 0,
|
|
556
|
+
timestamp: touchTime(drive, ev),
|
|
557
|
+
};
|
|
558
|
+
drive.touching = !ending;
|
|
559
|
+
return ["Input.dispatchTouchEvent", p];
|
|
560
|
+
}
|
|
561
|
+
if (ev.type === "input_text") return ["Input.insertText", { text: String(ev.text).slice(0, TEXT_MAX) }];
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Send a batch to Chrome in order without waiting for each answer. -> number sent
|
|
566
|
+
function dispatch(events) {
|
|
567
|
+
const drive = current;
|
|
568
|
+
if (!drive || drive.stopped || drive.conn.closed) return -1;
|
|
569
|
+
let sent = 0;
|
|
570
|
+
let tapped = false;
|
|
571
|
+
for (const ev of events) {
|
|
572
|
+
const cdp = toCdp(drive, ev);
|
|
573
|
+
if (!cdp) continue;
|
|
574
|
+
fire(drive.conn, cdp[0], cdp[1], drive.sid);
|
|
575
|
+
sent += 1;
|
|
576
|
+
if (ev.eventType === "touchEnd" || ev.eventType === "mouseReleased" || ev.type === "input_text" || (ev.type === "input_keyboard" && ev.key === "Tab")) tapped = true;
|
|
577
|
+
}
|
|
578
|
+
// A tap or a Tab can move the focus: read the fields again soon.
|
|
579
|
+
if (tapped) scheduleFields(drive, 200);
|
|
580
|
+
return sent;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function _setDeps(overrides = {}) {
|
|
584
|
+
deps = { ...deps, ...overrides };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function _reset() {
|
|
588
|
+
if (current) {
|
|
589
|
+
const drive = current;
|
|
590
|
+
current = null;
|
|
591
|
+
drive.stopped = true;
|
|
592
|
+
clearTimeout(drive.fieldsTimer);
|
|
593
|
+
closeConnection(drive.conn);
|
|
594
|
+
}
|
|
595
|
+
view.driveFeed.active = null;
|
|
596
|
+
view.driveFeed.last = null;
|
|
597
|
+
view.driveFeed.removeAllListeners();
|
|
598
|
+
deps = defaultDeps();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
module.exports = {
|
|
602
|
+
FIELDS_SCRIPT,
|
|
603
|
+
MAX_FPS,
|
|
604
|
+
MAX_PIXELS,
|
|
605
|
+
QUALITY,
|
|
606
|
+
TEXT_MAX,
|
|
607
|
+
active,
|
|
608
|
+
checkViewport,
|
|
609
|
+
cleanFields,
|
|
610
|
+
dispatch,
|
|
611
|
+
fit,
|
|
612
|
+
lastFrame,
|
|
613
|
+
restoreWindow,
|
|
614
|
+
start,
|
|
615
|
+
stop,
|
|
616
|
+
supported: () => !!deps.endpoint(),
|
|
617
|
+
_reset,
|
|
618
|
+
_setDeps,
|
|
619
|
+
};
|
package/core/browser-lock.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// hook (core/tool-guard.js, C7), which runs as a separate process for every tool call and
|
|
5
5
|
// re-reads it each time, like the tooling-mode switch (core/tooling-mode.js).
|
|
6
6
|
//
|
|
7
|
-
// { session, userId, name, chatId, since, expiresAt } (ISO times;
|
|
7
|
+
// { session, userId, name, chatId, since, expiresAt, fit?, bounds? } (ISO times; atomic, 0600)
|
|
8
|
+
// fit: the driver's view (core/browser-drive.js), bounds: the window's size before it, so a
|
|
9
|
+
// restarted bot can bring the view back, and the hand back can put the window back.
|
|
8
10
|
//
|
|
9
11
|
// An expired lock is no lock: it never blocks the bot, and the sweeper in
|
|
10
12
|
// core/browser-control.js hands the browser back.
|
|
@@ -48,4 +50,33 @@ function removeLock() {
|
|
|
48
50
|
try { fs.rmSync(driverFile(), { force: true }); } catch (_) { /* already gone */ }
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
// The bot was told to wait (core/tool-guard.js refused one of its agent-browser commands while
|
|
54
|
+
// this lock was live), so the hand back must wake it. Keyed by the lock's `since`: a marker from
|
|
55
|
+
// an earlier drive never counts. A separate file, because the tool guard runs in its own process
|
|
56
|
+
// and the lock file is rewritten every second while someone drives.
|
|
57
|
+
function waitingFile() {
|
|
58
|
+
return process.env.OC_BROWSER_WAITING_FILE || path.join(require("../config-dir"), "browser-waiting.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function markWaiting(lock) {
|
|
62
|
+
if (!lock || !lock.since) return;
|
|
63
|
+
try {
|
|
64
|
+
atomicWriteFileSync(waitingFile(), `${JSON.stringify({ since: lock.since, at: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
65
|
+
} catch (_) { /* best effort: the bot then just is not woken */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function waitedDuring(lock) {
|
|
69
|
+
if (!lock || !lock.since) return false;
|
|
70
|
+
try {
|
|
71
|
+
const mark = JSON.parse(fs.readFileSync(waitingFile(), "utf8"));
|
|
72
|
+
return !!mark && mark.since === lock.since;
|
|
73
|
+
} catch (_) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clearWaiting() {
|
|
79
|
+
try { fs.rmSync(waitingFile(), { force: true }); } catch (_) { /* already gone */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { LOCK_TTL_MS, driverFile, readLock, lockExpiresAt, liveLock, writeLock, removeLock, markWaiting, waitedDuring, clearWaiting };
|