getobsrv 0.4.1 → 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
@@ -83,8 +83,19 @@ raster density — the PNG comes back as an inline image up to 1.5 MiB),
83
83
  If the desktop app is open with the toolbar's **Agent control** toggle on,
84
84
  `obsrv_snap` drives the *visible* window instead: you watch the URL load and
85
85
  the preset flip, and the agent gets back a capture of the app exactly as you
86
- see it (plus `obsrv_drive` to flip URL/preset/profile directly). With no app
87
- running, everything falls back to the headless render automatically.
86
+ see it (plus `obsrv_drive` to flip URL/preset/profile directly). Agents can
87
+ also scroll, click, pan and highlight while you watch — a drive session works
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
92
+ render automatically.
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.
88
99
 
89
100
  Build first, then register:
90
101
 
@@ -119,7 +130,16 @@ xattr -cr /Applications/Obsrv.app
119
130
 
120
131
  ## Distribution
121
132
 
122
- Obsrv will publish to npm as **`getobsrv`** (the installed commands remain `obsrv`
133
+ Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
134
+ package.json before lifecycle hooks run, which silently skips the prepack
135
+ electron dev→prod dependency swap (this shipped a broken 0.4.0). The flow is:
136
+
137
+ ```bash
138
+ npm run release:pack
139
+ npm publish ./getobsrv-<version>.tgz
140
+ ```
141
+
142
+ Obsrv publishes to npm as **`getobsrv`** (the installed commands remain `obsrv`
123
143
  and `obsrv-mcp`; the app's display name remains Obsrv). The bare `obsrv` npm name
124
144
  belongs to an unrelated package.
125
145
 
@@ -129,6 +149,16 @@ belongs to an unrelated package.
129
149
  looks different again; a Windows build would show Windows truth natively.
130
150
  - Panel simulation is an approximation, not colourimetric.
131
151
  - Non-ASCII text input does not type into the target pane (Electron `sendInputEvent`
132
- 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.
133
163
  - Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
134
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,51 +69,13 @@ function attachFrameBus(target, win) {
68
69
  }
69
70
  };
70
71
  }
71
- const CONTROL_FILE_NAME = "control.json";
72
- const CONTROL_TOKEN_BYTES = 32;
73
- const CONTROL_COMMANDS = [
74
- "status",
75
- "navigate",
76
- "setPreset",
77
- "setProfile",
78
- "setViewMode",
79
- "captureVisible"
80
- ];
81
- function isControlCommand(v) {
82
- return typeof v === "string" && CONTROL_COMMANDS.includes(v);
83
- }
84
- function tokenEqual(expected, provided) {
85
- if (typeof provided !== "string") return false;
86
- const a = node_crypto.createHash("sha256").update(expected).digest();
87
- const b = node_crypto.createHash("sha256").update(provided).digest();
88
- return node_crypto.timingSafeEqual(a, b);
89
- }
90
- const idList = (ids) => ids.join(", ");
91
- function presetApplyError(id) {
92
- if (typeof id !== "string") return "setPreset payload must be { id: string }";
93
- if (id === "custom") {
94
- return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
95
- }
96
- if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
97
- return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
98
- }
99
- return null;
100
- }
101
- function profileApplyError(id) {
102
- if (typeof id !== "string") return "setProfile payload must be { id: string }";
103
- if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
104
- return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
105
- }
106
- return null;
107
- }
108
- function viewModeApplyError(v) {
109
- return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
110
- }
72
+ const MAX_SCROLL_SELECTOR = 512;
111
73
  const MAX_RECT = 16384;
74
+ const MAX_SCROLL_WARNINGS = 4;
112
75
  const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
