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 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. With no app running, everything falls back to the headless
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); nested scroll containers aren't mirrored.
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-DkXWE0ha.js");
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-DkXWE0ha.js");
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 pos = parseScrollPos(payload);
378
- if (!pos) return reply(400, { error: "scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets" });
379
- this.deps.scroll(pos);
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: (pos) => {
657
- for (const wc of [native.webContents, target.webContents]) {
658
- if (!wc.isDestroyed()) wc.send(IPC.applyScroll, pos);
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.x === 0 && dirty.y === 0 && dirty.width === full.width && dirty.height === full.height;
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. Live: the app confirmed the navigation before the capture.'),
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({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
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
- if (input.scroll !== undefined)
552
- await (0, control_2.controlCall)(live.info, 'scroll', input.scroll, LIVE_APPLY_TIMEOUT_MS);
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(status, null, 2) }],
579
- structuredContent: { ...status },
634
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
635
+ structuredContent: structured,
580
636
  };
581
637
  }
582
638
  catch (e) {
@@ -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
- electron.ipcRenderer.on(APPLY_SCROLL, (_e, pos) => {
37
- lastApplied = pos;
38
- if (window.scrollX === pos.x && window.scrollY === pos.y) return;
39
- suppressUntil = performance.now() + SUPPRESS_MS;
40
- window.scrollTo(pos.x, pos.y);
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
+ }
@@ -1,2 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_SCROLL_SELECTOR = void 0;
4
+ /** Longest `scrollSelector` accepted; a CSS selector far beyond any real one. */
5
+ exports.MAX_SCROLL_SELECTOR = 512;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {