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 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. With no app running, everything falls back to the headless
88
+ as a guided demo. A `scroll` reports the offset it actually reached
89
+ (`scrolled` / `scroller`), finds the inner scroll container on pages whose
90
+ root cannot scroll, and takes a `scrollSelector` when you need to name the
91
+ container yourself. With no app running, everything falls back to the headless
89
92
  render automatically.
90
93
 
94
+ 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); nested scroll containers aren't mirrored.
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-DkXWE0ha.js");
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 metrics = diffMetrics(target, reference, referenceDeviceRows);
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-DkXWE0ha.js");
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
- return { hostDiagonalInches, hostNits, agentControl };
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 pos = parseScrollPos(payload);
378
- if (!pos) return reply(400, { error: "scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets" });
379
- this.deps.scroll(pos);
380
- return reply(200, { ok: true });
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: (pos) => {
657
- for (const wc of [native.webContents, target.webContents]) {
658
- if (!wc.isDestroyed()) wc.send(IPC.applyScroll, pos);
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 = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
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.x === 0 && dirty.y === 0 && dirty.width === full.width && dirty.height === full.height;
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.