getobsrv 0.5.0 → 0.7.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 +33 -2
- package/out/cli/args.js +5 -1
- package/out/main/cli.js +64 -9
- package/out/main/index.js +223 -13
- package/out/main/{targetSource-DkXWE0ha.js → targetSource-CbJwfugi.js} +22 -6
- package/out/mcp/server.js +202 -44
- package/out/preload/app.js +10 -2
- package/out/preload/sync.js +102 -5
- package/out/renderer/assets/{index-DyCD4ih_.js → index-BP1S2N6S.js} +81 -4
- package/out/renderer/assets/{index-CHF-G97L.css → index-BeroS4wv.css} +46 -0
- package/out/renderer/index.html +2 -2
- package/out/shared/ipcPayloads.js +61 -4
- package/out/shared/presets.js +7 -1
- package/out/shared/types.js +3 -0
- package/package.json +1 -1
- package/skills/obsrv-screens/SKILL.md +10 -0
package/README.md
CHANGED
|
@@ -85,9 +85,25 @@ If the desktop app is open with the toolbar's **Agent control** toggle on,
|
|
|
85
85
|
the preset flip, and the agent gets back a capture of the app exactly as you
|
|
86
86
|
see it (plus `obsrv_drive` to flip URL/preset/profile directly). Agents can
|
|
87
87
|
also scroll, click, pan and highlight while you watch — a drive session works
|
|
88
|
-
as a guided demo.
|
|
88
|
+
as a guided demo. A `scroll` reports the offset it actually reached
|
|
89
|
+
(`scrolled` / `scroller`), finds the inner scroll container on pages whose
|
|
90
|
+
root cannot scroll, and takes a `scrollSelector` when you need to name the
|
|
91
|
+
container yourself. With no app running, everything falls back to the headless
|
|
89
92
|
render automatically.
|
|
90
93
|
|
|
94
|
+
To photograph a scrolled or panned state, pass `capture: 'window' | 'pane'` to
|
|
95
|
+
`obsrv_drive`: it captures after its commands run, and nothing in that tool
|
|
96
|
+
navigates unless you pass `url`, so the scroll survives the shutter. A live
|
|
97
|
+
`obsrv_snap` only navigates when the app is showing a *different* URL — its
|
|
98
|
+
`navigated` field says which happened — and navigating is a fresh load, which
|
|
99
|
+
starts at the top of the page.
|
|
100
|
+
|
|
101
|
+
A headless `snap` returns `settled: true` when the page went paint-quiet and
|
|
102
|
+
every pixel painted. `settled: false` is still a usable capture, not a
|
|
103
|
+
failure — a page that kept animating, or one whose repaint never completed,
|
|
104
|
+
comes back as-is (exit code 0) with a warning saying what was missing. Only a
|
|
105
|
+
render that painted nothing at all is an error.
|
|
106
|
+
|
|
91
107
|
Build first, then register:
|
|
92
108
|
|
|
93
109
|
```bash
|
|
@@ -119,6 +135,11 @@ yet notarised, so macOS falsely reports it as "damaged"):
|
|
|
119
135
|
xattr -cr /Applications/Obsrv.app
|
|
120
136
|
```
|
|
121
137
|
|
|
138
|
+
Obsrv checks GitHub for a newer release once a day and, when there is one, shows
|
|
139
|
+
it in the toolbar; clicking opens the release page. It is a single
|
|
140
|
+
unauthenticated request carrying no identifiers, and Settings → Updates turns it
|
|
141
|
+
off.
|
|
142
|
+
|
|
122
143
|
## Distribution
|
|
123
144
|
|
|
124
145
|
Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
|
|
@@ -140,6 +161,16 @@ belongs to an unrelated package.
|
|
|
140
161
|
looks different again; a Windows build would show Windows truth natively.
|
|
141
162
|
- Panel simulation is an approximation, not colourimetric.
|
|
142
163
|
- Non-ASCII text input does not type into the target pane (Electron `sendInputEvent`
|
|
143
|
-
limitation)
|
|
164
|
+
limitation).
|
|
165
|
+
- Inner-scroller *reporting* is one-way. An agent `scroll` finds the page's real scroll
|
|
166
|
+
host — the app-shell pattern (`html, body { overflow: hidden }` with an inner
|
|
167
|
+
`overflow-y: auto` container) is handled, and the result reports the offset actually
|
|
168
|
+
reached — but scrolling a nested container **by hand** in the native pane is not
|
|
169
|
+
mirrored to the target: element scroll events don't bubble to `window`, so the report
|
|
170
|
+
side never sees them. Dragging the page itself still syncs both ways.
|
|
171
|
+
- Scroll targeting stops at the light DOM of the top-level document. A scroller inside a
|
|
172
|
+
shadow root or an iframe can't be found automatically *or* named with `scrollSelector`
|
|
173
|
+
(`document.querySelector` doesn't cross either boundary), so a web-component app that
|
|
174
|
+
hides its scroller in a shadow root has no escape hatch.
|
|
144
175
|
- Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
|
|
145
176
|
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-CbJwfugi.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));
|
|
@@ -323,6 +370,7 @@ function inkRows(img) {
|
|
|
323
370
|
}
|
|
324
371
|
return rows;
|
|
325
372
|
}
|
|
373
|
+
const UNSETTLED_FINDING = "renders did not go paint-quiet within the budget (animation or video), so the two captures are different frames — the band deltas below are frame-to-frame noise, not evidence about rasterisation. Compare a static page, or pass a longer --timeout if the page merely settles late.";
|
|
326
374
|
function bandInk(img, y0, y1) {
|
|
327
375
|
let ink = 0;
|
|
328
376
|
for (let y = y0; y < y1; y++) {
|
|
@@ -334,7 +382,7 @@ function bandInk(img, y0, y1) {
|
|
|
334
382
|
return pixels === 0 ? 0 : ink / pixels;
|
|
335
383
|
}
|
|
336
384
|
const pct = (v) => `${(v * 100).toFixed(2)}%`;
|
|
337
|
-
function diffMetrics(target, reference, referenceDeviceRows) {
|
|
385
|
+
function diffMetrics(target, reference, referenceDeviceRows, settled = true) {
|
|
338
386
|
if (target.width !== reference.width || target.height !== reference.height) {
|
|
339
387
|
throw new RangeError(
|
|
340
388
|
`diffMetrics: mismatched dimensions (target ${target.width}x${target.height}, reference ${reference.width}x${reference.height})`
|
|
@@ -360,6 +408,7 @@ function diffMetrics(target, reference, referenceDeviceRows) {
|
|
|
360
408
|
}
|
|
361
409
|
}
|
|
362
410
|
return {
|
|
411
|
+
settled,
|
|
363
412
|
inkCoverage: { target: targetCoverage, reference: referenceCoverage, delta: targetCoverage - referenceCoverage },
|
|
364
413
|
rows: {
|
|
365
414
|
target: targetRows,
|
|
@@ -367,7 +416,7 @@ function diffMetrics(target, reference, referenceDeviceRows) {
|
|
|
367
416
|
ratio: referenceDeviceRows > 0 ? targetRows / referenceDeviceRows : null
|
|
368
417
|
},
|
|
369
418
|
bands,
|
|
370
|
-
findings
|
|
419
|
+
findings: settled ? findings : [UNSETTLED_FINDING]
|
|
371
420
|
};
|
|
372
421
|
}
|
|
373
422
|
function profileToParams(p, hostNits) {
|
|
@@ -540,7 +589,12 @@ async function runDiff(cmd) {
|
|
|
540
589
|
const referenceFull = bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height);
|
|
541
590
|
const referenceDeviceRows = inkRows(referenceFull);
|
|
542
591
|
const reference = boxDownsample(referenceFull, 2);
|
|
543
|
-
const
|
|
592
|
+
const settled = t.frame.settled && r.frame.settled;
|
|
593
|
+
const warnings = [
|
|
594
|
+
...t.warnings.map((w) => `target: ${w}`),
|
|
595
|
+
...r.warnings.map((w) => `reference: ${w}`)
|
|
596
|
+
];
|
|
597
|
+
const metrics = diffMetrics(target, reference, referenceDeviceRows, settled);
|
|
544
598
|
let files;
|
|
545
599
|
if (cmd.outDir) {
|
|
546
600
|
const dir = node_path.resolve(cmd.outDir);
|
|
@@ -551,14 +605,15 @@ async function runDiff(cmd) {
|
|
|
551
605
|
}
|
|
552
606
|
const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
|
|
553
607
|
human(
|
|
554
|
-
`diff ${cmd.url} @ ${cmd.spec.presetId} (profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)`
|
|
608
|
+
`diff ${cmd.url} @ ${cmd.spec.presetId} (profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
|
|
555
609
|
);
|
|
556
610
|
await machine({
|
|
557
611
|
url: cmd.url,
|
|
558
612
|
preset: cmd.spec.presetId,
|
|
559
613
|
profile: profile.id,
|
|
560
614
|
...files ? { files } : {},
|
|
561
|
-
...metrics
|
|
615
|
+
...metrics,
|
|
616
|
+
warnings
|
|
562
617
|
});
|
|
563
618
|
}
|
|
564
619
|
const userData = process.env.OBSRV_CLI_USER_DATA ?? node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "obsrv-cli-"));
|
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-CbJwfugi.js");
|
|
8
8
|
const node_http = require("node:http");
|
|
9
9
|
const node_url = require("node:url");
|
|
10
10
|
const IPC = {
|
|
@@ -28,13 +28,18 @@ 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",
|
|
34
35
|
readImageFile: "obsrv:read-image-file",
|
|
35
36
|
uiState: "obsrv:ui-state",
|
|
36
37
|
agentApply: "obsrv:agent-apply",
|
|
37
|
-
agentActivity: "obsrv:agent-activity"
|
|
38
|
+
agentActivity: "obsrv:agent-activity",
|
|
39
|
+
getUpdate: "obsrv:get-update",
|
|
40
|
+
checkUpdate: "obsrv:check-update",
|
|
41
|
+
openRelease: "obsrv:open-release",
|
|
42
|
+
updateStatus: "obsrv:update-status"
|
|
38
43
|
};
|
|
39
44
|
function attachFrameBus(target, win) {
|
|
40
45
|
let ready = false;
|
|
@@ -68,7 +73,9 @@ function attachFrameBus(target, win) {
|
|
|
68
73
|
}
|
|
69
74
|
};
|
|
70
75
|
}
|
|
76
|
+
const MAX_SCROLL_SELECTOR = 512;
|
|
71
77
|
const MAX_RECT = 16384;
|
|
78
|
+
const MAX_SCROLL_WARNINGS = 4;
|
|
72
79
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
73
80
|
const isRecord$1 = (v) => typeof v === "object" && v !== null;
|
|
74
81
|
function parseRect(raw) {
|
|
@@ -133,7 +140,11 @@ function parseSettings(raw) {
|
|
|
133
140
|
if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
|
|
134
141
|
const agentControl = raw.agentControl ?? false;
|
|
135
142
|
if (typeof agentControl !== "boolean") return null;
|
|
136
|
-
|
|
143
|
+
const updateCheck = raw.updateCheck ?? true;
|
|
144
|
+
if (typeof updateCheck !== "boolean") return null;
|
|
145
|
+
const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
|
|
146
|
+
if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0) return null;
|
|
147
|
+
return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
|
|
137
148
|
}
|
|
138
149
|
function parseMode(raw) {
|
|
139
150
|
return raw === "url" || raw === "image" ? raw : null;
|
|
@@ -154,6 +165,26 @@ function parseScrollPos(raw) {
|
|
|
154
165
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
|
|
155
166
|
return { x, y };
|
|
156
167
|
}
|
|
168
|
+
function parseScrollRequest(raw) {
|
|
169
|
+
const pos = parseScrollPos(raw);
|
|
170
|
+
if (!pos) return "scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets";
|
|
171
|
+
const selector = raw.scrollSelector;
|
|
172
|
+
if (selector === void 0 || selector === null) return pos;
|
|
173
|
+
if (typeof selector !== "string") return "scrollSelector must be a CSS selector string";
|
|
174
|
+
const trimmed = selector.trim();
|
|
175
|
+
if (trimmed === "") return "scrollSelector must not be empty";
|
|
176
|
+
if (trimmed.length > MAX_SCROLL_SELECTOR) return `scrollSelector must be at most ${MAX_SCROLL_SELECTOR} characters`;
|
|
177
|
+
return { ...pos, selector: trimmed };
|
|
178
|
+
}
|
|
179
|
+
function parseScrollReport(raw) {
|
|
180
|
+
if (!isRecord$1(raw)) return null;
|
|
181
|
+
const { id, x, y, scroller } = raw;
|
|
182
|
+
if (!isFiniteNumber(id)) return null;
|
|
183
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
|
|
184
|
+
if (scroller !== "root" && scroller !== "element") return null;
|
|
185
|
+
const warnings = Array.isArray(raw.warnings) ? raw.warnings.filter((w) => typeof w === "string").slice(0, MAX_SCROLL_WARNINGS) : [];
|
|
186
|
+
return { id, x, y, scroller, warnings };
|
|
187
|
+
}
|
|
157
188
|
const CONTROL_FILE_NAME = "control.json";
|
|
158
189
|
const CONTROL_TOKEN_BYTES = 32;
|
|
159
190
|
const CONTROL_COMMANDS = [
|
|
@@ -239,6 +270,7 @@ function parseHighlight(raw) {
|
|
|
239
270
|
};
|
|
240
271
|
}
|
|
241
272
|
const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
273
|
+
const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
242
274
|
function loadSettings(file) {
|
|
243
275
|
try {
|
|
244
276
|
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
@@ -247,7 +279,11 @@ function loadSettings(file) {
|
|
|
247
279
|
hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits,
|
|
248
280
|
// Anything but a literal true (older files have no key at all) means off:
|
|
249
281
|
// a network-facing capability must never be enabled by a malformed file.
|
|
250
|
-
agentControl: raw.agentControl === true
|
|
282
|
+
agentControl: raw.agentControl === true,
|
|
283
|
+
// The opposite default: only a literal false turns the update check off,
|
|
284
|
+
// so a file from before this feature keeps it on.
|
|
285
|
+
updateCheck: raw.updateCheck !== false,
|
|
286
|
+
lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
|
|
251
287
|
};
|
|
252
288
|
} catch {
|
|
253
289
|
return { ...targetSource.DEFAULT_SETTINGS };
|
|
@@ -256,9 +292,67 @@ function loadSettings(file) {
|
|
|
256
292
|
function saveSettings(file, s) {
|
|
257
293
|
if (!isPositive(s.hostDiagonalInches) || !isPositive(s.hostNits)) throw new RangeError("settings values must be finite and > 0");
|
|
258
294
|
if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
|
|
295
|
+
if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
|
|
296
|
+
if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
|
|
259
297
|
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
260
298
|
node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
261
299
|
}
|
|
300
|
+
const RELEASES_API = "https://api.github.com/repos/vibesyemmy/obsrv/releases/latest";
|
|
301
|
+
const RELEASE_URL_PREFIX = "https://github.com/vibesyemmy/obsrv/releases/";
|
|
302
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
303
|
+
const CHECK_TIMEOUT_MS = 5e3;
|
|
304
|
+
function parseVersion(raw) {
|
|
305
|
+
const trimmed = raw.trim().replace(/^v/i, "");
|
|
306
|
+
if (trimmed === "") return null;
|
|
307
|
+
const [core = "", ...rest] = trimmed.split("-");
|
|
308
|
+
const parts = core.split(".").map(Number);
|
|
309
|
+
if (parts.length === 0 || parts.some((n) => !Number.isInteger(n) || n < 0)) return null;
|
|
310
|
+
return { parts, pre: rest.length > 0 };
|
|
311
|
+
}
|
|
312
|
+
function isNewer(latest, current) {
|
|
313
|
+
const a = parseVersion(latest);
|
|
314
|
+
const b = parseVersion(current);
|
|
315
|
+
if (!a || !b) return false;
|
|
316
|
+
const len = Math.max(a.parts.length, b.parts.length);
|
|
317
|
+
for (let i = 0; i < len; i++) {
|
|
318
|
+
const x = a.parts[i] ?? 0;
|
|
319
|
+
const y = b.parts[i] ?? 0;
|
|
320
|
+
if (x !== y) return x > y;
|
|
321
|
+
}
|
|
322
|
+
return !a.pre && b.pre;
|
|
323
|
+
}
|
|
324
|
+
function isReleaseUrl(url) {
|
|
325
|
+
let parsed;
|
|
326
|
+
try {
|
|
327
|
+
parsed = new URL(url);
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
const allowed = new URL(RELEASE_URL_PREFIX);
|
|
332
|
+
return parsed.protocol === allowed.protocol && parsed.host === allowed.host && parsed.pathname.startsWith(allowed.pathname);
|
|
333
|
+
}
|
|
334
|
+
function readRelease(body, current, now) {
|
|
335
|
+
let raw;
|
|
336
|
+
try {
|
|
337
|
+
raw = JSON.parse(body);
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
342
|
+
const { tag_name: tag, html_url: url } = raw;
|
|
343
|
+
if (typeof tag !== "string" || parseVersion(tag) === null) return null;
|
|
344
|
+
if (!isNewer(tag, current)) return { state: { status: "current", current, checkedAt: now }, url: "" };
|
|
345
|
+
if (typeof url !== "string" || !isReleaseUrl(url)) return null;
|
|
346
|
+
return {
|
|
347
|
+
state: { status: "available", current, latest: tag.trim().replace(/^v/i, ""), checkedAt: now },
|
|
348
|
+
url
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function isCheckDue(lastUpdateCheck, now) {
|
|
352
|
+
if (!Number.isFinite(lastUpdateCheck) || lastUpdateCheck <= 0) return true;
|
|
353
|
+
if (lastUpdateCheck > now) return true;
|
|
354
|
+
return now - lastUpdateCheck >= CHECK_INTERVAL_MS;
|
|
355
|
+
}
|
|
262
356
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
263
357
|
const APPLY_WAIT_MS = 2e3;
|
|
264
358
|
const APPLY_POLL_MS = 25;
|
|
@@ -374,10 +468,16 @@ class ControlServer {
|
|
|
374
468
|
return reply(200, { ok: true, ...capture });
|
|
375
469
|
}
|
|
376
470
|
case "scroll": {
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
379
|
-
this.deps.scroll(
|
|
380
|
-
return reply(200, { ok: true });
|
|
471
|
+
const req2 = parseScrollRequest(payload);
|
|
472
|
+
if (typeof req2 === "string") return reply(400, { error: req2 });
|
|
473
|
+
const result = await this.deps.scroll(req2);
|
|
474
|
+
if (!result) return reply(200, { ok: true, scrolled: null, warnings: ["scroll offset could not be confirmed"] });
|
|
475
|
+
return reply(200, {
|
|
476
|
+
ok: true,
|
|
477
|
+
scrolled: { x: result.x, y: result.y },
|
|
478
|
+
scroller: result.scroller,
|
|
479
|
+
...result.warnings.length > 0 ? { warnings: result.warnings } : {}
|
|
480
|
+
});
|
|
381
481
|
}
|
|
382
482
|
case "panTo": {
|
|
383
483
|
const pos = parseScrollPos(payload);
|
|
@@ -453,8 +553,52 @@ class ControlServer {
|
|
|
453
553
|
});
|
|
454
554
|
}
|
|
455
555
|
}
|
|
556
|
+
async function checkForUpdate(current, now) {
|
|
557
|
+
const failed = { state: { status: "error", current, checkedAt: now }, url: "" };
|
|
558
|
+
const endpoint = process.env.OBSRV_RELEASES_API ?? RELEASES_API;
|
|
559
|
+
return new Promise((resolve) => {
|
|
560
|
+
let settled = false;
|
|
561
|
+
const done = (v) => {
|
|
562
|
+
if (settled) return;
|
|
563
|
+
settled = true;
|
|
564
|
+
clearTimeout(timer);
|
|
565
|
+
resolve(v);
|
|
566
|
+
};
|
|
567
|
+
let request;
|
|
568
|
+
try {
|
|
569
|
+
request = electron.net.request({ method: "GET", url: endpoint });
|
|
570
|
+
} catch {
|
|
571
|
+
resolve(failed);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const timer = setTimeout(() => {
|
|
575
|
+
request.abort();
|
|
576
|
+
done(failed);
|
|
577
|
+
}, CHECK_TIMEOUT_MS);
|
|
578
|
+
request.setHeader("accept", "application/vnd.github+json");
|
|
579
|
+
request.setHeader("user-agent", "obsrv-update-check");
|
|
580
|
+
request.on("response", (response) => {
|
|
581
|
+
if (response.statusCode !== 200) {
|
|
582
|
+
response.on("data", () => void 0);
|
|
583
|
+
response.on("end", () => done(failed));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const chunks = [];
|
|
587
|
+
response.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
588
|
+
response.on("end", () => {
|
|
589
|
+
const parsed = readRelease(Buffer.concat(chunks).toString("utf8"), current, now);
|
|
590
|
+
done(parsed ?? failed);
|
|
591
|
+
});
|
|
592
|
+
response.on("error", () => done(failed));
|
|
593
|
+
});
|
|
594
|
+
request.on("error", () => done(failed));
|
|
595
|
+
request.on("abort", () => done(failed));
|
|
596
|
+
request.end();
|
|
597
|
+
});
|
|
598
|
+
}
|
|
456
599
|
const TOOLBAR_H = 44;
|
|
457
600
|
const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
|
|
601
|
+
const SCROLL_REPLY_TIMEOUT_MS = 1e3;
|
|
458
602
|
function hostInfo(win) {
|
|
459
603
|
try {
|
|
460
604
|
const d = electron.screen.getDisplayMatching(win.getBounds());
|
|
@@ -551,6 +695,37 @@ function registerIpc(ctx) {
|
|
|
551
695
|
native.setBounds(rect);
|
|
552
696
|
rendererDrivesLayout = true;
|
|
553
697
|
});
|
|
698
|
+
let scrollSeq = 0;
|
|
699
|
+
const scrollWaiters = /* @__PURE__ */ new Map();
|
|
700
|
+
electron.ipcMain.on(IPC.scrollResult, (e, raw) => {
|
|
701
|
+
if (e.sender !== target.webContents && e.sender !== native.webContents) return;
|
|
702
|
+
const report = parseScrollReport(raw);
|
|
703
|
+
if (!report) return;
|
|
704
|
+
const waiter = scrollWaiters.get(report.id);
|
|
705
|
+
if (!waiter) return;
|
|
706
|
+
scrollWaiters.delete(report.id);
|
|
707
|
+
waiter(report);
|
|
708
|
+
});
|
|
709
|
+
const scrollBoth = async (req) => {
|
|
710
|
+
const base = { x: req.x, y: req.y };
|
|
711
|
+
if (req.selector !== void 0) base.selector = req.selector;
|
|
712
|
+
if (!native.webContents.isDestroyed()) native.webContents.send(IPC.applyScroll, base);
|
|
713
|
+
const wc = target.webContents;
|
|
714
|
+
if (wc.isDestroyed()) return null;
|
|
715
|
+
const id = ++scrollSeq;
|
|
716
|
+
const answered = new Promise((resolve) => {
|
|
717
|
+
const timer = setTimeout(() => {
|
|
718
|
+
scrollWaiters.delete(id);
|
|
719
|
+
resolve(null);
|
|
720
|
+
}, SCROLL_REPLY_TIMEOUT_MS);
|
|
721
|
+
scrollWaiters.set(id, (report) => {
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
resolve(report);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
wc.send(IPC.applyScroll, { ...base, id });
|
|
727
|
+
return answered;
|
|
728
|
+
};
|
|
554
729
|
electron.ipcMain.handle(IPC.getHostInfo, (e) => {
|
|
555
730
|
assertRenderer(e);
|
|
556
731
|
return hostInfo(win);
|
|
@@ -610,6 +785,40 @@ function registerIpc(ctx) {
|
|
|
610
785
|
return electron.app.getVersion();
|
|
611
786
|
}
|
|
612
787
|
})();
|
|
788
|
+
let update = { status: "current", current: appVersion, checkedAt: 0 };
|
|
789
|
+
let releaseUrl = "";
|
|
790
|
+
const runUpdateCheck = async () => {
|
|
791
|
+
const now = Date.now();
|
|
792
|
+
const { state, url } = await checkForUpdate(appVersion, now);
|
|
793
|
+
update = state;
|
|
794
|
+
releaseUrl = state.status === "available" && isReleaseUrl(url) ? url : "";
|
|
795
|
+
const next = { ...settings, lastUpdateCheck: now };
|
|
796
|
+
try {
|
|
797
|
+
saveSettings(settingsFile, next);
|
|
798
|
+
settings = next;
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
if (!win.isDestroyed()) win.webContents.send(IPC.updateStatus, state);
|
|
802
|
+
return state;
|
|
803
|
+
};
|
|
804
|
+
electron.ipcMain.handle(IPC.getUpdate, (e) => {
|
|
805
|
+
assertRenderer(e);
|
|
806
|
+
return update;
|
|
807
|
+
});
|
|
808
|
+
electron.ipcMain.handle(IPC.checkUpdate, (e) => {
|
|
809
|
+
assertRenderer(e);
|
|
810
|
+
return runUpdateCheck();
|
|
811
|
+
});
|
|
812
|
+
electron.ipcMain.handle(IPC.openRelease, async (e) => {
|
|
813
|
+
assertRenderer(e);
|
|
814
|
+
if (releaseUrl === "") return false;
|
|
815
|
+
await electron.shell.openExternal(releaseUrl);
|
|
816
|
+
return true;
|
|
817
|
+
});
|
|
818
|
+
const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
|
|
819
|
+
if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
|
|
820
|
+
void runUpdateCheck();
|
|
821
|
+
}
|
|
613
822
|
const control = new ControlServer(node_path.join(electron.app.getPath("userData"), CONTROL_FILE_NAME), {
|
|
614
823
|
status: () => {
|
|
615
824
|
let url = "";
|
|
@@ -653,11 +862,12 @@ function registerIpc(ctx) {
|
|
|
653
862
|
};
|
|
654
863
|
},
|
|
655
864
|
viewport: () => target.getViewport(),
|
|
656
|
-
scroll
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
865
|
+
// An agent scroll drives both panes over the same `applyScroll` channel
|
|
866
|
+
// the pane-sync mirror uses — each pane's sync preload applies it and
|
|
867
|
+
// suppresses its own echo, so the two arrive together with no loop.
|
|
868
|
+
// Relying on the mirror instead would be silent: an applied scroll is
|
|
869
|
+
// deliberately not re-reported (see preload/sync.ts).
|
|
870
|
+
scroll: scrollBoth,
|
|
661
871
|
click: (c) => {
|
|
662
872
|
const down = parseInputEvent({ type: "mouseDown", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
663
873
|
const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
@@ -3,7 +3,13 @@ const electron = require("electron");
|
|
|
3
3
|
const node_events = require("node:events");
|
|
4
4
|
const node_path = require("node:path");
|
|
5
5
|
const MAX_VIEWPORT = 4096;
|
|
6
|
-
const DEFAULT_SETTINGS = {
|
|
6
|
+
const DEFAULT_SETTINGS = {
|
|
7
|
+
hostDiagonalInches: 27,
|
|
8
|
+
hostNits: 500,
|
|
9
|
+
agentControl: false,
|
|
10
|
+
updateCheck: true,
|
|
11
|
+
lastUpdateCheck: 0
|
|
12
|
+
};
|
|
7
13
|
const SCREEN_PRESETS = [
|
|
8
14
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
9
15
|
{ id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
|
|
@@ -59,6 +65,15 @@ function classifyFileNavigation(from, to) {
|
|
|
59
65
|
if (IMAGE_EXTENSIONS.test(path)) return "image";
|
|
60
66
|
return from.startsWith("file:") ? "allow" : "block";
|
|
61
67
|
}
|
|
68
|
+
function isFullFrame(dirty, frameWidth, frameHeight, deviceScaleFactor) {
|
|
69
|
+
if (dirty.x !== 0 || dirty.y !== 0) return false;
|
|
70
|
+
if (dirty.width === frameWidth && dirty.height === frameHeight) return true;
|
|
71
|
+
if (!(deviceScaleFactor > 1)) return false;
|
|
72
|
+
return dirty.width === Math.round(frameWidth / deviceScaleFactor) && dirty.height === Math.round(frameHeight / deviceScaleFactor);
|
|
73
|
+
}
|
|
74
|
+
function fitsFrame(dirty, frameWidth, frameHeight) {
|
|
75
|
+
return dirty.x >= 0 && dirty.y >= 0 && dirty.width > 0 && dirty.height > 0 && dirty.x + dirty.width <= frameWidth && dirty.y + dirty.height <= frameHeight;
|
|
76
|
+
}
|
|
62
77
|
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
63
78
|
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i;
|
|
64
79
|
function normalizeUrl(input) {
|
|
@@ -159,13 +174,14 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
159
174
|
if (dirty.width <= 0 || dirty.height <= 0) return;
|
|
160
175
|
if (image.isEmpty()) return;
|
|
161
176
|
const full = image.getSize();
|
|
162
|
-
const isFull = dirty
|
|
177
|
+
const isFull = isFullFrame(dirty, full.width, full.height, this.dsf);
|
|
178
|
+
if (!isFull && !fitsFrame(dirty, full.width, full.height)) return;
|
|
163
179
|
this.emit("frame", {
|
|
164
180
|
frame: {
|
|
165
|
-
x: dirty.x,
|
|
166
|
-
y: dirty.y,
|
|
167
|
-
width: dirty.width,
|
|
168
|
-
height: dirty.height,
|
|
181
|
+
x: isFull ? 0 : dirty.x,
|
|
182
|
+
y: isFull ? 0 : dirty.y,
|
|
183
|
+
width: isFull ? full.width : dirty.width,
|
|
184
|
+
height: isFull ? full.height : dirty.height,
|
|
169
185
|
// `toBitmap()` already returns a fresh copy of the pixels (unlike the
|
|
170
186
|
// deprecated `getBitmap()`, typed `void` in Electron 43), and a
|
|
171
187
|
// Buffer is a Uint8Array, so this is the only copy of the slice.
|