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 +34 -4
- package/out/cli/args.js +5 -1
- package/out/main/cli.js +51 -4
- package/out/main/index.js +249 -53
- package/out/main/{targetSource-DkXWE0ha.js → targetSource-BKW32VA5.js} +15 -5
- package/out/mcp/lib.js +8 -4
- package/out/mcp/server.js +173 -12
- package/out/preload/sync.js +102 -5
- package/out/renderer/assets/{index-FtoKShe_.css → index-CHF-G97L.css} +21 -2
- package/out/renderer/assets/{index-P496vEhv.js → index-DyCD4ih_.js} +123 -38
- package/out/renderer/index.html +2 -2
- package/out/shared/api.js +2 -0
- package/out/shared/control.js +74 -1
- package/out/shared/ipcPayloads.js +208 -0
- package/out/shared/types.js +3 -0
- package/package.json +3 -2
- package/skills/obsrv-screens/SKILL.md +3 -2
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).
|
|
87
|
-
|
|
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
|
-
|
|
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)
|
|
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-
|
|
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,51 +69,13 @@ function attachFrameBus(target, win) {
|
|
|
68
69
|
}
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
534
|
+
goBack();
|
|
410
535
|
});
|
|
411
536
|
electron.ipcMain.on(IPC.forward, (e) => {
|
|
412
537
|
if (!fromRenderer(e)) return;
|
|
413
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|