113
- const isRecord = (v) => typeof v === "object" && v !== null;
76
+ const isRecord$1 = (v) => typeof v === "object" && v !== null;
114
77
  function parseRect(raw) {
115
- if (!isRecord(raw)) return null;
78
+ if (!isRecord$1(raw)) return null;
116
79
  const { x, y, width, height } = raw;
117
80
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
118
81
  const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
@@ -134,7 +97,7 @@ function parseModifiers(raw) {
134
97
  return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
135
98
  }
136
99
  function parseInputEvent(raw) {
137
- if (!isRecord(raw)) return null;
100
+ if (!isRecord$1(raw)) return null;
138
101
  const modifiers = parseModifiers(raw.modifiers);
139
102
  switch (raw.type) {
140
103
  case "mouseDown":
@@ -167,7 +130,7 @@ function parseDeviceScaleFactor(raw) {
167
130
  return raw;
168
131
  }
169
132
  function parseSettings(raw) {
170
- if (!isRecord(raw)) return null;
133
+ if (!isRecord$1(raw)) return null;
171
134
  const { hostDiagonalInches, hostNits } = raw;
172
135
  if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
173
136
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
@@ -180,20 +143,124 @@ function parseMode(raw) {
180
143
  }
181
144
  const MAX_UI_ID = 64;
182
145
  function parseUiState(raw) {
183
- if (!isRecord(raw)) return null;
146
+ if (!isRecord$1(raw)) return null;
184
147
  const { presetId, profileId, viewMode, mode } = raw;
185
148
  if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
186
149
  if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
187
150
  if (viewMode !== "1:1" && viewMode !== "fit") return null;
188
151
  if (mode !== "url" && mode !== "image") return null;
189
- return { presetId, profileId, viewMode, mode };
152
+ return { presetId, profileId, viewMode, mode, targetBounds: parseRect(raw.targetBounds) };
190
153
  }
191
154
  function parseScrollPos(raw) {
192
- if (!isRecord(raw)) return null;
155
+ if (!isRecord$1(raw)) return null;
193
156
  const { x, y } = raw;
194
157
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
195
158
  return { x, y };
196
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
+ }
180
+ const CONTROL_FILE_NAME = "control.json";
181
+ const CONTROL_TOKEN_BYTES = 32;
182
+ const CONTROL_COMMANDS = [
183
+ "status",
184
+ "navigate",
185
+ "setPreset",
186
+ "setProfile",
187
+ "setViewMode",
188
+ "captureVisible",
189
+ // v0.5 drive controls (spec §14 "Drive controls").
190
+ "scroll",
191
+ "panTo",
192
+ "click",
193
+ "highlight",
194
+ "back",
195
+ "forward",
196
+ "reload",
197
+ "setPixelExact",
198
+ "captureTarget",
199
+ "focusWindow"
200
+ ];
201
+ function isControlCommand(v) {
202
+ return typeof v === "string" && CONTROL_COMMANDS.includes(v);
203
+ }
204
+ const isRecord = (v) => typeof v === "object" && v !== null;
205
+ function tokenEqual(expected, provided) {
206
+ if (typeof provided !== "string") return false;
207
+ const a = node_crypto.createHash("sha256").update(expected).digest();
208
+ const b = node_crypto.createHash("sha256").update(provided).digest();
209
+ return node_crypto.timingSafeEqual(a, b);
210
+ }
211
+ const idList = (ids) => ids.join(", ");
212
+ function presetApplyError(id) {
213
+ if (typeof id !== "string") return "setPreset payload must be { id: string }";
214
+ if (id === "custom") {
215
+ return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
216
+ }
217
+ if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
218
+ return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
219
+ }
220
+ return null;
221
+ }
222
+ function profileApplyError(id) {
223
+ if (typeof id !== "string") return "setProfile payload must be { id: string }";
224
+ if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
225
+ return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
226
+ }
227
+ return null;
228
+ }
229
+ function viewModeApplyError(v) {
230
+ return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
231
+ }
232
+ function pixelExactApplyError(v) {
233
+ return typeof v === "boolean" ? null : "setPixelExact payload must be { on: boolean }";
234
+ }
235
+ function parseClick(raw, viewport) {
236
+ const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
237
+ if (!isRecord(raw)) return shape;
238
+ const { x, y } = raw;
239
+ if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
240
+ if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
241
+ return `click (${x}, ${y}) is outside the current CSS viewport ${viewport.width}x${viewport.height}`;
242
+ }
243
+ const button = raw.button ?? "left";
244
+ if (button !== "left" && button !== "middle" && button !== "right") {
245
+ return "click button must be left, middle or right";
246
+ }
247
+ return { x, y, button };
248
+ }
249
+ const HIGHLIGHT_DURATION_DEFAULT_MS = 2e3;
250
+ const HIGHLIGHT_DURATION_MIN_MS = 250;
251
+ const HIGHLIGHT_DURATION_MAX_MS = 1e4;
252
+ function parseHighlight(raw) {
253
+ const rect = parseRect(raw);
254
+ if (!rect) return "highlight payload must be { x, y, width, height, durationMs? } with finite, non-negative target-pixel bounds";
255
+ if (rect.width < 1 || rect.height < 1) return "highlight rect must be at least 1x1 target pixels";
256
+ const d = raw.durationMs;
257
+ if (d === void 0) return { ...rect, durationMs: HIGHLIGHT_DURATION_DEFAULT_MS };
258
+ if (typeof d !== "number" || !Number.isFinite(d)) return "highlight durationMs must be a finite number of milliseconds";
259
+ return {
260
+ ...rect,
261
+ durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
262
+ };
263
+ }
197
264
  const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
198
265
  function loadSettings(file) {
199
266
  try {
@@ -325,6 +392,58 @@ class ControlServer {
325
392
  const capture = await this.deps.captureVisible();
326
393
  return reply(200, { ok: true, ...capture });
327
394
  }
395
+ case "captureTarget": {
396
+ const capture = await this.deps.captureTarget();
397
+ return reply(200, { ok: true, ...capture });
398
+ }
399
+ case "scroll": {
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
+ });
410
+ }
411
+ case "panTo": {
412
+ const pos = parseScrollPos(payload);
413
+ if (!pos) return reply(400, { error: "panTo payload must be { x, y } with finite, non-negative target-pixel coordinates" });
414
+ this.deps.apply({ panTo: pos });
415
+ return reply(200, { ok: true });
416
+ }
417
+ case "click": {
418
+ const click = parseClick(payload, this.deps.viewport());
419
+ if (typeof click === "string") return reply(400, { error: click });
420
+ this.deps.click(click);
421
+ return reply(200, { ok: true });
422
+ }
423
+ case "highlight": {
424
+ const highlight = parseHighlight(payload);
425
+ if (typeof highlight === "string") return reply(400, { error: highlight });
426
+ this.deps.apply({ highlight });
427
+ return reply(200, { ok: true });
428
+ }
429
+ case "back":
430
+ this.deps.back();
431
+ return reply(200, { ok: true });
432
+ case "forward":
433
+ this.deps.forward();
434
+ return reply(200, { ok: true });
435
+ case "reload":
436
+ this.deps.reload();
437
+ return reply(200, { ok: true });
438
+ case "setPixelExact": {
439
+ const err = pixelExactApplyError(payload.on);
440
+ if (err) return reply(400, { error: err });
441
+ this.deps.apply({ pixelExact: payload.on });
442
+ return reply(200, { ok: true });
443
+ }
444
+ case "focusWindow":
445
+ this.deps.focusWindow();
446
+ return reply(200, { ok: true });
328
447
  }
