getobsrv 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/out/cli/args.js +5 -1
- package/out/main/cli.js +51 -4
- package/out/main/index.js +72 -10
- package/out/main/{targetSource-DkXWE0ha.js → targetSource-BKW32VA5.js} +15 -5
- package/out/mcp/server.js +64 -8
- package/out/preload/sync.js +102 -5
- package/out/shared/ipcPayloads.js +50 -0
- package/out/shared/types.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,9 +85,18 @@ If the desktop app is open with the toolbar's **Agent control** toggle on,
|
|
|
85
85
|
the preset flip, and the agent gets back a capture of the app exactly as you
|
|
86
86
|
see it (plus `obsrv_drive` to flip URL/preset/profile directly). Agents can
|
|
87
87
|
also scroll, click, pan and highlight while you watch — a drive session works
|
|
88
|
-
as a guided demo.
|
|
88
|
+
as a guided demo. A `scroll` reports the offset it actually reached
|
|
89
|
+
(`scrolled` / `scroller`), finds the inner scroll container on pages whose
|
|
90
|
+
root cannot scroll, and takes a `scrollSelector` when you need to name the
|
|
91
|
+
container yourself. With no app running, everything falls back to the headless
|
|
89
92
|
render automatically.
|
|
90
93
|
|
|
94
|
+
A headless `snap` returns `settled: true` when the page went paint-quiet and
|
|
95
|
+
every pixel painted. `settled: false` is still a usable capture, not a
|
|
96
|
+
failure — a page that kept animating, or one whose repaint never completed,
|
|
97
|
+
comes back as-is (exit code 0) with a warning saying what was missing. Only a
|
|
98
|
+
render that painted nothing at all is an error.
|
|
99
|
+
|
|
91
100
|
Build first, then register:
|
|
92
101
|
|
|
93
102
|
```bash
|
|
@@ -140,6 +149,16 @@ belongs to an unrelated package.
|
|
|
140
149
|
looks different again; a Windows build would show Windows truth natively.
|
|
141
150
|
- Panel simulation is an approximation, not colourimetric.
|
|
142
151
|
- Non-ASCII text input does not type into the target pane (Electron `sendInputEvent`
|
|
143
|
-
limitation)
|
|
152
|
+
limitation).
|
|
153
|
+
- Inner-scroller *reporting* is one-way. An agent `scroll` finds the page's real scroll
|
|
154
|
+
host — the app-shell pattern (`html, body { overflow: hidden }` with an inner
|
|
155
|
+
`overflow-y: auto` container) is handled, and the result reports the offset actually
|
|
156
|
+
reached — but scrolling a nested container **by hand** in the native pane is not
|
|
157
|
+
mirrored to the target: element scroll events don't bubble to `window`, so the report
|
|
158
|
+
side never sees them. Dragging the page itself still syncs both ways.
|
|
159
|
+
- Scroll targeting stops at the light DOM of the top-level document. A scroller inside a
|
|
160
|
+
shadow root or an iframe can't be found automatically *or* named with `scrollSelector`
|
|
161
|
+
(`document.querySelector` doesn't cross either boundary), so a web-component app that
|
|
162
|
+
hides its scroller in a shadow root has no escape hatch.
|
|
144
163
|
- Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
|
|
145
164
|
with dirty rects it has not been needed.
|
package/out/cli/args.js
CHANGED
|
@@ -44,7 +44,11 @@ diff flags:
|
|
|
44
44
|
|
|
45
45
|
Repeated flags: the last occurrence wins.
|
|
46
46
|
Machine output (JSON) goes to stdout; everything human goes to stderr.
|
|
47
|
-
Exit code 0 on success — diff findings are informational, never a failure
|
|
47
|
+
Exit code 0 on success — diff findings are informational, never a failure.
|
|
48
|
+
snap's "settled" is true when the page went paint-quiet and every pixel
|
|
49
|
+
painted. False is a rescued capture, not a failure: a page that kept animating
|
|
50
|
+
(or whose repaint never completed) is written as-is, exit code 0, with a
|
|
51
|
+
warning naming what was missing. Only a render that painted nothing errors.`;
|
|
48
52
|
}
|
|
49
53
|
/** Flags that take no value. */
|
|
50
54
|
const BOOLEAN_FLAGS = new Set(['full-page', 'json']);
|
package/out/main/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ const electron = require("electron");
|
|
|
3
3
|
const node_fs = require("node:fs");
|
|
4
4
|
const node_os = require("node:os");
|
|
5
5
|
const node_path = require("node:path");
|
|
6
|
-
const targetSource = require("./targetSource-
|
|
6
|
+
const targetSource = require("./targetSource-BKW32VA5.js");
|
|
7
7
|
function boxDownsample(src, factor) {
|
|
8
8
|
if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
|
|
9
9
|
const width = Math.floor(src.width / factor);
|
|
@@ -75,7 +75,11 @@ diff flags:
|
|
|
75
75
|
|
|
76
76
|
Repeated flags: the last occurrence wins.
|
|
77
77
|
Machine output (JSON) goes to stdout; everything human goes to stderr.
|
|
78
|
-
Exit code 0 on success — diff findings are informational, never a failure
|
|
78
|
+
Exit code 0 on success — diff findings are informational, never a failure.
|
|
79
|
+
snap's "settled" is true when the page went paint-quiet and every pixel
|
|
80
|
+
painted. False is a rescued capture, not a failure: a page that kept animating
|
|
81
|
+
(or whose repaint never completed) is written as-is, exit code 0, with a
|
|
82
|
+
warning naming what was missing. Only a render that painted nothing errors.`;
|
|
79
83
|
}
|
|
80
84
|
const BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["full-page", "json"]);
|
|
81
85
|
const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
|
|
@@ -218,6 +222,37 @@ ${usage()}`);
|
|
|
218
222
|
}
|
|
219
223
|
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
220
224
|
const DEFAULT_SETTLE_MS = 400;
|
|
225
|
+
function uncoveredBounds(mask, width, height) {
|
|
226
|
+
const rowHasGap = (y) => {
|
|
227
|
+
const row = y * width;
|
|
228
|
+
for (let x = 0; x < width; x++) if (mask[row + x] === 0) return true;
|
|
229
|
+
return false;
|
|
230
|
+
};
|
|
231
|
+
let y0 = -1;
|
|
232
|
+
for (let y = 0; y < height; y++) {
|
|
233
|
+
if (rowHasGap(y)) {
|
|
234
|
+
y0 = y;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (y0 < 0) return null;
|
|
239
|
+
let y1 = y0;
|
|
240
|
+
for (let y = height - 1; y > y0; y--) {
|
|
241
|
+
if (rowHasGap(y)) {
|
|
242
|
+
y1 = y;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const columnHasGap = (x) => {
|
|
247
|
+
for (let y = y0; y <= y1; y++) if (mask[y * width + x] === 0) return true;
|
|
248
|
+
return false;
|
|
249
|
+
};
|
|
250
|
+
let x0 = 0;
|
|
251
|
+
while (x0 < width && !columnHasGap(x0)) x0++;
|
|
252
|
+
let x1 = width - 1;
|
|
253
|
+
while (x1 > x0 && !columnHasGap(x1)) x1--;
|
|
254
|
+
return { x: x0, y: y0, width: x1 - x0 + 1, height: y1 - y0 + 1 };
|
|
255
|
+
}
|
|
221
256
|
async function captureQuiescent(source, options = {}) {
|
|
222
257
|
const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
|
|
223
258
|
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
@@ -228,8 +263,10 @@ async function captureQuiescent(source, options = {}) {
|
|
|
228
263
|
let mask = null;
|
|
229
264
|
let uncovered = 0;
|
|
230
265
|
let lastPaint = Date.now();
|
|
266
|
+
let frames = 0;
|
|
231
267
|
const onFrame = (m) => {
|
|
232
268
|
lastPaint = Date.now();
|
|
269
|
+
frames++;
|
|
233
270
|
if (m.frameWidth !== width || m.frameHeight !== height) {
|
|
234
271
|
width = m.frameWidth;
|
|
235
272
|
height = m.frameHeight;
|
|
@@ -275,9 +312,19 @@ async function captureQuiescent(source, options = {}) {
|
|
|
275
312
|
if (failed) throw failed;
|
|
276
313
|
if (covered && Date.now() - lastPaint >= settleMs) break;
|
|
277
314
|
if (Date.now() >= deadline) {
|
|
278
|
-
if (!covered) throw new Error(`no full frame painted within ${timeoutMs} ms`);
|
|
279
|
-
options.onWarn?.(`page kept painting for ${timeoutMs} ms (animation?); capturing the current frame`);
|
|
280
315
|
settled = false;
|
|
316
|
+
if (covered) {
|
|
317
|
+
options.onWarn?.(`page kept painting for ${timeoutMs} ms (animation?); capturing the current frame`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (frames === 0 || width === 0 || height === 0) {
|
|
321
|
+
throw new Error(`no frame painted within ${timeoutMs} ms`);
|
|
322
|
+
}
|
|
323
|
+
const total = width * height;
|
|
324
|
+
const box = mask ? uncoveredBounds(mask, width, height) : null;
|
|
325
|
+
options.onWarn?.(
|
|
326
|
+
`warning: ${(uncovered / total * 100).toFixed(1)}% of the ${width}x${height} frame never painted within ${timeoutMs} ms` + (box ? ` (uncovered region ${box.width}x${box.height} at ${box.x},${box.y})` : "") + `; those pixels are transparent, not page content. Returning the frame as captured (settled: false)`
|
|
327
|
+
);
|
|
281
328
|
break;
|
|
282
329
|
}
|
|
283
330
|
await sleep$1(Math.min(50, settleMs));
|
package/out/main/index.js
CHANGED
|
@@ -4,7 +4,7 @@ const node_fs = require("node:fs");
|
|
|
4
4
|
const promises = require("node:fs/promises");
|
|
5
5
|
const node_path = require("node:path");
|
|
6
6
|
const node_crypto = require("node:crypto");
|
|
7
|
-
const targetSource = require("./targetSource-
|
|
7
|
+
const targetSource = require("./targetSource-BKW32VA5.js");
|
|
8
8
|
const node_http = require("node:http");
|
|
9
9
|
const node_url = require("node:url");
|
|
10
10
|
const IPC = {
|
|
@@ -28,6 +28,7 @@ const IPC = {
|
|
|
28
28
|
targetNavigating: "obsrv:target-navigating",
|
|
29
29
|
syncScroll: "obsrv:sync-scroll",
|
|
30
30
|
applyScroll: "obsrv:apply-scroll",
|
|
31
|
+
scrollResult: "obsrv:scroll-result",
|
|
31
32
|
openImage: "obsrv:open-image",
|
|
32
33
|
focusUrl: "obsrv:focus-url",
|
|
33
34
|
openImagePath: "obsrv:open-image-path",
|
|
@@ -68,7 +69,9 @@ function attachFrameBus(target, win) {
|
|
|
68
69
|
}
|
|
69
70
|
};
|
|
70
71
|
}
|
|
72
|
+
const MAX_SCROLL_SELECTOR = 512;
|
|
71
73
|
const MAX_RECT = 16384;
|
|
74
|
+
const MAX_SCROLL_WARNINGS = 4;
|
|
72
75
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
73
76
|
const isRecord$1 = (v) => typeof v === "object" && v !== null;
|
|
74
77
|
function parseRect(raw) {
|
|
@@ -154,6 +157,26 @@ function parseScrollPos(raw) {
|
|
|
154
157
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
|
|
155
158
|
return { x, y };
|
|
156
159
|
}
|
|
160
|
+
function parseScrollRequest(raw) {
|
|
161
|
+
const pos = parseScrollPos(raw);
|
|
162
|
+
if (!pos) return "scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets";
|
|
163
|
+
const selector = raw.scrollSelector;
|
|
164
|
+
if (selector === void 0 || selector === null) return pos;
|
|
165
|
+
if (typeof selector !== "string") return "scrollSelector must be a CSS selector string";
|
|
166
|
+
const trimmed = selector.trim();
|
|
167
|
+
if (trimmed === "") return "scrollSelector must not be empty";
|
|
168
|
+
if (trimmed.length > MAX_SCROLL_SELECTOR) return `scrollSelector must be at most ${MAX_SCROLL_SELECTOR} characters`;
|
|
169
|
+
return { ...pos, selector: trimmed };
|
|
170
|
+
}
|
|
171
|
+
function parseScrollReport(raw) {
|
|
172
|
+
if (!isRecord$1(raw)) return null;
|
|
173
|
+
const { id, x, y, scroller } = raw;
|
|
174
|
+
if (!isFiniteNumber(id)) return null;
|
|
175
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
|
|
176
|
+
if (scroller !== "root" && scroller !== "element") return null;
|
|
177
|
+
const warnings = Array.isArray(raw.warnings) ? raw.warnings.filter((w) => typeof w === "string").slice(0, MAX_SCROLL_WARNINGS) : [];
|
|
178
|
+
return { id, x, y, scroller, warnings };
|
|
179
|
+
}
|
|
157
180
|
const CONTROL_FILE_NAME = "control.json";
|
|
158
181
|
const CONTROL_TOKEN_BYTES = 32;
|
|
159
182
|
const CONTROL_COMMANDS = [
|
|
@@ -374,10 +397,16 @@ class ControlServer {
|
|
|
374
397
|
return reply(200, { ok: true, ...capture });
|
|
375
398
|
}
|
|
376
399
|
case "scroll": {
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
379
|
-
this.deps.scroll(
|
|
380
|
-
return reply(200, { ok: true });
|
|
400
|
+
const req2 = parseScrollRequest(payload);
|
|
401
|
+
if (typeof req2 === "string") return reply(400, { error: req2 });
|
|
402
|
+
const result = await this.deps.scroll(req2);
|
|
403
|
+
if (!result) return reply(200, { ok: true, scrolled: null, warnings: ["scroll offset could not be confirmed"] });
|
|
404
|
+
return reply(200, {
|
|
405
|
+
ok: true,
|
|
406
|
+
scrolled: { x: result.x, y: result.y },
|
|
407
|
+
scroller: result.scroller,
|
|
408
|
+
...result.warnings.length > 0 ? { warnings: result.warnings } : {}
|
|
409
|
+
});
|
|
381
410
|
}
|
|
382
411
|
case "panTo": {
|
|
383
412
|
const pos = parseScrollPos(payload);
|
|
@@ -455,6 +484,7 @@ class ControlServer {
|
|
|
455
484
|
}
|
|
456
485
|
const TOOLBAR_H = 44;
|
|
457
486
|
const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
|
|
487
|
+
const SCROLL_REPLY_TIMEOUT_MS = 1e3;
|
|
458
488
|
function hostInfo(win) {
|
|
459
489
|
try {
|
|
460
490
|
const d = electron.screen.getDisplayMatching(win.getBounds());
|
|
@@ -551,6 +581,37 @@ function registerIpc(ctx) {
|
|
|
551
581
|
native.setBounds(rect);
|
|
552
582
|
rendererDrivesLayout = true;
|
|
553
583
|
});
|
|
584
|
+
let scrollSeq = 0;
|
|
585
|
+
const scrollWaiters = /* @__PURE__ */ new Map();
|
|
586
|
+
electron.ipcMain.on(IPC.scrollResult, (e, raw) => {
|
|
587
|
+
if (e.sender !== target.webContents && e.sender !== native.webContents) return;
|
|
588
|
+
const report = parseScrollReport(raw);
|
|
589
|
+
if (!report) return;
|
|
590
|
+
const waiter = scrollWaiters.get(report.id);
|
|
591
|
+
if (!waiter) return;
|
|
592
|
+
scrollWaiters.delete(report.id);
|
|
593
|
+
waiter(report);
|
|
594
|
+
});
|
|
595
|
+
const scrollBoth = async (req) => {
|
|
596
|
+
const base = { x: req.x, y: req.y };
|
|
597
|
+
if (req.selector !== void 0) base.selector = req.selector;
|
|
598
|
+
if (!native.webContents.isDestroyed()) native.webContents.send(IPC.applyScroll, base);
|
|
599
|
+
const wc = target.webContents;
|
|
600
|
+
if (wc.isDestroyed()) return null;
|
|
601
|
+
const id = ++scrollSeq;
|
|
602
|
+
const answered = new Promise((resolve) => {
|
|
603
|
+
const timer = setTimeout(() => {
|
|
604
|
+
scrollWaiters.delete(id);
|
|
605
|
+
resolve(null);
|
|
606
|
+
}, SCROLL_REPLY_TIMEOUT_MS);
|
|
607
|
+
scrollWaiters.set(id, (report) => {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
resolve(report);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
wc.send(IPC.applyScroll, { ...base, id });
|
|
613
|
+
return answered;
|
|
614
|
+
};
|
|
554
615
|
electron.ipcMain.handle(IPC.getHostInfo, (e) => {
|
|
555
616
|
assertRenderer(e);
|
|
556
617
|
return hostInfo(win);
|
|
@@ -653,11 +714,12 @@ function registerIpc(ctx) {
|
|
|
653
714
|
};
|
|
654
715
|
},
|
|
655
716
|
viewport: () => target.getViewport(),
|
|
656
|
-
scroll
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
717
|
+
// An agent scroll drives both panes over the same `applyScroll` channel
|
|
718
|
+
// the pane-sync mirror uses — each pane's sync preload applies it and
|
|
719
|
+
// suppresses its own echo, so the two arrive together with no loop.
|
|
720
|
+
// Relying on the mirror instead would be silent: an applied scroll is
|
|
721
|
+
// deliberately not re-reported (see preload/sync.ts).
|
|
722
|
+
scroll: scrollBoth,
|
|
661
723
|
click: (c) => {
|
|
662
724
|
const down = parseInputEvent({ type: "mouseDown", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
663
725
|
const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
@@ -59,6 +59,15 @@ function classifyFileNavigation(from, to) {
|
|
|
59
59
|
if (IMAGE_EXTENSIONS.test(path)) return "image";
|
|
60
60
|
return from.startsWith("file:") ? "allow" : "block";
|
|
61
61
|
}
|
|
62
|
+
function isFullFrame(dirty, frameWidth, frameHeight, deviceScaleFactor) {
|
|
63
|
+
if (dirty.x !== 0 || dirty.y !== 0) return false;
|
|
64
|
+
if (dirty.width === frameWidth && dirty.height === frameHeight) return true;
|
|
65
|
+
if (!(deviceScaleFactor > 1)) return false;
|
|
66
|
+
return dirty.width === Math.round(frameWidth / deviceScaleFactor) && dirty.height === Math.round(frameHeight / deviceScaleFactor);
|
|
67
|
+
}
|
|
68
|
+
function fitsFrame(dirty, frameWidth, frameHeight) {
|
|
69
|
+
return dirty.x >= 0 && dirty.y >= 0 && dirty.width > 0 && dirty.height > 0 && dirty.x + dirty.width <= frameWidth && dirty.y + dirty.height <= frameHeight;
|
|
70
|
+
}
|
|
62
71
|
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
63
72
|
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i;
|
|
64
73
|
function normalizeUrl(input) {
|
|
@@ -159,13 +168,14 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
159
168
|
if (dirty.width <= 0 || dirty.height <= 0) return;
|
|
160
169
|
if (image.isEmpty()) return;
|
|
161
170
|
const full = image.getSize();
|
|
162
|
-
const isFull = dirty
|
|
171
|
+
const isFull = isFullFrame(dirty, full.width, full.height, this.dsf);
|
|
172
|
+
if (!isFull && !fitsFrame(dirty, full.width, full.height)) return;
|
|
163
173
|
this.emit("frame", {
|
|
164
174
|
frame: {
|
|
165
|
-
x: dirty.x,
|
|
166
|
-
y: dirty.y,
|
|
167
|
-
width: dirty.width,
|
|
168
|
-
height: dirty.height,
|
|
175
|
+
x: isFull ? 0 : dirty.x,
|
|
176
|
+
y: isFull ? 0 : dirty.y,
|
|
177
|
+
width: isFull ? full.width : dirty.width,
|
|
178
|
+
height: isFull ? full.height : dirty.height,
|
|
169
179
|
// `toBitmap()` already returns a fresh copy of the pixels (unlike the
|
|
170
180
|
// deprecated `getBitmap()`, typed `void` in Electron 43), and a
|
|
171
181
|
// Buffer is a Uint8Array, so this is the only copy of the slice.
|
package/out/mcp/server.js
CHANGED
|
@@ -11,6 +11,7 @@ const zod_1 = require("zod");
|
|
|
11
11
|
const args_1 = require("../cli/args");
|
|
12
12
|
const control_1 = require("../shared/control");
|
|
13
13
|
const presets_1 = require("../shared/presets");
|
|
14
|
+
const types_1 = require("../shared/types");
|
|
14
15
|
const control_2 = require("./control");
|
|
15
16
|
const lib_1 = require("./lib");
|
|
16
17
|
/**
|
|
@@ -141,7 +142,9 @@ const snapOutputShape = {
|
|
|
141
142
|
profile: zod_1.z.string().optional().describe('Headless only: applied panel profile id.'),
|
|
142
143
|
settled: zod_1.z
|
|
143
144
|
.boolean()
|
|
144
|
-
.describe('Headless: the page went paint-quiet.
|
|
145
|
+
.describe('Headless: the page went paint-quiet and every pixel painted. False is still a usable capture — a page that ' +
|
|
146
|
+
'kept animating, or one whose repaint never completed, is returned as-is with a warning saying what was ' +
|
|
147
|
+
'missing. Live: the app confirmed the navigation before the capture.'),
|
|
145
148
|
warnings: zod_1.z.array(zod_1.z.string()),
|
|
146
149
|
pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
|
|
147
150
|
url: zod_1.z.string().optional().describe('Live only: the URL the app reports showing.'),
|
|
@@ -233,9 +236,24 @@ const driveInputShape = {
|
|
|
233
236
|
back: zod_1.z.boolean().optional().describe('true: history back (native pane history; the target mirrors the committed page).'),
|
|
234
237
|
forward: zod_1.z.boolean().optional().describe('true: history forward (native pane history; the target mirrors it).'),
|
|
235
238
|
scroll: zod_1.z
|
|
236
|
-
.object({
|
|
239
|
+
.object({
|
|
240
|
+
x: zod_1.z.number().min(0),
|
|
241
|
+
y: zod_1.z.number().min(0),
|
|
242
|
+
scrollSelector: zod_1.z
|
|
243
|
+
.string()
|
|
244
|
+
.min(1)
|
|
245
|
+
.max(types_1.MAX_SCROLL_SELECTOR)
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Escape hatch: a CSS selector naming the element to scroll, for pages whose scroll host the automatic ' +
|
|
248
|
+
'detection misjudges (several large scrollers, a virtualised list that translates content). No fallback ' +
|
|
249
|
+
'if it matches nothing — the result says so. Same reach as the detection: light DOM of the top-level ' +
|
|
250
|
+
'document only, so a scroller inside a shadow root or an iframe cannot be targeted.'),
|
|
251
|
+
})
|
|
237
252
|
.optional()
|
|
238
|
-
.describe('Scroll both panes to this absolute page offset in CSS px.'
|
|
253
|
+
.describe('Scroll both panes to this absolute page offset in CSS px. Pages whose root cannot scroll (app shells with ' +
|
|
254
|
+
'`html, body { overflow: hidden }` and an inner `overflow-y: auto` container) are handled: the largest ' +
|
|
255
|
+
'visible inner scroller is found and scrolled instead. Check `scrolled` in the result for the offset ' +
|
|
256
|
+
'actually reached — that is how you tell a real scroll from one that clamped.'),
|
|
239
257
|
panTo: zod_1.z
|
|
240
258
|
.object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
|
|
241
259
|
.optional()
|
|
@@ -263,6 +281,18 @@ const driveOutputShape = {
|
|
|
263
281
|
profileId: zod_1.z.string(),
|
|
264
282
|
viewMode: zod_1.z.string(),
|
|
265
283
|
mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
|
|
284
|
+
scrolled: zod_1.z
|
|
285
|
+
.object({ x: zod_1.z.number(), y: zod_1.z.number() })
|
|
286
|
+
.nullable()
|
|
287
|
+
.optional()
|
|
288
|
+
.describe('Only when `scroll` was requested: the offset the target pane actually reached, read back after the write. ' +
|
|
289
|
+
'Less than you asked for means the content clamped (short page, or the wrong scroller). Null means the ' +
|
|
290
|
+
'pane did not confirm in time — the scroll may still have landed.'),
|
|
291
|
+
scroller: zod_1.z
|
|
292
|
+
.enum(['root', 'element'])
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("Only when `scroll` was requested: 'root' if the document scrolled, 'element' if an inner scroll container did."),
|
|
295
|
+
warnings: zod_1.z.array(zod_1.z.string()).optional().describe('Anything worth knowing about the commands that ran (e.g. a scrollSelector that matched nothing).'),
|
|
266
296
|
};
|
|
267
297
|
// --- live drive --------------------------------------------------------------
|
|
268
298
|
/** Budget for one control `status` round-trip once the app is known live. */
|
|
@@ -504,7 +534,10 @@ server.registerTool('obsrv_drive', {
|
|
|
504
534
|
`Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
|
|
505
535
|
`preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight. ` +
|
|
506
536
|
`The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
|
|
507
|
-
`click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. ` +
|
|
537
|
+
`click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
|
|
538
|
+
`scroll adds \`scrolled\` (the offset actually reached) and \`scroller\` ('root' or 'element'): compare ` +
|
|
539
|
+
`\`scrolled\` with what you asked for rather than trusting the call's success, and use \`scroll.scrollSelector\` ` +
|
|
540
|
+
`when the automatic scroll-host detection picks the wrong container.\n\n` +
|
|
508
541
|
`Coordinates: click takes CSS-viewport px of the page (the valid range is 0 up to but not including the ` +
|
|
509
542
|
`viewport size); panTo and highlight take target-pane pixels (device px of the render — identical to CSS px ` +
|
|
510
543
|
`on 1x presets); scroll takes page CSS px.\n\n` +
|
|
@@ -548,8 +581,25 @@ server.registerTool('obsrv_drive', {
|
|
|
548
581
|
await (0, control_2.controlCall)(live.info, 'back', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
549
582
|
if (input.forward)
|
|
550
583
|
await (0, control_2.controlCall)(live.info, 'forward', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
551
|
-
|
|
552
|
-
|
|
584
|
+
// The scroll answer is the interesting half: it reports the offset the
|
|
585
|
+
// pane reached, which is the only way to tell a scroll from a clamp.
|
|
586
|
+
let scrolled;
|
|
587
|
+
let scroller;
|
|
588
|
+
const warnings = [];
|
|
589
|
+
if (input.scroll !== undefined) {
|
|
590
|
+
const r = await (0, control_2.controlCall)(live.info, 'scroll', input.scroll, LIVE_APPLY_TIMEOUT_MS);
|
|
591
|
+
const at = r['scrolled'];
|
|
592
|
+
scrolled =
|
|
593
|
+
at !== null && typeof at === 'object' && typeof at.x === 'number' && typeof at.y === 'number'
|
|
594
|
+
? { x: at.x, y: at.y }
|
|
595
|
+
: null;
|
|
596
|
+
if (r['scroller'] === 'root' || r['scroller'] === 'element')
|
|
597
|
+
scroller = r['scroller'];
|
|
598
|
+
if (Array.isArray(r['warnings']))
|
|
599
|
+
for (const w of r['warnings'])
|
|
600
|
+
if (typeof w === 'string')
|
|
601
|
+
warnings.push(w);
|
|
602
|
+
}
|
|
553
603
|
if (input.panTo !== undefined)
|
|
554
604
|
await (0, control_2.controlCall)(live.info, 'panTo', input.panTo, LIVE_APPLY_TIMEOUT_MS);
|
|
555
605
|
if (input.click !== undefined) {
|
|
@@ -574,9 +624,15 @@ server.registerTool('obsrv_drive', {
|
|
|
574
624
|
const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
575
625
|
if (!status)
|
|
576
626
|
return toolError('the control server returned a malformed status');
|
|
627
|
+
const structured = {
|
|
628
|
+
...status,
|
|
629
|
+
...(input.scroll !== undefined ? { scrolled: scrolled ?? null } : {}),
|
|
630
|
+
...(scroller !== undefined ? { scroller } : {}),
|
|
631
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
632
|
+
};
|
|
577
633
|
return {
|
|
578
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
579
|
-
structuredContent:
|
|
634
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
635
|
+
structuredContent: structured,
|
|
580
636
|
};
|
|
581
637
|
}
|
|
582
638
|
catch (e) {
|
package/out/preload/sync.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
3
|
const electron = require("electron");
|
|
3
4
|
const SUPPRESS_MS = 120;
|
|
4
5
|
const SYNC_SCROLL = "obsrv:sync-scroll";
|
|
5
6
|
const APPLY_SCROLL = "obsrv:apply-scroll";
|
|
7
|
+
const SCROLL_RESULT = "obsrv:scroll-result";
|
|
8
|
+
const MAX_VISITED = 2e3;
|
|
9
|
+
const SCROLL_EPSILON = 1;
|
|
6
10
|
let suppressUntil = 0;
|
|
7
11
|
let lastApplied = null;
|
|
8
12
|
let rafId = 0;
|
|
@@ -33,9 +37,102 @@ window.addEventListener(
|
|
|
33
37
|
},
|
|
34
38
|
{ passive: true }
|
|
35
39
|
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
function rootScrolls() {
|
|
41
|
+
const el = document.scrollingElement;
|
|
42
|
+
if (!el) return false;
|
|
43
|
+
return el.scrollHeight > el.clientHeight + SCROLL_EPSILON || el.scrollWidth > el.clientWidth + SCROLL_EPSILON;
|
|
44
|
+
}
|
|
45
|
+
function canScroll(el) {
|
|
46
|
+
const overflowsY = el.scrollHeight > el.clientHeight + SCROLL_EPSILON;
|
|
47
|
+
const overflowsX = el.scrollWidth > el.clientWidth + SCROLL_EPSILON;
|
|
48
|
+
if (!overflowsY && !overflowsX) return false;
|
|
49
|
+
const style = window.getComputedStyle(el);
|
|
50
|
+
const scrollableY = style.overflowY === "auto" || style.overflowY === "scroll";
|
|
51
|
+
const scrollableX = style.overflowX === "auto" || style.overflowX === "scroll";
|
|
52
|
+
return overflowsY && scrollableY || overflowsX && scrollableX;
|
|
53
|
+
}
|
|
54
|
+
function isVisible(el) {
|
|
55
|
+
const check = el.checkVisibility;
|
|
56
|
+
if (typeof check !== "function") return el.getClientRects().length > 0;
|
|
57
|
+
return check.call(el, { visibilityProperty: true, opacityProperty: true });
|
|
58
|
+
}
|
|
59
|
+
function findScroller(root = document.body) {
|
|
60
|
+
if (!root) return null;
|
|
61
|
+
let best = null;
|
|
62
|
+
let bestArea = 0;
|
|
63
|
+
let visited = 0;
|
|
64
|
+
const stack = [root];
|
|
65
|
+
while (stack.length > 0) {
|
|
66
|
+
const el = stack.pop();
|
|
67
|
+
if (visited++ >= MAX_VISITED) break;
|
|
68
|
+
const area = el.clientWidth * el.clientHeight;
|
|
69
|
+
if (area <= 0 && el.getClientRects().length === 0 && window.getComputedStyle(el).display === "none") continue;
|
|
70
|
+
if (area > bestArea && canScroll(el) && isVisible(el)) {
|
|
71
|
+
best = el;
|
|
72
|
+
bestArea = area;
|
|
73
|
+
}
|
|
74
|
+
const kids = el.children;
|
|
75
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
let cachedScroller = null;
|
|
80
|
+
function resolveScroller() {
|
|
81
|
+
if (rootScrolls()) {
|
|
82
|
+
cachedScroller = null;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (cachedScroller && cachedScroller.isConnected && canScroll(cachedScroller)) return cachedScroller;
|
|
86
|
+
cachedScroller = findScroller();
|
|
87
|
+
return cachedScroller;
|
|
88
|
+
}
|
|
89
|
+
function applyTo(el, pos) {
|
|
90
|
+
if (el) {
|
|
91
|
+
el.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
|
|
92
|
+
return { x: el.scrollLeft, y: el.scrollTop };
|
|
93
|
+
}
|
|
94
|
+
window.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
|
|
95
|
+
return { x: window.scrollX, y: window.scrollY };
|
|
96
|
+
}
|
|
97
|
+
electron.ipcRenderer.on(APPLY_SCROLL, (_e, req) => {
|
|
98
|
+
const pos = { x: req.x, y: req.y };
|
|
99
|
+
const warnings = [];
|
|
100
|
+
let scroller = "root";
|
|
101
|
+
let reached;
|
|
102
|
+
if (typeof req.selector === "string") {
|
|
103
|
+
let el = null;
|
|
104
|
+
try {
|
|
105
|
+
el = document.querySelector(req.selector);
|
|
106
|
+
} catch {
|
|
107
|
+
warnings.push(`scrollSelector ${JSON.stringify(req.selector)} is not a valid CSS selector; nothing was scrolled`);
|
|
108
|
+
}
|
|
109
|
+
if (el) {
|
|
110
|
+
scroller = "element";
|
|
111
|
+
reached = applyTo(el, pos);
|
|
112
|
+
if (reached.x !== pos.x || reached.y !== pos.y) {
|
|
113
|
+
warnings.push(
|
|
114
|
+
`scrollSelector ${JSON.stringify(req.selector)} matched an element that could not reach (${pos.x}, ${pos.y}); it stopped at (${reached.x}, ${reached.y})`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
if (warnings.length === 0) {
|
|
119
|
+
warnings.push(`scrollSelector ${JSON.stringify(req.selector)} matched no element; nothing was scrolled`);
|
|
120
|
+
}
|
|
121
|
+
reached = { x: window.scrollX, y: window.scrollY };
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
const el = resolveScroller();
|
|
125
|
+
scroller = el ? "element" : "root";
|
|
126
|
+
if (!el) {
|
|
127
|
+
lastApplied = pos;
|
|
128
|
+
if (window.scrollX !== pos.x || window.scrollY !== pos.y) suppressUntil = performance.now() + SUPPRESS_MS;
|
|
129
|
+
}
|
|
130
|
+
reached = applyTo(el, pos);
|
|
131
|
+
}
|
|
132
|
+
if (typeof req.id === "number") {
|
|
133
|
+
electron.ipcRenderer.send(SCROLL_RESULT, { id: req.id, x: reached.x, y: reached.y, scroller, warnings });
|
|
134
|
+
}
|
|
41
135
|
});
|
|
136
|
+
exports.MAX_VISITED = MAX_VISITED;
|
|
137
|
+
exports.findScroller = findScroller;
|
|
138
|
+
exports.resolveScroller = resolveScroller;
|
|
@@ -8,6 +8,9 @@ exports.parseSettings = parseSettings;
|
|
|
8
8
|
exports.parseMode = parseMode;
|
|
9
9
|
exports.parseUiState = parseUiState;
|
|
10
10
|
exports.parseScrollPos = parseScrollPos;
|
|
11
|
+
exports.parseScrollRequest = parseScrollRequest;
|
|
12
|
+
exports.parseScrollReport = parseScrollReport;
|
|
13
|
+
const types_1 = require("./types");
|
|
11
14
|
/**
|
|
12
15
|
* Parsers for everything the renderer sends main over IPC. Each returns a
|
|
13
16
|
* fresh, fully-typed value or `null`; nothing from the wire is passed through
|
|
@@ -17,6 +20,8 @@ exports.parseScrollPos = parseScrollPos;
|
|
|
17
20
|
*/
|
|
18
21
|
/** Largest coordinate or size a pane rect may carry; far beyond any real window. */
|
|
19
22
|
exports.MAX_RECT = 16384;
|
|
23
|
+
/** Most warnings a pane's scroll reply may carry; the preload sends at most one. */
|
|
24
|
+
const MAX_SCROLL_WARNINGS = 4;
|
|
20
25
|
const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
21
26
|
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
22
27
|
function parseRect(raw) {
|
|
@@ -156,3 +161,48 @@ function parseScrollPos(raw) {
|
|
|
156
161
|
return null;
|
|
157
162
|
return { x, y };
|
|
158
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* A `scroll` command payload: an offset plus the optional `scrollSelector`
|
|
166
|
+
* escape hatch. The selector is only ever handed to `document.querySelector`
|
|
167
|
+
* in the preload's isolated world — never evaluated — but it is still bounded
|
|
168
|
+
* and type-checked here so a malformed one is refused with an explanation
|
|
169
|
+
* rather than silently ignored by the page. Returns the parsed request, or the
|
|
170
|
+
* error message.
|
|
171
|
+
*/
|
|
172
|
+
function parseScrollRequest(raw) {
|
|
173
|
+
const pos = parseScrollPos(raw);
|
|
174
|
+
if (!pos)
|
|
175
|
+
return 'scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets';
|
|
176
|
+
const selector = raw.scrollSelector;
|
|
177
|
+
if (selector === undefined || selector === null)
|
|
178
|
+
return pos;
|
|
179
|
+
if (typeof selector !== 'string')
|
|
180
|
+
return 'scrollSelector must be a CSS selector string';
|
|
181
|
+
const trimmed = selector.trim();
|
|
182
|
+
if (trimmed === '')
|
|
183
|
+
return 'scrollSelector must not be empty';
|
|
184
|
+
if (trimmed.length > types_1.MAX_SCROLL_SELECTOR)
|
|
185
|
+
return `scrollSelector must be at most ${types_1.MAX_SCROLL_SELECTOR} characters`;
|
|
186
|
+
return { ...pos, selector: trimmed };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A pane's `IPC.scrollResult` reply. Sent by the sync preload, which runs
|
|
190
|
+
* beside a third-party page, so it is parsed exactly like any renderer
|
|
191
|
+
* message; anything malformed is dropped and the caller times out rather than
|
|
192
|
+
* reporting an offset it cannot trust.
|
|
193
|
+
*/
|
|
194
|
+
function parseScrollReport(raw) {
|
|
195
|
+
if (!isRecord(raw))
|
|
196
|
+
return null;
|
|
197
|
+
const { id, x, y, scroller } = raw;
|
|
198
|
+
if (!isFiniteNumber(id))
|
|
199
|
+
return null;
|
|
200
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y))
|
|
201
|
+
return null;
|
|
202
|
+
if (scroller !== 'root' && scroller !== 'element')
|
|
203
|
+
return null;
|
|
204
|
+
const warnings = Array.isArray(raw.warnings)
|
|
205
|
+
? raw.warnings.filter((w) => typeof w === 'string').slice(0, MAX_SCROLL_WARNINGS)
|
|
206
|
+
: [];
|
|
207
|
+
return { id, x, y, scroller, warnings };
|
|
208
|
+
}
|
package/out/shared/types.js
CHANGED