getobsrv 0.6.0 → 0.7.1
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 +12 -0
- package/out/main/cli.js +14 -6
- package/out/main/index.js +212 -9
- package/out/main/{targetSource-BKW32VA5.js → targetSource-CbJwfugi.js} +7 -1
- package/out/mcp/server.js +146 -42
- package/out/preload/app.js +10 -2
- package/out/renderer/assets/{index-CHF-G97L.css → index-BeroS4wv.css} +46 -0
- package/out/renderer/assets/{index-DyCD4ih_.js → index-Cgx8iixu.js} +104 -9
- package/out/renderer/index.html +2 -2
- package/out/shared/ipcPayloads.js +19 -5
- package/out/shared/presets.js +7 -1
- package/package.json +1 -1
- package/skills/obsrv-screens/SKILL.md +10 -0
package/README.md
CHANGED
|
@@ -91,6 +91,13 @@ root cannot scroll, and takes a `scrollSelector` when you need to name the
|
|
|
91
91
|
container yourself. With no app running, everything falls back to the headless
|
|
92
92
|
render automatically.
|
|
93
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
|
+
|
|
94
101
|
A headless `snap` returns `settled: true` when the page went paint-quiet and
|
|
95
102
|
every pixel painted. `settled: false` is still a usable capture, not a
|
|
96
103
|
failure — a page that kept animating, or one whose repaint never completed,
|
|
@@ -128,6 +135,11 @@ yet notarised, so macOS falsely reports it as "damaged"):
|
|
|
128
135
|
xattr -cr /Applications/Obsrv.app
|
|
129
136
|
```
|
|
130
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
|
+
|
|
131
143
|
## Distribution
|
|
132
144
|
|
|
133
145
|
Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
|
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);
|
|
@@ -370,6 +370,7 @@ function inkRows(img) {
|
|
|
370
370
|
}
|
|
371
371
|
return rows;
|
|
372
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.";
|
|
373
374
|
function bandInk(img, y0, y1) {
|
|
374
375
|
let ink = 0;
|
|
375
376
|
for (let y = y0; y < y1; y++) {
|
|
@@ -381,7 +382,7 @@ function bandInk(img, y0, y1) {
|
|
|
381
382
|
return pixels === 0 ? 0 : ink / pixels;
|
|
382
383
|
}
|
|
383
384
|
const pct = (v) => `${(v * 100).toFixed(2)}%`;
|
|
384
|
-
function diffMetrics(target, reference, referenceDeviceRows) {
|
|
385
|
+
function diffMetrics(target, reference, referenceDeviceRows, settled = true) {
|
|
385
386
|
if (target.width !== reference.width || target.height !== reference.height) {
|
|
386
387
|
throw new RangeError(
|
|
387
388
|
`diffMetrics: mismatched dimensions (target ${target.width}x${target.height}, reference ${reference.width}x${reference.height})`
|
|
@@ -407,6 +408,7 @@ function diffMetrics(target, reference, referenceDeviceRows) {
|
|
|
407
408
|
}
|
|
408
409
|
}
|
|
409
410
|
return {
|
|
411
|
+
settled,
|
|
410
412
|
inkCoverage: { target: targetCoverage, reference: referenceCoverage, delta: targetCoverage - referenceCoverage },
|
|
411
413
|
rows: {
|
|
412
414
|
target: targetRows,
|
|
@@ -414,7 +416,7 @@ function diffMetrics(target, reference, referenceDeviceRows) {
|
|
|
414
416
|
ratio: referenceDeviceRows > 0 ? targetRows / referenceDeviceRows : null
|
|
415
417
|
},
|
|
416
418
|
bands,
|
|
417
|
-
findings
|
|
419
|
+
findings: settled ? findings : [UNSETTLED_FINDING]
|
|
418
420
|
};
|
|
419
421
|
}
|
|
420
422
|
function profileToParams(p, hostNits) {
|
|
@@ -587,7 +589,12 @@ async function runDiff(cmd) {
|
|
|
587
589
|
const referenceFull = bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height);
|
|
588
590
|
const referenceDeviceRows = inkRows(referenceFull);
|
|
589
591
|
const reference = boxDownsample(referenceFull, 2);
|
|
590
|
-
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);
|
|
591
598
|
let files;
|
|
592
599
|
if (cmd.outDir) {
|
|
593
600
|
const dir = node_path.resolve(cmd.outDir);
|
|
@@ -598,14 +605,15 @@ async function runDiff(cmd) {
|
|
|
598
605
|
}
|
|
599
606
|
const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
|
|
600
607
|
human(
|
|
601
|
-
`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"}`
|
|
602
609
|
);
|
|
603
610
|
await machine({
|
|
604
611
|
url: cmd.url,
|
|
605
612
|
preset: cmd.spec.presetId,
|
|
606
613
|
profile: profile.id,
|
|
607
614
|
...files ? { files } : {},
|
|
608
|
-
...metrics
|
|
615
|
+
...metrics,
|
|
616
|
+
warnings
|
|
609
617
|
});
|
|
610
618
|
}
|
|
611
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 = {
|
|
@@ -35,7 +35,11 @@ const IPC = {
|
|
|
35
35
|
readImageFile: "obsrv:read-image-file",
|
|
36
36
|
uiState: "obsrv:ui-state",
|
|
37
37
|
agentApply: "obsrv:agent-apply",
|
|
38
|
-
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"
|
|
39
43
|
};
|
|
40
44
|
function attachFrameBus(target, win) {
|
|
41
45
|
let ready = false;
|
|
@@ -136,7 +140,11 @@ function parseSettings(raw) {
|
|
|
136
140
|
if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
|
|
137
141
|
const agentControl = raw.agentControl ?? false;
|
|
138
142
|
if (typeof agentControl !== "boolean") return null;
|
|
139
|
-
|
|
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 };
|
|
140
148
|
}
|
|
141
149
|
function parseMode(raw) {
|
|
142
150
|
return raw === "url" || raw === "image" ? raw : null;
|
|
@@ -149,7 +157,14 @@ function parseUiState(raw) {
|
|
|
149
157
|
if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
|
|
150
158
|
if (viewMode !== "1:1" && viewMode !== "fit") return null;
|
|
151
159
|
if (mode !== "url" && mode !== "image") return null;
|
|
152
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
presetId,
|
|
162
|
+
profileId,
|
|
163
|
+
viewMode,
|
|
164
|
+
mode,
|
|
165
|
+
targetBounds: parseRect(raw.targetBounds),
|
|
166
|
+
canvasBounds: parseRect(raw.canvasBounds)
|
|
167
|
+
};
|
|
153
168
|
}
|
|
154
169
|
function parseScrollPos(raw) {
|
|
155
170
|
if (!isRecord$1(raw)) return null;
|
|
@@ -262,6 +277,7 @@ function parseHighlight(raw) {
|
|
|
262
277
|
};
|
|
263
278
|
}
|
|
264
279
|
const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
280
|
+
const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
265
281
|
function loadSettings(file) {
|
|
266
282
|
try {
|
|
267
283
|
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
@@ -270,7 +286,11 @@ function loadSettings(file) {
|
|
|
270
286
|
hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits,
|
|
271
287
|
// Anything but a literal true (older files have no key at all) means off:
|
|
272
288
|
// a network-facing capability must never be enabled by a malformed file.
|
|
273
|
-
agentControl: raw.agentControl === true
|
|
289
|
+
agentControl: raw.agentControl === true,
|
|
290
|
+
// The opposite default: only a literal false turns the update check off,
|
|
291
|
+
// so a file from before this feature keeps it on.
|
|
292
|
+
updateCheck: raw.updateCheck !== false,
|
|
293
|
+
lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
|
|
274
294
|
};
|
|
275
295
|
} catch {
|
|
276
296
|
return { ...targetSource.DEFAULT_SETTINGS };
|
|
@@ -279,9 +299,67 @@ function loadSettings(file) {
|
|
|
279
299
|
function saveSettings(file, s) {
|
|
280
300
|
if (!isPositive(s.hostDiagonalInches) || !isPositive(s.hostNits)) throw new RangeError("settings values must be finite and > 0");
|
|
281
301
|
if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
|
|
302
|
+
if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
|
|
303
|
+
if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
|
|
282
304
|
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
283
305
|
node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
284
306
|
}
|
|
307
|
+
const RELEASES_API = "https://api.github.com/repos/vibesyemmy/obsrv/releases/latest";
|
|
308
|
+
const RELEASE_URL_PREFIX = "https://github.com/vibesyemmy/obsrv/releases/";
|
|
309
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
310
|
+
const CHECK_TIMEOUT_MS = 5e3;
|
|
311
|
+
function parseVersion(raw) {
|
|
312
|
+
const trimmed = raw.trim().replace(/^v/i, "");
|
|
313
|
+
if (trimmed === "") return null;
|
|
314
|
+
const [core = "", ...rest] = trimmed.split("-");
|
|
315
|
+
const parts = core.split(".").map(Number);
|
|
316
|
+
if (parts.length === 0 || parts.some((n) => !Number.isInteger(n) || n < 0)) return null;
|
|
317
|
+
return { parts, pre: rest.length > 0 };
|
|
318
|
+
}
|
|
319
|
+
function isNewer(latest, current) {
|
|
320
|
+
const a = parseVersion(latest);
|
|
321
|
+
const b = parseVersion(current);
|
|
322
|
+
if (!a || !b) return false;
|
|
323
|
+
const len = Math.max(a.parts.length, b.parts.length);
|
|
324
|
+
for (let i = 0; i < len; i++) {
|
|
325
|
+
const x = a.parts[i] ?? 0;
|
|
326
|
+
const y = b.parts[i] ?? 0;
|
|
327
|
+
if (x !== y) return x > y;
|
|
328
|
+
}
|
|
329
|
+
return !a.pre && b.pre;
|
|
330
|
+
}
|
|
331
|
+
function isReleaseUrl(url) {
|
|
332
|
+
let parsed;
|
|
333
|
+
try {
|
|
334
|
+
parsed = new URL(url);
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
const allowed = new URL(RELEASE_URL_PREFIX);
|
|
339
|
+
return parsed.protocol === allowed.protocol && parsed.host === allowed.host && parsed.pathname.startsWith(allowed.pathname);
|
|
340
|
+
}
|
|
341
|
+
function readRelease(body, current, now) {
|
|
342
|
+
let raw;
|
|
343
|
+
try {
|
|
344
|
+
raw = JSON.parse(body);
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
349
|
+
const { tag_name: tag, html_url: url } = raw;
|
|
350
|
+
if (typeof tag !== "string" || parseVersion(tag) === null) return null;
|
|
351
|
+
if (!isNewer(tag, current)) return { state: { status: "current", current, checkedAt: now }, url: "" };
|
|
352
|
+
if (typeof url !== "string" || !isReleaseUrl(url)) return null;
|
|
353
|
+
return {
|
|
354
|
+
state: { status: "available", current, latest: tag.trim().replace(/^v/i, ""), checkedAt: now },
|
|
355
|
+
url
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function isCheckDue(lastUpdateCheck, now) {
|
|
359
|
+
if (!Number.isFinite(lastUpdateCheck) || lastUpdateCheck <= 0) return true;
|
|
360
|
+
if (lastUpdateCheck > now) return true;
|
|
361
|
+
return now - lastUpdateCheck >= CHECK_INTERVAL_MS;
|
|
362
|
+
}
|
|
285
363
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
286
364
|
const APPLY_WAIT_MS = 2e3;
|
|
287
365
|
const APPLY_POLL_MS = 25;
|
|
@@ -482,6 +560,49 @@ class ControlServer {
|
|
|
482
560
|
});
|
|
483
561
|
}
|
|
484
562
|
}
|
|
563
|
+
async function checkForUpdate(current, now) {
|
|
564
|
+
const failed = { state: { status: "error", current, checkedAt: now }, url: "" };
|
|
565
|
+
const endpoint = process.env.OBSRV_RELEASES_API ?? RELEASES_API;
|
|
566
|
+
return new Promise((resolve) => {
|
|
567
|
+
let settled = false;
|
|
568
|
+
const done = (v) => {
|
|
569
|
+
if (settled) return;
|
|
570
|
+
settled = true;
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
resolve(v);
|
|
573
|
+
};
|
|
574
|
+
let request;
|
|
575
|
+
try {
|
|
576
|
+
request = electron.net.request({ method: "GET", url: endpoint });
|
|
577
|
+
} catch {
|
|
578
|
+
resolve(failed);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
request.abort();
|
|
583
|
+
done(failed);
|
|
584
|
+
}, CHECK_TIMEOUT_MS);
|
|
585
|
+
request.setHeader("accept", "application/vnd.github+json");
|
|
586
|
+
request.setHeader("user-agent", "obsrv-update-check");
|
|
587
|
+
request.on("response", (response) => {
|
|
588
|
+
if (response.statusCode !== 200) {
|
|
589
|
+
response.on("data", () => void 0);
|
|
590
|
+
response.on("end", () => done(failed));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const chunks = [];
|
|
594
|
+
response.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
595
|
+
response.on("end", () => {
|
|
596
|
+
const parsed = readRelease(Buffer.concat(chunks).toString("utf8"), current, now);
|
|
597
|
+
done(parsed ?? failed);
|
|
598
|
+
});
|
|
599
|
+
response.on("error", () => done(failed));
|
|
600
|
+
});
|
|
601
|
+
request.on("error", () => done(failed));
|
|
602
|
+
request.on("abort", () => done(failed));
|
|
603
|
+
request.end();
|
|
604
|
+
});
|
|
605
|
+
}
|
|
485
606
|
const TOOLBAR_H = 44;
|
|
486
607
|
const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
|
|
487
608
|
const SCROLL_REPLY_TIMEOUT_MS = 1e3;
|
|
@@ -645,6 +766,7 @@ function registerIpc(ctx) {
|
|
|
645
766
|
});
|
|
646
767
|
const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
|
|
647
768
|
let targetBounds = null;
|
|
769
|
+
let canvasBounds = null;
|
|
648
770
|
const MAX_PENDING_APPLIES = 32;
|
|
649
771
|
let rendererReported = false;
|
|
650
772
|
let warnedPendingOverflow = false;
|
|
@@ -653,9 +775,10 @@ function registerIpc(ctx) {
|
|
|
653
775
|
if (!fromRenderer(e)) return;
|
|
654
776
|
const s = parseUiState(raw);
|
|
655
777
|
if (!s) return;
|
|
656
|
-
const { targetBounds: bounds, ...state } = s;
|
|
778
|
+
const { targetBounds: bounds, canvasBounds: canvas, ...state } = s;
|
|
657
779
|
Object.assign(uiState, state);
|
|
658
780
|
targetBounds = bounds ?? null;
|
|
781
|
+
canvasBounds = canvas ?? null;
|
|
659
782
|
if (!rendererReported) {
|
|
660
783
|
rendererReported = true;
|
|
661
784
|
for (const patch of pendingApplies.splice(0)) {
|
|
@@ -663,6 +786,46 @@ function registerIpc(ctx) {
|
|
|
663
786
|
}
|
|
664
787
|
}
|
|
665
788
|
});
|
|
789
|
+
const SETTLE_POLL_MS = 80;
|
|
790
|
+
const SETTLE_STABLE_READS = 2;
|
|
791
|
+
const SETTLE_BUDGET_MS = 4e3;
|
|
792
|
+
const SETTLE_DRAW_MS = 120;
|
|
793
|
+
const nextFrame = (budgetMs) => new Promise((resolve) => {
|
|
794
|
+
const timer = setTimeout(() => {
|
|
795
|
+
target.off("frame", onFrame);
|
|
796
|
+
resolve(false);
|
|
797
|
+
}, budgetMs);
|
|
798
|
+
const onFrame = () => {
|
|
799
|
+
clearTimeout(timer);
|
|
800
|
+
target.off("frame", onFrame);
|
|
801
|
+
resolve(true);
|
|
802
|
+
};
|
|
803
|
+
target.on("frame", onFrame);
|
|
804
|
+
target.invalidate();
|
|
805
|
+
});
|
|
806
|
+
const settleTarget = async () => {
|
|
807
|
+
const deadline = Date.now() + SETTLE_BUDGET_MS;
|
|
808
|
+
let last = "";
|
|
809
|
+
let stable = 0;
|
|
810
|
+
while (Date.now() < deadline) {
|
|
811
|
+
const v = target.getViewport();
|
|
812
|
+
const key = `${v.width}x${v.height}`;
|
|
813
|
+
stable = key === last ? stable + 1 : 0;
|
|
814
|
+
last = key;
|
|
815
|
+
if (stable >= SETTLE_STABLE_READS) break;
|
|
816
|
+
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
817
|
+
}
|
|
818
|
+
if (stable < SETTLE_STABLE_READS) return false;
|
|
819
|
+
const painted = await nextFrame(Math.max(0, deadline - Date.now()));
|
|
820
|
+
await new Promise((r) => setTimeout(r, SETTLE_DRAW_MS));
|
|
821
|
+
return painted;
|
|
822
|
+
};
|
|
823
|
+
const roundRect = (r) => ({
|
|
824
|
+
x: Math.round(r.x),
|
|
825
|
+
y: Math.round(r.y),
|
|
826
|
+
width: Math.max(1, Math.round(r.width)),
|
|
827
|
+
height: Math.max(1, Math.round(r.height))
|
|
828
|
+
});
|
|
666
829
|
const appVersion = (() => {
|
|
667
830
|
try {
|
|
668
831
|
const pkg = JSON.parse(node_fs.readFileSync(node_path.join(__dirname, "..", "..", "package.json"), "utf8"));
|
|
@@ -671,6 +834,40 @@ function registerIpc(ctx) {
|
|
|
671
834
|
return electron.app.getVersion();
|
|
672
835
|
}
|
|
673
836
|
})();
|
|
837
|
+
let update = { status: "current", current: appVersion, checkedAt: 0 };
|
|
838
|
+
let releaseUrl = "";
|
|
839
|
+
const runUpdateCheck = async () => {
|
|
840
|
+
const now = Date.now();
|
|
841
|
+
const { state, url } = await checkForUpdate(appVersion, now);
|
|
842
|
+
update = state;
|
|
843
|
+
releaseUrl = state.status === "available" && isReleaseUrl(url) ? url : "";
|
|
844
|
+
const next = { ...settings, lastUpdateCheck: now };
|
|
845
|
+
try {
|
|
846
|
+
saveSettings(settingsFile, next);
|
|
847
|
+
settings = next;
|
|
848
|
+
} catch {
|
|
849
|
+
}
|
|
850
|
+
if (!win.isDestroyed()) win.webContents.send(IPC.updateStatus, state);
|
|
851
|
+
return state;
|
|
852
|
+
};
|
|
853
|
+
electron.ipcMain.handle(IPC.getUpdate, (e) => {
|
|
854
|
+
assertRenderer(e);
|
|
855
|
+
return update;
|
|
856
|
+
});
|
|
857
|
+
electron.ipcMain.handle(IPC.checkUpdate, (e) => {
|
|
858
|
+
assertRenderer(e);
|
|
859
|
+
return runUpdateCheck();
|
|
860
|
+
});
|
|
861
|
+
electron.ipcMain.handle(IPC.openRelease, async (e) => {
|
|
862
|
+
assertRenderer(e);
|
|
863
|
+
if (releaseUrl === "") return false;
|
|
864
|
+
await electron.shell.openExternal(releaseUrl);
|
|
865
|
+
return true;
|
|
866
|
+
});
|
|
867
|
+
const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
|
|
868
|
+
if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
|
|
869
|
+
void runUpdateCheck();
|
|
870
|
+
}
|
|
674
871
|
const control = new ControlServer(node_path.join(electron.app.getPath("userData"), CONTROL_FILE_NAME), {
|
|
675
872
|
status: () => {
|
|
676
873
|
let url = "";
|
|
@@ -697,20 +894,26 @@ function registerIpc(ctx) {
|
|
|
697
894
|
win.webContents.send(IPC.agentApply, patch);
|
|
698
895
|
},
|
|
699
896
|
captureVisible: async () => {
|
|
897
|
+
await settleTarget();
|
|
700
898
|
const image = await win.webContents.capturePage();
|
|
701
899
|
const size = image.getSize();
|
|
702
900
|
return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
|
|
703
901
|
},
|
|
704
902
|
captureTarget: async () => {
|
|
705
|
-
const
|
|
903
|
+
const settled = await settleTarget();
|
|
904
|
+
const bounds = canvasBounds ?? targetBounds;
|
|
706
905
|
const known = bounds !== null && bounds.width >= 1 && bounds.height >= 1;
|
|
707
|
-
const image = await win.webContents.capturePage(known ? bounds : void 0);
|
|
906
|
+
const image = await win.webContents.capturePage(known ? roundRect(bounds) : void 0);
|
|
708
907
|
const size = image.getSize();
|
|
908
|
+
const warnings = [];
|
|
909
|
+
if (!known) warnings.push("the renderer has not reported the pane bounds yet; captured the full window instead");
|
|
910
|
+
else if (canvasBounds === null) warnings.push("the renderer has not reported the render bounds yet; captured the whole pane instead");
|
|
911
|
+
if (!settled) warnings.push("the target was still resizing when the capture budget ran out; the PNG may show a transitional frame");
|
|
709
912
|
return {
|
|
710
913
|
data: image.toPNG().toString("base64"),
|
|
711
914
|
width: size.width,
|
|
712
915
|
height: size.height,
|
|
713
|
-
warnings
|
|
916
|
+
warnings
|
|
714
917
|
};
|
|
715
918
|
},
|
|
716
919
|
viewport: () => target.getViewport(),
|
|
@@ -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" },
|
package/out/mcp/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const args_1 = require("../cli/args");
|
|
|
12
12
|
const control_1 = require("../shared/control");
|
|
13
13
|
const presets_1 = require("../shared/presets");
|
|
14
14
|
const types_1 = require("../shared/types");
|
|
15
|
+
const url_1 = require("../shared/url");
|
|
15
16
|
const control_2 = require("./control");
|
|
16
17
|
const lib_1 = require("./lib");
|
|
17
18
|
/**
|
|
@@ -127,8 +128,9 @@ const snapInputShape = {
|
|
|
127
128
|
capture: zod_1.z
|
|
128
129
|
.enum(['window', 'pane'])
|
|
129
130
|
.optional()
|
|
130
|
-
.describe("Live mode only: what the returned PNG shows — 'window' (default) is the whole app window, 'pane' is
|
|
131
|
-
'
|
|
131
|
+
.describe("Live mode only: what the returned PNG shows — 'window' (default) is the whole app window, 'pane' is the " +
|
|
132
|
+
'rendered screen cropped to itself, so a minified mobile preset is phone-shaped rather than a small phone ' +
|
|
133
|
+
'in a large rectangle. Ignored (with a note) when the render is headless.'),
|
|
132
134
|
};
|
|
133
135
|
const snapOutputShape = {
|
|
134
136
|
mode: zod_1.z
|
|
@@ -144,7 +146,14 @@ const snapOutputShape = {
|
|
|
144
146
|
.boolean()
|
|
145
147
|
.describe('Headless: the page went paint-quiet and every pixel painted. False is still a usable capture — a page that ' +
|
|
146
148
|
'kept animating, or one whose repaint never completed, is returned as-is with a warning saying what was ' +
|
|
147
|
-
'missing. Live: the app confirmed the navigation before the capture
|
|
149
|
+
'missing. Live: the app confirmed the navigation before the capture (trivially true when the app was ' +
|
|
150
|
+
'already showing the URL and nothing was navigated).'),
|
|
151
|
+
navigated: zod_1.z
|
|
152
|
+
.boolean()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe('Live only: false when the app was already showing this URL, so no reload was issued and the capture kept ' +
|
|
155
|
+
'the current scroll position, pan and in-page state. True when the app was pointed somewhere new — that is ' +
|
|
156
|
+
'a fresh load, which starts at the top of the page.'),
|
|
148
157
|
warnings: zod_1.z.array(zod_1.z.string()),
|
|
149
158
|
pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
|
|
150
159
|
url: zod_1.z.string().optional().describe('Live only: the URL the app reports showing.'),
|
|
@@ -182,6 +191,12 @@ const diffInputShape = {
|
|
|
182
191
|
.describe(`Per-render budget for load + paint quiescence, in ms. Default ${args_1.DEFAULT_TIMEOUT_MS}.`),
|
|
183
192
|
};
|
|
184
193
|
const diffOutputShape = {
|
|
194
|
+
settled: zod_1.z
|
|
195
|
+
.boolean()
|
|
196
|
+
.describe('False when either render was a best-effort capture of a page that never stopped painting. The two captures ' +
|
|
197
|
+
'are then different frames, so the band deltas are frame-to-frame noise rather than rendering evidence — ' +
|
|
198
|
+
'`findings` says so instead of interpreting them.'),
|
|
199
|
+
warnings: zod_1.z.array(zod_1.z.string()).describe('Anything either render warned about, prefixed target: / reference:.'),
|
|
185
200
|
url: zod_1.z.string(),
|
|
186
201
|
preset: zod_1.z.string(),
|
|
187
202
|
profile: zod_1.z.string(),
|
|
@@ -273,6 +288,14 @@ const driveInputShape = {
|
|
|
273
288
|
.optional()
|
|
274
289
|
.describe('Draw a temporary neutral marker over this target-pixel rect in the pane (durationMs default 2000, clamped ' +
|
|
275
290
|
'250-10000). A new highlight replaces the previous one.'),
|
|
291
|
+
capture: zod_1.z
|
|
292
|
+
.enum(['window', 'pane'])
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("Capture the app after the commands run: 'pane' crops to the rendered screen itself (the render, not the " +
|
|
295
|
+
"empty pane around it — a minified mobile preset comes back phone-shaped), 'window' takes the whole app " +
|
|
296
|
+
'window. The capture waits for a preset resize to finish first, so the PNG matches the status beside it. ' +
|
|
297
|
+
'This is how you see a scrolled or panned state — unlike obsrv_snap, nothing is navigated, so the scroll ' +
|
|
298
|
+
'position survives. The PNG comes back inline when it is within the 1.5 MiB cap, and always as pngPath.'),
|
|
276
299
|
};
|
|
277
300
|
const driveOutputShape = {
|
|
278
301
|
version: zod_1.z.string().describe('The running app version.'),
|
|
@@ -293,6 +316,15 @@ const driveOutputShape = {
|
|
|
293
316
|
.optional()
|
|
294
317
|
.describe("Only when `scroll` was requested: 'root' if the document scrolled, 'element' if an inner scroll container did."),
|
|
295
318
|
warnings: zod_1.z.array(zod_1.z.string()).optional().describe('Anything worth knowing about the commands that ran (e.g. a scrollSelector that matched nothing).'),
|
|
319
|
+
pngPath: zod_1.z.string().optional().describe('Only when `capture` was requested: absolute path of the PNG (kept in a per-call temp dir).'),
|
|
320
|
+
width: zod_1.z
|
|
321
|
+
.number()
|
|
322
|
+
.optional()
|
|
323
|
+
.describe('Only when `capture` was requested: captured width in device-independent px; the raster is this times the display scale.'),
|
|
324
|
+
height: zod_1.z
|
|
325
|
+
.number()
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Only when `capture` was requested: captured height in device-independent px.'),
|
|
296
328
|
};
|
|
297
329
|
// --- live drive --------------------------------------------------------------
|
|
298
330
|
/** Budget for one control `status` round-trip once the app is known live. */
|
|
@@ -317,20 +349,83 @@ function liveFailure(e) {
|
|
|
317
349
|
}
|
|
318
350
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
319
351
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
352
|
+
* One short grace before a live capture: the renderer repaints the pane a
|
|
353
|
+
* frame or two after the store confirms, and a capture racing that would show
|
|
354
|
+
* a half-applied flip.
|
|
355
|
+
*/
|
|
356
|
+
const LIVE_CAPTURE_GRACE_MS = 300;
|
|
357
|
+
/**
|
|
358
|
+
* Is the app already showing this page? Compared as parsed URLs so a request
|
|
359
|
+
* for `http://host:5173` matches the `http://host:5173/` the browser commits,
|
|
360
|
+
* and through the same normaliser the URL bar uses so a bare host works too.
|
|
361
|
+
* Anything unparseable falls back to a trimmed string compare.
|
|
362
|
+
*/
|
|
363
|
+
function sameUrl(a, b) {
|
|
364
|
+
const norm = (raw) => {
|
|
365
|
+
const t = raw.trim();
|
|
366
|
+
if (t === '')
|
|
367
|
+
return '';
|
|
368
|
+
try {
|
|
369
|
+
return new URL((0, url_1.normalizeUrl)(t)).href;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return t;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const x = norm(a);
|
|
376
|
+
const y = norm(b);
|
|
377
|
+
return x !== '' && x === y;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Capture the app window — or just the target pane — over the control server
|
|
381
|
+
* and write it to a per-call temp PNG. Shared by the live `obsrv_snap` path
|
|
382
|
+
* and `obsrv_drive`'s `capture`, so both produce byte-identical results.
|
|
383
|
+
*/
|
|
384
|
+
async function liveCapture(info, what) {
|
|
385
|
+
// `pane` crops to the target pane; both answer with the same
|
|
386
|
+
// { data, width, height } shape plus their own warnings (e.g. the pre-mount
|
|
387
|
+
// full-window fallback), which join the tool's.
|
|
388
|
+
const command = what === 'pane' ? 'captureTarget' : 'captureVisible';
|
|
389
|
+
const capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
|
|
390
|
+
const { data, width, height } = capture;
|
|
391
|
+
if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
|
|
392
|
+
throw new Error('the control server returned a malformed capture');
|
|
393
|
+
}
|
|
394
|
+
const warnings = [];
|
|
395
|
+
if (Array.isArray(capture['warnings'])) {
|
|
396
|
+
for (const w of capture['warnings'])
|
|
397
|
+
if (typeof w === 'string')
|
|
398
|
+
warnings.push(w);
|
|
399
|
+
}
|
|
400
|
+
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
401
|
+
const pngPath = (0, node_path_1.join)(dir, 'live.png');
|
|
402
|
+
await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
|
|
403
|
+
return { pngPath, width, height, warnings };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The live `obsrv_snap` path: point the visible app at the URL (plus
|
|
407
|
+
* preset/profile when given), wait — bounded — for it to report the
|
|
408
|
+
* navigation, then capture the window exactly as the user sees it.
|
|
409
|
+
*
|
|
410
|
+
* When the app is already showing that URL the navigation is skipped entirely.
|
|
411
|
+
* A navigate is a fresh `loadURL`, which resets the scroll position, so
|
|
412
|
+
* reloading here would make `obsrv_drive { scroll }` followed by a snap of the
|
|
413
|
+
* same page always capture the top. `navigated: false` says which happened.
|
|
323
414
|
*/
|
|
324
415
|
async function liveSnap(app, input, notes) {
|
|
325
416
|
const { info } = app;
|
|
326
417
|
const warnings = [...notes];
|
|
327
418
|
const before = app.status.url;
|
|
328
|
-
|
|
419
|
+
// Already there? Then leave the page alone — see the note above.
|
|
420
|
+
const navigated = !sameUrl(before, input.url);
|
|
421
|
+
let applied = before;
|
|
329
422
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
423
|
+
if (navigated) {
|
|
424
|
+
// The navigate command answers once both panes finished loading, so it
|
|
425
|
+
// carries the same per-render budget the headless path polices.
|
|
426
|
+
const nav = await (0, control_2.controlCall)(info, 'navigate', { url: input.url.trim() }, (input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS) + 10_000);
|
|
427
|
+
applied = typeof nav['url'] === 'string' ? nav['url'] : '';
|
|
428
|
+
}
|
|
334
429
|
if (input.preset !== undefined)
|
|
335
430
|
await (0, control_2.controlCall)(info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
|
|
336
431
|
if (input.profile !== undefined)
|
|
@@ -341,15 +436,18 @@ async function liveSnap(app, input, notes) {
|
|
|
341
436
|
}
|
|
342
437
|
// The app settles when it reports the applied URL — or, after a redirect,
|
|
343
438
|
// any committed non-blank URL that is no longer the pre-navigation one.
|
|
439
|
+
// Nothing to settle when no navigation was issued; one status read still
|
|
440
|
+
// refreshes the preset/profile/view the result reports.
|
|
344
441
|
let status = app.status;
|
|
345
|
-
let settled =
|
|
442
|
+
let settled = !navigated;
|
|
346
443
|
const deadline = Date.now() + LIVE_SETTLE_MS;
|
|
347
444
|
for (;;) {
|
|
348
445
|
try {
|
|
349
446
|
const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
350
447
|
if (s) {
|
|
351
448
|
status = s;
|
|
352
|
-
|
|
449
|
+
if (navigated)
|
|
450
|
+
settled = s.url === applied || (applied !== '' && s.url !== before && s.url !== 'about:blank');
|
|
353
451
|
}
|
|
354
452
|
}
|
|
355
453
|
catch (e) {
|
|
@@ -361,33 +459,16 @@ async function liveSnap(app, input, notes) {
|
|
|
361
459
|
}
|
|
362
460
|
if (!settled)
|
|
363
461
|
warnings.push('the app did not confirm the navigation before capture; the PNG may show the previous page.');
|
|
364
|
-
|
|
365
|
-
// (and any preset resize) a frame or two after the store confirms, and a
|
|
366
|
-
// capture racing that would show a half-applied flip.
|
|
367
|
-
await sleep(300);
|
|
368
|
-
// `capture: 'pane'` crops to the target pane; the command answers with the
|
|
369
|
-
// same { data, width, height } shape plus its own warnings (e.g. the
|
|
370
|
-
// pre-mount full-window fallback), which join the tool's.
|
|
462
|
+
await sleep(LIVE_CAPTURE_GRACE_MS);
|
|
371
463
|
let capture;
|
|
372
464
|
try {
|
|
373
|
-
|
|
374
|
-
capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
|
|
465
|
+
capture = await liveCapture(info, input.capture === 'pane' ? 'pane' : 'window');
|
|
375
466
|
}
|
|
376
467
|
catch (e) {
|
|
377
468
|
return toolError(liveFailure(e));
|
|
378
469
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return toolError('the control server returned a malformed capture');
|
|
382
|
-
}
|
|
383
|
-
if (Array.isArray(capture['warnings'])) {
|
|
384
|
-
for (const w of capture['warnings'])
|
|
385
|
-
if (typeof w === 'string')
|
|
386
|
-
warnings.push(w);
|
|
387
|
-
}
|
|
388
|
-
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
389
|
-
const pngPath = (0, node_path_1.join)(dir, 'live.png');
|
|
390
|
-
await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
|
|
470
|
+
warnings.push(...capture.warnings);
|
|
471
|
+
const { pngPath, width, height } = capture;
|
|
391
472
|
const structured = {
|
|
392
473
|
mode: 'live',
|
|
393
474
|
url: status.url,
|
|
@@ -397,6 +478,7 @@ async function liveSnap(app, input, notes) {
|
|
|
397
478
|
width,
|
|
398
479
|
height,
|
|
399
480
|
settled,
|
|
481
|
+
navigated,
|
|
400
482
|
warnings,
|
|
401
483
|
pngPath,
|
|
402
484
|
};
|
|
@@ -428,7 +510,10 @@ server.registerTool('obsrv_snap', {
|
|
|
428
510
|
`ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
|
|
429
511
|
`touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
|
|
430
512
|
`the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
|
|
431
|
-
`capture; that visible steering is the point of live mode
|
|
513
|
+
`capture; that visible steering is the point of live mode.\n\n` +
|
|
514
|
+
`A live snap only navigates when the app is showing a different URL; the result's \`navigated\` says which ` +
|
|
515
|
+
`happened. Navigating is a fresh load, so it starts at the top of the page — to photograph a scrolled or ` +
|
|
516
|
+
`panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.`,
|
|
432
517
|
inputSchema: snapInputShape,
|
|
433
518
|
outputSchema: snapOutputShape,
|
|
434
519
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -492,6 +577,9 @@ server.registerTool('obsrv_diff', {
|
|
|
492
577
|
`8 horizontal band deltas with humanised findings (informational — apply your own thresholds), and the ` +
|
|
493
578
|
`paths of target.png / reference.png in a per-call temp dir. \`includeImages: true\` also inlines both ` +
|
|
494
579
|
`PNGs (1.5 MiB cap each).\n\n` +
|
|
580
|
+
`Check \`settled\` before believing the bands: a page that never stops painting (animation, video) yields ` +
|
|
581
|
+
`two captures of *different frames*, so every delta is frame-to-frame noise. When it is false the numbers ` +
|
|
582
|
+
`are still returned but \`findings\` says so instead of interpreting them.\n\n` +
|
|
495
583
|
`1x presets only (e.g. laptop-768, 1080p-24): dense presets (phones) and CSS viewports over 2048px are ` +
|
|
496
584
|
`refused with an explanatory error — use obsrv_snap for those.\n\n` +
|
|
497
585
|
`Headless-only: a diff always performs its own two renders and never drives a running Obsrv app window ` +
|
|
@@ -532,7 +620,8 @@ server.registerTool('obsrv_drive', {
|
|
|
532
620
|
`both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
|
|
533
621
|
`neutral marker, all while the user watches.\n\n` +
|
|
534
622
|
`Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
|
|
535
|
-
`preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight
|
|
623
|
+
`preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
|
|
624
|
+
`capture. ` +
|
|
536
625
|
`The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
|
|
537
626
|
`click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
|
|
538
627
|
`scroll adds \`scrolled\` (the offset actually reached) and \`scroller\` ('root' or 'element'): compare ` +
|
|
@@ -541,9 +630,12 @@ server.registerTool('obsrv_drive', {
|
|
|
541
630
|
`Coordinates: click takes CSS-viewport px of the page (the valid range is 0 up to but not including the ` +
|
|
542
631
|
`viewport size); panTo and highlight take target-pane pixels (device px of the render — identical to CSS px ` +
|
|
543
632
|
`on 1x presets); scroll takes page CSS px.\n\n` +
|
|
633
|
+
`Pass \`capture\` to get a PNG back once the commands have run. Nothing in this tool navigates unless you ` +
|
|
634
|
+
`pass \`url\`, so this is how you photograph a scrolled or panned state: scroll, then capture, in one call. ` +
|
|
635
|
+
`obsrv_snap is the other way round — it points the app at a URL first, and pointing it somewhere new is a ` +
|
|
636
|
+
`fresh load that starts at the top.\n\n` +
|
|
544
637
|
`Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
|
|
545
|
-
`mutates visible app state (it changes what the user's window shows, and a click can act on the live page)
|
|
546
|
-
`but renders nothing itself — use obsrv_snap for a capture.`,
|
|
638
|
+
`mutates visible app state (it changes what the user's window shows, and a click can act on the live page).`,
|
|
547
639
|
inputSchema: driveInputShape,
|
|
548
640
|
outputSchema: driveOutputShape,
|
|
549
641
|
// Honest annotation: this changes what the user's window is showing.
|
|
@@ -621,6 +713,15 @@ server.registerTool('obsrv_drive', {
|
|
|
621
713
|
}
|
|
622
714
|
if (input.highlight !== undefined)
|
|
623
715
|
await (0, control_2.controlCall)(live.info, 'highlight', input.highlight, LIVE_APPLY_TIMEOUT_MS);
|
|
716
|
+
// Capture last, so the PNG shows everything the commands above did.
|
|
717
|
+
// Nothing here navigates, so a scroll or pan applied in this same call
|
|
718
|
+
// is still in place when the shutter fires.
|
|
719
|
+
let capture = null;
|
|
720
|
+
if (input.capture !== undefined) {
|
|
721
|
+
await sleep(LIVE_CAPTURE_GRACE_MS);
|
|
722
|
+
capture = await liveCapture(live.info, input.capture);
|
|
723
|
+
warnings.push(...capture.warnings);
|
|
724
|
+
}
|
|
624
725
|
const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
625
726
|
if (!status)
|
|
626
727
|
return toolError('the control server returned a malformed status');
|
|
@@ -629,11 +730,14 @@ server.registerTool('obsrv_drive', {
|
|
|
629
730
|
...(input.scroll !== undefined ? { scrolled: scrolled ?? null } : {}),
|
|
630
731
|
...(scroller !== undefined ? { scroller } : {}),
|
|
631
732
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
733
|
+
...(capture !== null ? { pngPath: capture.pngPath, width: capture.width, height: capture.height } : {}),
|
|
632
734
|
};
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
735
|
+
const content = [{ type: 'text', text: JSON.stringify(structured, null, 2) }];
|
|
736
|
+
if (capture !== null) {
|
|
737
|
+
const label = input.capture === 'pane' ? 'The captured target pane' : 'The captured app window';
|
|
738
|
+
content.push(await imageOrNote(capture.pngPath, label, 'read the file at pngPath'));
|
|
739
|
+
}
|
|
740
|
+
return { content, structuredContent: structured };
|
|
637
741
|
}
|
|
638
742
|
catch (e) {
|
|
639
743
|
return toolError(liveFailure(e));
|
package/out/preload/app.js
CHANGED
|
@@ -25,7 +25,11 @@ const IPC = {
|
|
|
25
25
|
readImageFile: "obsrv:read-image-file",
|
|
26
26
|
uiState: "obsrv:ui-state",
|
|
27
27
|
agentApply: "obsrv:agent-apply",
|
|
28
|
-
agentActivity: "obsrv:agent-activity"
|
|
28
|
+
agentActivity: "obsrv:agent-activity",
|
|
29
|
+
getUpdate: "obsrv:get-update",
|
|
30
|
+
checkUpdate: "obsrv:check-update",
|
|
31
|
+
openRelease: "obsrv:open-release",
|
|
32
|
+
updateStatus: "obsrv:update-status"
|
|
29
33
|
};
|
|
30
34
|
function subscribe(channel, cb) {
|
|
31
35
|
const listener = (_e, v) => cb(v);
|
|
@@ -94,6 +98,10 @@ const api = {
|
|
|
94
98
|
return () => {
|
|
95
99
|
electron.ipcRenderer.removeListener(IPC.agentActivity, listener);
|
|
96
100
|
};
|
|
97
|
-
}
|
|
101
|
+
},
|
|
102
|
+
getUpdate: () => electron.ipcRenderer.invoke(IPC.getUpdate),
|
|
103
|
+
checkUpdate: () => electron.ipcRenderer.invoke(IPC.checkUpdate),
|
|
104
|
+
openRelease: () => electron.ipcRenderer.invoke(IPC.openRelease),
|
|
105
|
+
onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb)
|
|
98
106
|
};
|
|
99
107
|
electron.contextBridge.exposeInMainWorld("obsrv", api);
|
|
@@ -477,3 +477,49 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
477
477
|
}
|
|
478
478
|
.browser-notice p { margin: 0.75rem 0 0; color: var(--text-1); }
|
|
479
479
|
.browser-notice code { font-family: var(--mono); }
|
|
480
|
+
|
|
481
|
+
/* An update is neither a warning nor an error, so it carries no colour: the
|
|
482
|
+
only chromatic pixels in this chrome stay reserved for real attention. */
|
|
483
|
+
/* `.toolbar button` is (0,1,1) and pins every button to a 26px square, so this
|
|
484
|
+
needs two classes to outrank it — a bare `.update-button` renders as "v0.". */
|
|
485
|
+
.toolbar .update-button {
|
|
486
|
+
font-family: var(--mono);
|
|
487
|
+
font-variant-numeric: tabular-nums;
|
|
488
|
+
white-space: nowrap;
|
|
489
|
+
width: auto;
|
|
490
|
+
flex: 0 0 auto;
|
|
491
|
+
padding: 0 8px;
|
|
492
|
+
}
|
|
493
|
+
.version-block { margin: 8px 0; }
|
|
494
|
+
.version-row {
|
|
495
|
+
display: flex;
|
|
496
|
+
justify-content: space-between;
|
|
497
|
+
gap: 10px;
|
|
498
|
+
padding: 3px 0;
|
|
499
|
+
color: var(--text-1);
|
|
500
|
+
}
|
|
501
|
+
.version-row .version-current,
|
|
502
|
+
.version-row .version-latest,
|
|
503
|
+
.version-row .version-checked {
|
|
504
|
+
color: var(--text-0);
|
|
505
|
+
text-align: right;
|
|
506
|
+
}
|
|
507
|
+
.link {
|
|
508
|
+
background: none;
|
|
509
|
+
border: 0;
|
|
510
|
+
padding: 0;
|
|
511
|
+
color: var(--text-0);
|
|
512
|
+
text-decoration: underline;
|
|
513
|
+
cursor: pointer;
|
|
514
|
+
font: inherit;
|
|
515
|
+
}
|
|
516
|
+
.check-now {
|
|
517
|
+
width: 100%;
|
|
518
|
+
height: 24px;
|
|
519
|
+
margin-top: 8px;
|
|
520
|
+
background: var(--chrome-2);
|
|
521
|
+
color: var(--text-0);
|
|
522
|
+
border: 1px solid var(--line);
|
|
523
|
+
border-radius: 4px;
|
|
524
|
+
cursor: pointer;
|
|
525
|
+
}
|
|
@@ -12688,7 +12688,13 @@ const createImpl = (createState) => {
|
|
|
12688
12688
|
};
|
|
12689
12689
|
const create = ((createState) => createImpl);
|
|
12690
12690
|
const MAX_VIEWPORT = 4096;
|
|
12691
|
-
const DEFAULT_SETTINGS = {
|
|
12691
|
+
const DEFAULT_SETTINGS = {
|
|
12692
|
+
hostDiagonalInches: 27,
|
|
12693
|
+
hostNits: 500,
|
|
12694
|
+
agentControl: false,
|
|
12695
|
+
updateCheck: true,
|
|
12696
|
+
lastUpdateCheck: 0
|
|
12697
|
+
};
|
|
12692
12698
|
const SCREEN_PRESETS = [
|
|
12693
12699
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
12694
12700
|
{ id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
|
|
@@ -12773,6 +12779,7 @@ const useStore = create()((set) => ({
|
|
|
12773
12779
|
targetLoading: false,
|
|
12774
12780
|
error: null,
|
|
12775
12781
|
toast: null,
|
|
12782
|
+
update: null,
|
|
12776
12783
|
image: null,
|
|
12777
12784
|
surround: "graphite",
|
|
12778
12785
|
viewMode: "1:1",
|
|
@@ -12798,6 +12805,7 @@ const useStore = create()((set) => ({
|
|
|
12798
12805
|
// Both panes report the same `loadError` for one failed navigation; the
|
|
12799
12806
|
// duplicate must not replace the object and re-render everything twice.
|
|
12800
12807
|
setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
|
|
12808
|
+
setUpdate: (update) => set({ update }),
|
|
12801
12809
|
setToast: (toast) => set({ toast }),
|
|
12802
12810
|
setImage: (image) => set({ image }),
|
|
12803
12811
|
setSurround: (surround) => set({ surround }),
|
|
@@ -13228,6 +13236,18 @@ function PanelControls() {
|
|
|
13228
13236
|
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Choosing a profile in the toolbar resets these." })
|
|
13229
13237
|
] });
|
|
13230
13238
|
}
|
|
13239
|
+
const MINUTE = 6e4;
|
|
13240
|
+
const HOUR = 60 * MINUTE;
|
|
13241
|
+
const DAY = 24 * HOUR;
|
|
13242
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"} ago`;
|
|
13243
|
+
function formatAge(checkedAt, now) {
|
|
13244
|
+
if (!Number.isFinite(checkedAt) || checkedAt <= 0) return "never";
|
|
13245
|
+
const age = now - checkedAt;
|
|
13246
|
+
if (age < MINUTE) return "just now";
|
|
13247
|
+
if (age < HOUR) return plural(Math.floor(age / MINUTE), "minute");
|
|
13248
|
+
if (age < DAY) return plural(Math.floor(age / HOUR), "hour");
|
|
13249
|
+
return plural(Math.floor(age / DAY), "day");
|
|
13250
|
+
}
|
|
13231
13251
|
function NumberField({ className, label, unit, value, min, step, onCommit, onInvalid }) {
|
|
13232
13252
|
const ref = reactExports.useRef(null);
|
|
13233
13253
|
const [draft, setDraft] = reactExports.useState(String(value));
|
|
@@ -13278,6 +13298,7 @@ function NumberField({ className, label, unit, value, min, step, onCommit, onInv
|
|
|
13278
13298
|
function SettingsPanel() {
|
|
13279
13299
|
const host = useStore(useShallow((s) => s.host));
|
|
13280
13300
|
const settings = useStore(useShallow((s) => s.settings));
|
|
13301
|
+
const update = useStore((s) => s.update);
|
|
13281
13302
|
const custom = useStore(useShallow((s) => s.custom));
|
|
13282
13303
|
const viewport = useStore(useShallow(selectViewport));
|
|
13283
13304
|
const scale = useStore(selectScale);
|
|
@@ -13396,7 +13417,45 @@ function SettingsPanel() {
|
|
|
13396
13417
|
viewport.height,
|
|
13397
13418
|
"."
|
|
13398
13419
|
] }),
|
|
13399
|
-
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "readout", children: custom.diagonalInches > 0 ? `${ppi(custom.width, custom.height, custom.diagonalInches).toFixed(0)} PPI` : "Enter a diagonal to compute PPI" })
|
|
13420
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "readout", children: custom.diagonalInches > 0 ? `${ppi(custom.width, custom.height, custom.diagonalInches).toFixed(0)} PPI` : "Enter a diagonal to compute PPI" }),
|
|
13421
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Updates" }),
|
|
13422
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-block", children: [
|
|
13423
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
|
|
13424
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Version" }),
|
|
13425
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "version-current num", children: update?.current ?? "—" })
|
|
13426
|
+
] }),
|
|
13427
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
|
|
13428
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Latest" }),
|
|
13429
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "version-latest", children: [
|
|
13430
|
+
update === null && "Not checked yet",
|
|
13431
|
+
update?.status === "current" && update.checkedAt === 0 && "Not checked yet",
|
|
13432
|
+
update?.status === "current" && update.checkedAt > 0 && "Up to date",
|
|
13433
|
+
update?.status === "error" && "Couldn’t check",
|
|
13434
|
+
update?.status === "available" && update.latest !== void 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
|
|
13435
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "num", children: update.latest }),
|
|
13436
|
+
" · ",
|
|
13437
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "link", onClick: () => void window.obsrv.openRelease(), children: "Download" })
|
|
13438
|
+
] })
|
|
13439
|
+
] })
|
|
13440
|
+
] }),
|
|
13441
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
|
|
13442
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Last checked" }),
|
|
13443
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "version-checked num", children: update === null ? "never" : formatAge(update.checkedAt, Date.now()) })
|
|
13444
|
+
] })
|
|
13445
|
+
] }),
|
|
13446
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline update-check-toggle", children: [
|
|
13447
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
13448
|
+
"input",
|
|
13449
|
+
{
|
|
13450
|
+
type: "checkbox",
|
|
13451
|
+
checked: settings.updateCheck,
|
|
13452
|
+
onChange: (e) => commit({ ...settings, updateCheck: e.target.checked })
|
|
13453
|
+
}
|
|
13454
|
+
),
|
|
13455
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Check for updates automatically" })
|
|
13456
|
+
] }),
|
|
13457
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
|
|
13458
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." })
|
|
13400
13459
|
] });
|
|
13401
13460
|
}
|
|
13402
13461
|
const VERT_SRC = `#version 300 es
|
|
@@ -14237,6 +14296,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14237
14296
|
const setPixelExact = useStore((s) => s.setPixelExact);
|
|
14238
14297
|
const setError = useStore((s) => s.setError);
|
|
14239
14298
|
const surround = useStore((s) => s.surround);
|
|
14299
|
+
const update = useStore((s) => s.update);
|
|
14240
14300
|
const setSurround = useStore((s) => s.setSurround);
|
|
14241
14301
|
const viewMode = useStore((s) => s.viewMode);
|
|
14242
14302
|
const setViewMode = useStore((s) => s.setViewMode);
|
|
@@ -14367,6 +14427,20 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14367
14427
|
),
|
|
14368
14428
|
"Pixel-exact"
|
|
14369
14429
|
] }),
|
|
14430
|
+
update?.status === "available" && update.latest !== void 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs(
|
|
14431
|
+
"button",
|
|
14432
|
+
{
|
|
14433
|
+
className: "update-button",
|
|
14434
|
+
type: "button",
|
|
14435
|
+
title: `Obsrv ${update.latest} is available — opens the download page`,
|
|
14436
|
+
onClick: () => void window.obsrv.openRelease(),
|
|
14437
|
+
children: [
|
|
14438
|
+
"v",
|
|
14439
|
+
update.latest,
|
|
14440
|
+
" ↓"
|
|
14441
|
+
]
|
|
14442
|
+
}
|
|
14443
|
+
),
|
|
14370
14444
|
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "surround-control", role: "group", "aria-label": "Pane surround", children: SURROUNDS.map((s) => /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14371
14445
|
"button",
|
|
14372
14446
|
{
|
|
@@ -14425,12 +14499,14 @@ function App() {
|
|
|
14425
14499
|
const dropToken = reactExports.useRef(0);
|
|
14426
14500
|
const targetPaneRef = reactExports.useRef(null);
|
|
14427
14501
|
const [targetBounds, setTargetBounds] = reactExports.useState(null);
|
|
14502
|
+
const [canvasBounds, setCanvasBounds] = reactExports.useState(null);
|
|
14428
14503
|
const toggle = (which) => () => setDrawer((d) => d === which ? "none" : which);
|
|
14429
14504
|
const setHost = useStore((s) => s.setHost);
|
|
14430
14505
|
const setSettings = useStore((s) => s.setSettings);
|
|
14431
14506
|
const setUrl = useStore((s) => s.setUrl);
|
|
14432
14507
|
const setError = useStore((s) => s.setError);
|
|
14433
14508
|
const setTargetLoading = useStore((s) => s.setTargetLoading);
|
|
14509
|
+
const setUpdate = useStore((s) => s.setUpdate);
|
|
14434
14510
|
const setImageMeta = useStore((s) => s.setImage);
|
|
14435
14511
|
const setMode = useStore((s) => s.setMode);
|
|
14436
14512
|
const setToast = useStore((s) => s.setToast);
|
|
@@ -14444,6 +14520,7 @@ function App() {
|
|
|
14444
14520
|
reactExports.useEffect(() => {
|
|
14445
14521
|
window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
|
|
14446
14522
|
window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
|
|
14523
|
+
window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
|
|
14447
14524
|
const offs = [
|
|
14448
14525
|
window.obsrv.onHostChanged(setHost),
|
|
14449
14526
|
// A committed navigation — back, forward, reload, a link — supersedes
|
|
@@ -14454,12 +14531,13 @@ function App() {
|
|
|
14454
14531
|
setUrl(url);
|
|
14455
14532
|
}),
|
|
14456
14533
|
window.obsrv.onLoadError(setError),
|
|
14457
|
-
window.obsrv.onTargetLoading(setTargetLoading)
|
|
14534
|
+
window.obsrv.onTargetLoading(setTargetLoading),
|
|
14535
|
+
window.obsrv.onUpdateStatus(setUpdate)
|
|
14458
14536
|
];
|
|
14459
14537
|
return () => {
|
|
14460
14538
|
for (const off of offs) off();
|
|
14461
14539
|
};
|
|
14462
|
-
}, [setHost, setSettings, setUrl, setError, setTargetLoading]);
|
|
14540
|
+
}, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate]);
|
|
14463
14541
|
reactExports.useEffect(() => {
|
|
14464
14542
|
void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
|
|
14465
14543
|
}, [viewport.width, viewport.height, deviceScaleFactor]);
|
|
@@ -14470,17 +14548,34 @@ function App() {
|
|
|
14470
14548
|
const el = targetPaneRef.current;
|
|
14471
14549
|
if (!el) return;
|
|
14472
14550
|
const measure = () => {
|
|
14473
|
-
const
|
|
14474
|
-
setTargetBounds({ x:
|
|
14551
|
+
const pane = el.getBoundingClientRect();
|
|
14552
|
+
setTargetBounds({ x: pane.x, y: pane.y, width: pane.width, height: pane.height });
|
|
14553
|
+
const canvas2 = el.querySelector("canvas");
|
|
14554
|
+
if (!canvas2) {
|
|
14555
|
+
setCanvasBounds(null);
|
|
14556
|
+
return;
|
|
14557
|
+
}
|
|
14558
|
+
const c = canvas2.getBoundingClientRect();
|
|
14559
|
+
const x = Math.max(pane.x, c.x);
|
|
14560
|
+
const y = Math.max(pane.y, c.y);
|
|
14561
|
+
const right = Math.min(pane.x + pane.width, c.x + c.width);
|
|
14562
|
+
const bottom = Math.min(pane.y + pane.height, c.y + c.height);
|
|
14563
|
+
setCanvasBounds(right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null);
|
|
14475
14564
|
};
|
|
14476
14565
|
const ro = new ResizeObserver(measure);
|
|
14477
14566
|
ro.observe(el);
|
|
14567
|
+
const canvas = el.querySelector("canvas");
|
|
14568
|
+
if (canvas) ro.observe(canvas);
|
|
14569
|
+
el.addEventListener("scroll", measure, { passive: true });
|
|
14478
14570
|
measure();
|
|
14479
|
-
return () =>
|
|
14571
|
+
return () => {
|
|
14572
|
+
ro.disconnect();
|
|
14573
|
+
el.removeEventListener("scroll", measure);
|
|
14574
|
+
};
|
|
14480
14575
|
}, []);
|
|
14481
14576
|
reactExports.useEffect(() => {
|
|
14482
|
-
window.obsrv.reportUiState({ presetId, profileId, viewMode, mode, targetBounds });
|
|
14483
|
-
}, [presetId, profileId, viewMode, mode, targetBounds]);
|
|
14577
|
+
window.obsrv.reportUiState({ presetId, profileId, viewMode, mode, targetBounds, canvasBounds });
|
|
14578
|
+
}, [presetId, profileId, viewMode, mode, targetBounds, canvasBounds]);
|
|
14484
14579
|
reactExports.useEffect(() => {
|
|
14485
14580
|
return window.obsrv.onAgentApply((patch) => {
|
|
14486
14581
|
const s = useStore.getState();
|
package/out/renderer/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:" />
|
|
6
6
|
<title>Obsrv</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-Cgx8iixu.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BeroS4wv.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -99,9 +99,10 @@ function parseDeviceScaleFactor(raw) {
|
|
|
99
99
|
return raw;
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* Copies exactly the
|
|
103
|
-
* positive. A missing `agentControl` means false
|
|
104
|
-
*
|
|
102
|
+
* Copies exactly the five known keys; the numbers must be finite and
|
|
103
|
+
* positive. A missing `agentControl` means false and a missing `updateCheck`
|
|
104
|
+
* means true (the pre-feature wire shapes); any non-boolean value is refused,
|
|
105
|
+
* never coerced.
|
|
105
106
|
*/
|
|
106
107
|
function parseSettings(raw) {
|
|
107
108
|
if (!isRecord(raw))
|
|
@@ -114,7 +115,13 @@ function parseSettings(raw) {
|
|
|
114
115
|
const agentControl = raw.agentControl ?? false;
|
|
115
116
|
if (typeof agentControl !== 'boolean')
|
|
116
117
|
return null;
|
|
117
|
-
|
|
118
|
+
const updateCheck = raw.updateCheck ?? true;
|
|
119
|
+
if (typeof updateCheck !== 'boolean')
|
|
120
|
+
return null;
|
|
121
|
+
const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
|
|
122
|
+
if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0)
|
|
123
|
+
return null;
|
|
124
|
+
return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
|
|
118
125
|
}
|
|
119
126
|
function parseMode(raw) {
|
|
120
127
|
return raw === 'url' || raw === 'image' ? raw : null;
|
|
@@ -146,7 +153,14 @@ function parseUiState(raw) {
|
|
|
146
153
|
return null;
|
|
147
154
|
if (mode !== 'url' && mode !== 'image')
|
|
148
155
|
return null;
|
|
149
|
-
return {
|
|
156
|
+
return {
|
|
157
|
+
presetId,
|
|
158
|
+
profileId,
|
|
159
|
+
viewMode,
|
|
160
|
+
mode,
|
|
161
|
+
targetBounds: parseRect(raw.targetBounds),
|
|
162
|
+
canvasBounds: parseRect(raw.canvasBounds),
|
|
163
|
+
};
|
|
150
164
|
}
|
|
151
165
|
/**
|
|
152
166
|
* A scroll offset reported by the sync preload in a page webContents. Both
|
package/out/shared/presets.js
CHANGED
|
@@ -4,7 +4,13 @@ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exp
|
|
|
4
4
|
exports.findPreset = findPreset;
|
|
5
5
|
exports.findProfile = findProfile;
|
|
6
6
|
exports.MAX_VIEWPORT = 4096;
|
|
7
|
-
exports.DEFAULT_SETTINGS = {
|
|
7
|
+
exports.DEFAULT_SETTINGS = {
|
|
8
|
+
hostDiagonalInches: 27,
|
|
9
|
+
hostNits: 500,
|
|
10
|
+
agentControl: false,
|
|
11
|
+
updateCheck: true,
|
|
12
|
+
lastUpdateCheck: 0,
|
|
13
|
+
};
|
|
8
14
|
exports.SCREEN_PRESETS = [
|
|
9
15
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
10
16
|
{ id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
|
package/package.json
CHANGED
|
@@ -54,6 +54,13 @@ comes back inline. If the Obsrv desktop app is open with "Agent control" on
|
|
|
54
54
|
click, pan and highlight to walk the user through what it found; no app
|
|
55
55
|
means the usual headless render.
|
|
56
56
|
|
|
57
|
+
To see anything below the fold, scroll and capture in the **same**
|
|
58
|
+
`obsrv_drive` call — `{ scroll: { x: 0, y: 1500 }, capture: 'pane' }`. That
|
|
59
|
+
tool never navigates unless you pass `url`, so the scroll is still in place
|
|
60
|
+
when the PNG is taken. Reaching for `obsrv_snap` after a scroll works only
|
|
61
|
+
when the app is already on that exact URL (it answers `navigated: false`);
|
|
62
|
+
snapping a different URL is a fresh load and lands back at the top.
|
|
63
|
+
|
|
57
64
|
## The loop that catches real regressions
|
|
58
65
|
|
|
59
66
|
1. Snap the dev URL across `--matrix laptop-768,android-65,1080p-24`, plus a
|
|
@@ -80,6 +87,9 @@ happened on the matrix snaps.
|
|
|
80
87
|
not colorimetry of one specific panel.
|
|
81
88
|
- `diff` is 1x-only in v1: dsf>1 presets and CSS viewports over 2048px exit
|
|
82
89
|
with an error. Its findings are informational — apply your own thresholds.
|
|
90
|
+
- `diff` on an animating page compares two different frames. Check `settled`
|
|
91
|
+
in its output: when false the band deltas are frame-to-frame noise and
|
|
92
|
+
`findings` says so rather than interpreting them. Snap that page instead.
|
|
83
93
|
- `diff` cannot say "the hairline vanished": a 0.5px hairline renders one
|
|
84
94
|
device row at 1x *and* 2x. It reports ink deltas and row ratios; vanishing
|
|
85
95
|
is judged by reading the PNG.
|