329
448
  }
330
449
  /**
@@ -365,6 +484,7 @@ class ControlServer {
365
484
  }
366
485
  const TOOLBAR_H = 44;
367
486
  const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
487
+ const SCROLL_REPLY_TIMEOUT_MS = 1e3;
368
488
  function hostInfo(win) {
369
489
  try {
370
490
  const d = electron.screen.getDisplayMatching(win.getBounds());
@@ -399,18 +519,23 @@ function registerIpc(ctx) {
399
519
  assertRenderer(e);
400
520
  return navigateBoth(url);
401
521
  });
402
- electron.ipcMain.on(IPC.reload, (e) => {
403
- if (!fromRenderer(e)) return;
522
+ const reloadBoth = () => {
404
523
  native.reload();
405
524
  target.reload();
525
+ };
526
+ const goBack = () => native.back();
527
+ const goForward = () => native.forward();
528
+ electron.ipcMain.on(IPC.reload, (e) => {
529
+ if (!fromRenderer(e)) return;
530
+ reloadBoth();
406
531
  });
407
532
  electron.ipcMain.on(IPC.back, (e) => {
408
533
  if (!fromRenderer(e)) return;
409
- native.back();
534
+ goBack();
410
535
  });
411
536
  electron.ipcMain.on(IPC.forward, (e) => {
412
537
  if (!fromRenderer(e)) return;
413
- native.forward();
538
+ goForward();
414
539
  });
415
540
  electron.ipcMain.handle(IPC.setViewport, (e, width, height, rawDsf) => {
416
541
  assertRenderer(e);
@@ -456,6 +581,37 @@ function registerIpc(ctx) {
456
581
  native.setBounds(rect);
457
582
  rendererDrivesLayout = true;
458
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
+ };
459
615
  electron.ipcMain.handle(IPC.getHostInfo, (e) => {
460
616
  assertRenderer(e);
461
617
  return hostInfo(win);
@@ -488,6 +644,7 @@ function registerIpc(ctx) {
488
644
  if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
489
645
  });
490
646
  const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
647
+ let targetBounds = null;
491
648
  const MAX_PENDING_APPLIES = 32;
492
649
  let rendererReported = false;
493
650
  let warnedPendingOverflow = false;
@@ -496,7 +653,9 @@ function registerIpc(ctx) {
496
653
  if (!fromRenderer(e)) return;
497
654
  const s = parseUiState(raw);
498
655
  if (!s) return;
499
- Object.assign(uiState, s);
656
+ const { targetBounds: bounds, ...state } = s;
657
+ Object.assign(uiState, state);
658
+ targetBounds = bounds ?? null;
500
659
  if (!rendererReported) {
501
660
  rendererReported = true;
502
661
  for (const patch of pendingApplies.splice(0)) {
@@ -542,6 +701,43 @@ function registerIpc(ctx) {
542
701
  const size = image.getSize();
543
702
  return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
544
703
  },
704
+ captureTarget: async () => {
705
+ const bounds = targetBounds;
706
+ const known = bounds !== null && bounds.width >= 1 && bounds.height >= 1;
707
+ const image = await win.webContents.capturePage(known ? bounds : void 0);
708
+ const size = image.getSize();
709
+ return {
710
+ data: image.toPNG().toString("base64"),
711
+ width: size.width,
712
+ height: size.height,
713
+ warnings: known ? [] : ["the renderer has not reported the target pane bounds yet; captured the full window instead"]
714
+ };
715
+ },
716
+ viewport: () => target.getViewport(),
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,
723
+ click: (c) => {
724
+ const down = parseInputEvent({ type: "mouseDown", x: c.x, y: c.y, button: c.button, clickCount: 1 });
725
+ const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
726
+ if (!down || !up) return;
727
+ try {
728
+ target.sendInput(down);
729
+ target.sendInput(up);
730
+ } catch {
731
+ }
732
+ },
733
+ back: goBack,
734
+ forward: goForward,
735
+ reload: reloadBoth,
736
+ focusWindow: () => {
737
+ if (win.isDestroyed()) return;
738
+ win.show();
739
+ win.focus();
740
+ },
545
741
  activity: () => {
546
742
  if (!win.isDestroyed()) win.webContents.send(IPC.agentActivity);
547
743
  }
@@ -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/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
3
+ exports.PANE_CAPTURE_HEADLESS_NOTE = exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
4
4
  exports.buildSnapArgs = buildSnapArgs;
5
5
  exports.buildDiffArgs = buildDiffArgs;
6
6
  exports.shouldInlineImage = shouldInlineImage;
@@ -105,6 +105,7 @@ Object.defineProperty(exports, "ALLOWED_URL_SCHEMES", { enumerable: true, get: f
105
105
  Object.defineProperty(exports, "urlSchemeError", { enumerable: true, get: function () { return url_1.urlSchemeError; } });
106
106
  exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desktop app and enable "Agent control" in the toolbar ' +
107
107
  '(or pass mode: "headless" to render without it).';
108
+ exports.PANE_CAPTURE_HEADLESS_NOTE = "capture: 'pane' applies to live mode only; the headless render is the page raster itself, so the option was ignored.";
108
109
  /**
109
110
  * Decides whether an `obsrv_snap` call drives the visible app or renders
110
111
  * headlessly (spec §14 "Live drive"), given whether a control-enabled app
@@ -117,16 +118,19 @@ exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desk
117
118
  * full page), with a note.
118
119
  * - `waitMs` is honoured headlessly; on the live path it is ignored with a
119
120
  * note (the live capture settles on the app's own committed navigation).
121
+ * - `capture: 'pane'` shapes the live capture only; any headless outcome
122
+ * notes that it was ignored.
120
123
  * - `mode: 'live'` with no reachable app is an error, never a silent
121
124
  * headless fallback — the caller asked to watch.
122
125
  */
123
126
  function planSnapPath(input, mode, liveReachable) {
127
+ const paneNote = input.capture === 'pane' ? [exports.PANE_CAPTURE_HEADLESS_NOTE] : [];
124
128
  if (mode === 'headless')
125
- return { path: 'headless', notes: [] };
129
+ return { path: 'headless', notes: paneNote };
126
130
  if (!liveReachable) {
127
131
  if (mode === 'live')
128
132
  return { error: exports.APP_NOT_REACHABLE };
129
- return { path: 'headless', notes: [] };
133
+ return { path: 'headless', notes: paneNote };
130
134
  }
131
135
  const notes = [];
132
136
  const custom = input.width !== undefined ||
@@ -138,7 +142,7 @@ function planSnapPath(input, mode, liveReachable) {
138
142
  if (input.fullPage)
139
143
  notes.push('fullPage is headless-only; rendered headlessly instead of driving the app.');
140
144
  if (notes.length > 0)
141
- return { path: 'headless', notes };
145
+ return { path: 'headless', notes: [...notes, ...paneNote] };
142
146
  if (input.waitMs !== undefined)
143
147
  notes.push('waitMs is headless-only and was ignored in live mode.');
144
148
  return { path: 'live', notes };