getobsrv 0.3.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/bin/obsrv-mcp.js +19 -0
  4. package/bin/obsrv.js +73 -0
  5. package/out/cli/args.js +204 -0
  6. package/out/main/cli.js +600 -0
  7. package/out/main/index.js +539 -0
  8. package/out/main/targetSource-w_vWw7zd.js +351 -0
  9. package/out/mcp/lib.js +175 -0
  10. package/out/mcp/server.js +301 -0
  11. package/out/preload/app.js +87 -0
  12. package/out/preload/sync.js +41 -0
  13. package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  14. package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  15. package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  16. package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  17. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  18. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  19. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  20. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  21. package/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  22. package/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  23. package/out/renderer/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  24. package/out/renderer/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  25. package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  26. package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  27. package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  28. package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  29. package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  30. package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  31. package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  32. package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  33. package/out/renderer/assets/index-BAEO9_6W.js +14460 -0
  34. package/out/renderer/assets/index-VleEBVgL.css +446 -0
  35. package/out/renderer/index.html +13 -0
  36. package/out/shared/calibration.js +41 -0
  37. package/out/shared/presets.js +47 -0
  38. package/out/shared/types.js +2 -0
  39. package/package.json +62 -0
  40. package/skills/obsrv-screens/SKILL.md +80 -0
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ const electron = require("electron");
3
+ const node_events = require("node:events");
4
+ const node_path = require("node:path");
5
+ const MAX_VIEWPORT = 4096;
6
+ const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
7
+ const SCREEN_PRESETS = [
8
+ // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
9
+ { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
10
+ { id: "laptop-768-14", label: '1366×768 14"', width: 1366, height: 768, diagonalInches: 14, deviceScaleFactor: 1, group: "laptop" },
11
+ { id: "laptop-768-11", label: '1366×768 11.6" (Chromebook)', width: 1366, height: 768, diagonalInches: 11.6, deviceScaleFactor: 1, group: "laptop" },
12
+ { id: "laptop-800-11", label: '1280×800 11.6" (Chromebook)', width: 1280, height: 800, diagonalInches: 11.6, deviceScaleFactor: 1, group: "laptop" },
13
+ { id: "laptop-900-17", label: '1600×900 17.3"', width: 1600, height: 900, diagonalInches: 17.3, deviceScaleFactor: 1, group: "laptop" },
14
+ { id: "laptop-1080-15", label: '1080p 15.6"', width: 1920, height: 1080, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
15
+ // Desktops.
16
+ { id: "1080p-24", label: '1080p 24"', width: 1920, height: 1080, diagonalInches: 24, deviceScaleFactor: 1, group: "desktop" },
17
+ { id: "1080p-27", label: '1080p 27"', width: 1920, height: 1080, diagonalInches: 27, deviceScaleFactor: 1, group: "desktop" },
18
+ { id: "1440p-27", label: '1440p 27"', width: 2560, height: 1440, diagonalInches: 27, deviceScaleFactor: 1, group: "desktop" },
19
+ { id: "sxga-19", label: '1280×1024 19" (5:4)', width: 1280, height: 1024, diagonalInches: 19, deviceScaleFactor: 1, group: "desktop" },
20
+ { id: "1440x900-19", label: '1440×900 19"', width: 1440, height: 900, diagonalInches: 19, deviceScaleFactor: 1, group: "desktop" },
21
+ // Mobiles — CSS viewport x the device's real scale factor. A phone preset
22
+ // rendered at 1x would look worse than any real phone; these rasterise at
23
+ // 2x/3x and are shown at true physical size (usually minified on a desktop).
24
+ { id: "android-65", label: 'Budget Android 6.5" @2x', width: 360, height: 800, diagonalInches: 6.5, deviceScaleFactor: 2, group: "mobile" },
25
+ { id: "iphone-se", label: 'iPhone SE 4.7" @2x', width: 375, height: 667, diagonalInches: 4.7, deviceScaleFactor: 2, group: "mobile" },
26
+ { id: "iphone-61", label: 'iPhone 6.1" @3x', width: 393, height: 852, diagonalInches: 6.1, deviceScaleFactor: 3, group: "mobile" },
27
+ { id: "ipad-109", label: 'iPad 10.9" @2x', width: 820, height: 1180, diagonalInches: 10.9, deviceScaleFactor: 2, group: "mobile" }
28
+ ];
29
+ const PANEL_PROFILES = [
30
+ { id: "reference", label: "Reference (off)", contrastRatio: null, gamutCoverage: 1, bits: 8, frc: false, nits: null },
31
+ { id: "office-ips", label: "Office IPS", contrastRatio: 1e3, gamutCoverage: 1, bits: 8, frc: false, nits: 300 },
32
+ { id: "budget-tn", label: "Budget TN", contrastRatio: 700, gamutCoverage: 0.72, bits: 6, frc: true, nits: 250 },
33
+ { id: "old-laptop", label: "Old laptop", contrastRatio: 600, gamutCoverage: 0.6, bits: 6, frc: false, nits: 220 }
34
+ ];
35
+ function findPreset(id) {
36
+ const p = SCREEN_PRESETS.find((x) => x.id === id);
37
+ if (!p) throw new Error(`unknown preset: ${id}`);
38
+ return p;
39
+ }
40
+ function findProfile(id) {
41
+ const p = PANEL_PROFILES.find((x) => x.id === id);
42
+ if (!p) throw new Error(`unknown profile: ${id}`);
43
+ return p;
44
+ }
45
+ function maxCssViewport(deviceScaleFactor) {
46
+ const dsf = Number.isFinite(deviceScaleFactor) && deviceScaleFactor > 1 ? deviceScaleFactor : 1;
47
+ return Math.max(1, Math.floor(MAX_VIEWPORT / dsf));
48
+ }
49
+ function clampViewport(width, height, max = MAX_VIEWPORT) {
50
+ const finite = (v) => Number.isFinite(v) ? v : 1;
51
+ const w = Math.min(max, Math.max(1, Math.floor(finite(width))));
52
+ const h = Math.min(max, Math.max(1, Math.floor(finite(height))));
53
+ return { width: w, height: h, clamped: w !== width || h !== height };
54
+ }
55
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g)$/i;
56
+ function classifyFileNavigation(from, to) {
57
+ if (!to.startsWith("file:")) return "allow";
58
+ const path = to.split(/[?#]/, 1)[0] ?? "";
59
+ if (IMAGE_EXTENSIONS.test(path)) return "image";
60
+ return from.startsWith("file:") ? "allow" : "block";
61
+ }
62
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
63
+ const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i;
64
+ function normalizeUrl(input) {
65
+ const s = input.trim();
66
+ if (s === "") throw new Error("empty url");
67
+ if (/\s/.test(s)) throw new Error("invalid URL");
68
+ if (s.startsWith("/")) return `file://${s}`;
69
+ if (LOOPBACK.test(s)) return `http://${s}`;
70
+ if (SCHEME.test(s)) return s;
71
+ return `https://${s}`;
72
+ }
73
+ const ERR_ABORTED = -3;
74
+ const DEFAULT_FPS = 30;
75
+ const DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
76
+ const MOBILE_USER_AGENT = `Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${process.versions.chrome ?? "120.0.0.0"} Mobile Safari/537.36`;
77
+ class TargetSource extends node_events.EventEmitter {
78
+ win;
79
+ viewport = { ...DEFAULT_VIEWPORT };
80
+ dsf = 1;
81
+ fps;
82
+ /** See TargetSourceOptions.mobileEmulation. */
83
+ mobileEmulation;
84
+ /** Electron's own UA, captured from the first window and restored for dsf 1. */
85
+ defaultUserAgent = null;
86
+ /**
87
+ * Settles once the current window's first navigation has committed — or can
88
+ * never commit. Chromium's offscreen renderer segfaults (exit 11, Electron
89
+ * 43 / macOS) when a second `loadURL` interrupts the very first one before
90
+ * it commits; interrupting any later navigation is an ordinary
91
+ * `ERR_ABORTED`. Every `load()` waits on this so the unit is safe to drive
92
+ * the instant it is constructed — and re-waits if a recreation swapped in a
93
+ * fresh window (with a fresh first navigation) mid-wait. It also settles if
94
+ * the renderer dies or the surface is destroyed first, so a later `load()`
95
+ * never hangs on a gate that cannot open.
96
+ */
97
+ firstNavigation;
98
+ /** True once the current window's first-navigation gate has settled. */
99
+ firstNavDone = false;
100
+ /**
101
+ * The URL the target is meant to be showing: set the moment `load()`
102
+ * accepts a navigation and updated by every committed non-internal
103
+ * main-frame navigation. Recreation restores from *this*, never from the
104
+ * dying window's `getURL()` — mid-recreation that reads the replacement's
105
+ * `about:blank` and a second density change would silently drop the real
106
+ * page; likewise an in-flight `load()` whose window is destroyed under it
107
+ * still gets its URL restored, because it was recorded before the gate.
108
+ */
109
+ intendedUrl = null;
110
+ /**
111
+ * True while a recreated window loads its internal `about:blank`. Those
112
+ * navigation events are plumbing, not news: reported, SyncBus would mirror
113
+ * `about:blank` into the native pane every time the preset changes density.
114
+ * Frames still flow — a blank paint is stale for a moment, never wrong.
115
+ */
116
+ internal = false;
117
+ disposed = false;
118
+ constructor(fps = DEFAULT_FPS, options = {}) {
119
+ super();
120
+ this.fps = fps;
121
+ this.mobileEmulation = options.mobileEmulation ?? true;
122
+ this.createWindow();
123
+ }
124
+ createWindow() {
125
+ const win = new electron.BrowserWindow({
126
+ show: false,
127
+ width: this.viewport.width,
128
+ height: this.viewport.height,
129
+ useContentSize: true,
130
+ // macOS refuses window sizes larger than the display without this.
131
+ enableLargerThanScreen: true,
132
+ webPreferences: {
133
+ preload: node_path.join(__dirname, "../preload/sync.js"),
134
+ offscreen: { deviceScaleFactor: this.dsf },
135
+ contextIsolation: true,
136
+ sandbox: true,
137
+ nodeIntegration: false,
138
+ backgroundThrottling: false
139
+ }
140
+ });
141
+ this.win = win;
142
+ this.firstNavDone = false;
143
+ const wc = win.webContents;
144
+ wc.setFrameRate(this.fps);
145
+ wc.setAudioMuted(true);
146
+ this.defaultUserAgent ??= wc.getUserAgent();
147
+ wc.setUserAgent(this.dsf > 1 && this.mobileEmulation ? MOBILE_USER_AGENT : this.defaultUserAgent);
148
+ wc.on("paint", (_event, dirty, image) => {
149
+ if (dirty.width <= 0 || dirty.height <= 0) return;
150
+ if (image.isEmpty()) return;
151
+ const full = image.getSize();
152
+ const isFull = dirty.x === 0 && dirty.y === 0 && dirty.width === full.width && dirty.height === full.height;
153
+ this.emit("frame", {
154
+ frame: {
155
+ x: dirty.x,
156
+ y: dirty.y,
157
+ width: dirty.width,
158
+ height: dirty.height,
159
+ // `toBitmap()` already returns a fresh copy of the pixels (unlike the
160
+ // deprecated `getBitmap()`, typed `void` in Electron 43), and a
161
+ // Buffer is a Uint8Array, so this is the only copy of the slice.
162
+ data: (isFull ? image : image.crop(dirty)).toBitmap()
163
+ },
164
+ frameWidth: full.width,
165
+ frameHeight: full.height
166
+ });
167
+ });
168
+ wc.on("did-navigate", (_e, url) => {
169
+ this.applyEmulation();
170
+ if (this.internal) return;
171
+ this.intendedUrl = url;
172
+ this.emit("url-changed", url);
173
+ });
174
+ wc.on("did-navigate-in-page", (_e, url, isMainFrame) => {
175
+ if (isMainFrame && !this.internal) {
176
+ this.intendedUrl = url;
177
+ this.emit("url-changed", url);
178
+ }
179
+ });
180
+ wc.on("did-fail-load", (_e, code, description, url, isMainFrame) => {
181
+ if (isMainFrame && code !== ERR_ABORTED && !this.internal) {
182
+ this.emit("load-error", { code, description, url });
183
+ }
184
+ });
185
+ wc.on("did-start-loading", () => {
186
+ if (!this.internal) this.emit("loading", true);
187
+ });
188
+ wc.on("did-stop-loading", () => {
189
+ if (!this.internal) this.emit("loading", false);
190
+ });
191
+ wc.on("did-start-navigation", (details) => {
192
+ if (!this.internal && details.isMainFrame && !details.isSameDocument) this.emit("navigating");
193
+ });
194
+ wc.on("render-process-gone", (_e, details) => {
195
+ if (details.reason === "clean-exit") return;
196
+ this.emit("load-error", {
197
+ code: details.exitCode,
198
+ description: `renderer crashed: ${details.reason}`,
199
+ url: wc.getURL()
200
+ });
201
+ });
202
+ wc.on("will-navigate", (e, url) => {
203
+ if (classifyFileNavigation(wc.getURL(), url) !== "allow") e.preventDefault();
204
+ });
205
+ wc.setWindowOpenHandler(({ url }) => {
206
+ if (url && url !== "about:blank") void this.load(url);
207
+ return { action: "deny" };
208
+ });
209
+ const gate = new Promise((settle) => {
210
+ wc.once("render-process-gone", () => settle());
211
+ wc.once("destroyed", () => settle());
212
+ wc.loadURL("about:blank").then(() => settle(), () => settle());
213
+ });
214
+ this.firstNavigation = gate.then(() => {
215
+ if (this.win === win) this.firstNavDone = true;
216
+ });
217
+ }
218
+ /**
219
+ * Mobile viewport semantics for dsf > 1 (see class doc). Post-commit only:
220
+ * enabling emulation before a window's first navigation commits segfaults
221
+ * the OSR renderer, so callers are either the `did-navigate` handler (by
222
+ * definition post-commit) or gated on `firstNavDone`.
223
+ */
224
+ applyEmulation() {
225
+ if (this.dsf <= 1 || !this.mobileEmulation || this.win.isDestroyed()) return;
226
+ this.win.webContents.enableDeviceEmulation({
227
+ screenPosition: "mobile",
228
+ screenSize: { width: this.viewport.width, height: this.viewport.height },
229
+ viewPosition: { x: 0, y: 0 },
230
+ viewSize: { width: this.viewport.width, height: this.viewport.height },
231
+ deviceScaleFactor: this.dsf,
232
+ scale: 1
233
+ });
234
+ }
235
+ /**
236
+ * Swaps in a fresh window at the current viewport and dsf, then restores
237
+ * the page the old one was showing. Created before the old is destroyed —
238
+ * see the class doc for why that order is load-bearing.
239
+ */
240
+ recreate() {
241
+ const old = this.win;
242
+ this.internal = true;
243
+ this.createWindow();
244
+ if (!old.isDestroyed()) old.destroy();
245
+ const win = this.win;
246
+ void this.firstNavigation.then(async () => {
247
+ if (this.disposed || this.win !== win || win.isDestroyed()) return;
248
+ this.internal = false;
249
+ const url = this.intendedUrl;
250
+ if (url && url !== "about:blank") {
251
+ try {
252
+ await win.webContents.loadURL(url);
253
+ } catch {
254
+ }
255
+ } else {
256
+ win.webContents.invalidate();
257
+ }
258
+ });
259
+ }
260
+ /** Loads URL-bar input; returns the normalised URL that was requested. */
261
+ async load(input) {
262
+ try {
263
+ const url = normalizeUrl(input);
264
+ this.intendedUrl = url;
265
+ let gate;
266
+ do {
267
+ gate = this.firstNavigation;
268
+ await gate;
269
+ } while (gate !== this.firstNavigation);
270
+ if (this.win.isDestroyed()) return url;
271
+ try {
272
+ await this.win.webContents.loadURL(url);
273
+ } catch {
274
+ }
275
+ return url;
276
+ } catch (e) {
277
+ const description = e instanceof Error ? e.message : String(e);
278
+ this.emit("load-error", { code: 0, description, url: input });
279
+ return input;
280
+ }
281
+ }
282
+ /**
283
+ * Resizes the offscreen surface; `width`/`height` are CSS pixels and the
284
+ * clamp budget is device pixels (`maxCssViewport`). A changed
285
+ * `deviceScaleFactor` recreates the window (the offscreen dsf is fixed at
286
+ * creation); a same-dsf resize is the cheap `setContentSize` path. Returns
287
+ * the applied (possibly clamped) CSS size.
288
+ */
289
+ setViewport(width, height, deviceScaleFactor = 1) {
290
+ const dsf = Number.isFinite(deviceScaleFactor) && deviceScaleFactor >= 1 ? deviceScaleFactor : 1;
291
+ const v = clampViewport(width, height, maxCssViewport(dsf));
292
+ this.viewport = { width: v.width, height: v.height };
293
+ if (dsf !== this.dsf) {
294
+ this.dsf = dsf;
295
+ if (!this.disposed) this.recreate();
296
+ } else if (!this.win.isDestroyed()) {
297
+ this.win.setContentSize(v.width, v.height);
298
+ if (this.firstNavDone) this.applyEmulation();
299
+ this.win.webContents.invalidate();
300
+ }
301
+ return v;
302
+ }
303
+ /**
304
+ * Changes the raster density alone, keeping the current CSS viewport
305
+ * (re-clamped for the new device-pixel budget). Same recreation path as
306
+ * `setViewport` with a new factor.
307
+ */
308
+ setDeviceScaleFactor(deviceScaleFactor) {
309
+ return this.setViewport(this.viewport.width, this.viewport.height, deviceScaleFactor);
310
+ }
311
+ getViewport() {
312
+ return { ...this.viewport };
313
+ }
314
+ getDeviceScaleFactor() {
315
+ return this.dsf;
316
+ }
317
+ /** Forces a full-frame repaint, e.g. after the renderer loses its texture. */
318
+ invalidate() {
319
+ if (!this.win.isDestroyed()) this.win.webContents.invalidate();
320
+ }
321
+ sendInput(ev) {
322
+ if (!this.win.isDestroyed()) this.win.webContents.sendInputEvent(ev);
323
+ }
324
+ reload() {
325
+ if (!this.win.isDestroyed()) this.win.webContents.reload();
326
+ }
327
+ back() {
328
+ if (!this.win.isDestroyed()) this.win.webContents.navigationHistory.goBack();
329
+ }
330
+ forward() {
331
+ if (!this.win.isDestroyed()) this.win.webContents.navigationHistory.goForward();
332
+ }
333
+ get webContents() {
334
+ return this.win.webContents;
335
+ }
336
+ destroy() {
337
+ this.disposed = true;
338
+ this.removeAllListeners();
339
+ if (!this.win.isDestroyed()) this.win.destroy();
340
+ }
341
+ }
342
+ exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
343
+ exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
344
+ exports.PANEL_PROFILES = PANEL_PROFILES;
345
+ exports.SCREEN_PRESETS = SCREEN_PRESETS;
346
+ exports.TargetSource = TargetSource;
347
+ exports.classifyFileNavigation = classifyFileNavigation;
348
+ exports.findPreset = findPreset;
349
+ exports.findProfile = findProfile;
350
+ exports.maxCssViewport = maxCssViewport;
351
+ exports.normalizeUrl = normalizeUrl;
package/out/mcp/lib.js ADDED
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
4
+ exports.buildSnapArgs = buildSnapArgs;
5
+ exports.buildDiffArgs = buildDiffArgs;
6
+ exports.shouldInlineImage = shouldInlineImage;
7
+ exports.killBudgetMs = killBudgetMs;
8
+ exports.stderrTail = stderrTail;
9
+ exports.urlSchemeError = urlSchemeError;
10
+ exports.extractTrailingJson = extractTrailingJson;
11
+ exports.listCatalog = listCatalog;
12
+ const presets_1 = require("../shared/presets");
13
+ /**
14
+ * Pure helpers for the MCP server (`src/mcp/server.ts`): tool-input → CLI
15
+ * argv mapping, inline-image size gating, and the presets/profiles catalog.
16
+ * No SDK, no child processes, no I/O — everything here runs under plain node
17
+ * and is unit-tested in tests/unit/mcpLib.test.ts.
18
+ */
19
+ /** A caller mistake (bad flag combination), as opposed to a render failure. */
20
+ class UsageError extends Error {
21
+ }
22
+ exports.UsageError = UsageError;
23
+ /** Inline-image budget: 1.5 MiB of PNG before we fall back to the file path. */
24
+ exports.MAX_INLINE_IMAGE_BYTES = 1_572_864;
25
+ /** How much CLI stderr a tool error carries back to the model. */
26
+ exports.STDERR_TAIL_CHARS = 2000;
27
+ /**
28
+ * Maps `obsrv_snap` input to `bin/obsrv.js` argv. Enforces the preset-XOR-
29
+ * custom-dims rule up front so the model gets one actionable message instead
30
+ * of the CLI's exit-2 round trip.
31
+ */
32
+ function buildSnapArgs(input, outPath) {
33
+ const custom = input.width !== undefined ||
34
+ input.height !== undefined ||
35
+ input.deviceScaleFactor !== undefined ||
36
+ input.diagonalInches !== undefined;
37
+ if (input.preset !== undefined && custom) {
38
+ throw new UsageError('`preset` and custom dimensions are mutually exclusive — pass either `preset`, ' +
39
+ 'or `width` + `height` (with optional `deviceScaleFactor` / `diagonalInches`). ' +
40
+ 'Use obsrv_presets to list the preset ids.');
41
+ }
42
+ if (custom && (input.width === undefined || input.height === undefined)) {
43
+ throw new UsageError('custom dimensions need both `width` and `height` (`deviceScaleFactor` and ' +
44
+ '`diagonalInches` only refine them) — or pass `preset` instead.');
45
+ }
46
+ const args = ['snap', input.url];
47
+ if (input.preset !== undefined)
48
+ args.push('--preset', input.preset);
49
+ if (custom) {
50
+ args.push('--width', String(input.width), '--height', String(input.height));
51
+ if (input.deviceScaleFactor !== undefined)
52
+ args.push('--dsf', String(input.deviceScaleFactor));
53
+ if (input.diagonalInches !== undefined)
54
+ args.push('--diagonal', String(input.diagonalInches));
55
+ }
56
+ if (input.profile !== undefined)
57
+ args.push('--profile', input.profile);
58
+ if (input.fullPage)
59
+ args.push('--full-page');
60
+ if (input.waitMs !== undefined)
61
+ args.push('--wait', String(input.waitMs));
62
+ if (input.timeoutMs !== undefined)
63
+ args.push('--timeout', String(input.timeoutMs));
64
+ args.push('--out', outPath);
65
+ return args;
66
+ }
67
+ /** Maps `obsrv_diff` input to CLI argv; target/reference PNGs always land in `outDir`. */
68
+ function buildDiffArgs(input, outDir) {
69
+ const args = ['diff', input.url];
70
+ if (input.preset !== undefined)
71
+ args.push('--preset', input.preset);
72
+ if (input.profile !== undefined)
73
+ args.push('--profile', input.profile);
74
+ if (input.waitMs !== undefined)
75
+ args.push('--wait', String(input.waitMs));
76
+ if (input.timeoutMs !== undefined)
77
+ args.push('--timeout', String(input.timeoutMs));
78
+ args.push('--out-dir', outDir);
79
+ return args;
80
+ }
81
+ /** Whether a PNG of this size goes into the response as an inline image block. */
82
+ function shouldInlineImage(byteLength) {
83
+ return byteLength <= exports.MAX_INLINE_IMAGE_BYTES;
84
+ }
85
+ /**
86
+ * Outer kill budget for one CLI invocation: the CLI polices each render with
87
+ * its own --timeout, so the server only guards against a wedged Electron —
88
+ * (per-render budget + settle wait) × renders, plus boot/encode headroom.
89
+ * --wait counts per render (a diff waits in both the target and reference
90
+ * renders), so a healthy long-wait run is never killed mid-flight.
91
+ */
92
+ function killBudgetMs(renders, timeoutMs, waitMs = 0) {
93
+ return renders * (timeoutMs + waitMs) + 60_000;
94
+ }
95
+ /** The tail of the CLI's stderr, trimmed and capped for a tool-error message. */
96
+ function stderrTail(stderr, max = exports.STDERR_TAIL_CHARS) {
97
+ const trimmed = stderr.trim();
98
+ return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`;
99
+ }
100
+ /** Schemes a tool call may hand to the CLI. */
101
+ exports.ALLOWED_URL_SCHEMES = ['http:', 'https:', 'file:'];
102
+ /**
103
+ * Rejects URLs whose explicit scheme is outside the allowlist (javascript:,
104
+ * data:, chrome:, …) with an actionable message, or returns null when the URL
105
+ * may proceed. Scheme-relative (`//host`), bare-host (`example.com/page`) and
106
+ * host:port (`localhost:5173`) forms pass — they normalise to http(s)
107
+ * downstream.
108
+ */
109
+ function urlSchemeError(url) {
110
+ const trimmed = url.trim();
111
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
112
+ if (!match)
113
+ return null; // bare host or scheme-relative
114
+ const scheme = `${match[1].toLowerCase()}:`;
115
+ if (exports.ALLOWED_URL_SCHEMES.includes(scheme))
116
+ return null;
117
+ // `localhost:5173`-style host:port, not a scheme: the "scheme" is followed
118
+ // by a bare port number.
119
+ if (/^[a-z0-9.-]+:\d+(\/|$)/i.test(trimmed))
120
+ return null;
121
+ return (`unsupported URL scheme "${scheme}" — obsrv renders ` +
122
+ `${exports.ALLOWED_URL_SCHEMES.map(s => `${s}//`).join(', ')} URLs only ` +
123
+ `(bare hosts like example.com also work; they normalise to http(s)).`);
124
+ }
125
+ /**
126
+ * Parses the CLI's machine output: the trailing JSON object on stdout.
127
+ * Tolerant of stray runtime noise ahead of it (e.g. Chromium warnings that
128
+ * escape onto stdout) by scanning line-start `{` candidates until one parses
129
+ * to the end.
130
+ */
131
+ function extractTrailingJson(stdout) {
132
+ const text = stdout.trim();
133
+ for (let i = text.indexOf('{'); i >= 0; i = text.indexOf('{', i + 1)) {
134
+ if (i > 0 && text[i - 1] !== '\n')
135
+ continue;
136
+ try {
137
+ const parsed = JSON.parse(text.slice(i));
138
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
139
+ return parsed;
140
+ }
141
+ }
142
+ catch {
143
+ // Not JSON from here; keep scanning.
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ /** The `obsrv_presets` payload, straight from src/shared/presets.ts — no spawn. */
149
+ function listCatalog() {
150
+ return {
151
+ presets: presets_1.SCREEN_PRESETS.map(p => ({
152
+ id: p.id,
153
+ label: p.label,
154
+ group: p.group,
155
+ cssWidth: p.width,
156
+ cssHeight: p.height,
157
+ deviceScaleFactor: p.deviceScaleFactor,
158
+ diagonalInches: p.diagonalInches,
159
+ ppi: Math.round(Math.hypot(p.width * p.deviceScaleFactor, p.height * p.deviceScaleFactor) / p.diagonalInches),
160
+ })),
161
+ profiles: presets_1.PANEL_PROFILES.map(p => ({
162
+ id: p.id,
163
+ label: p.label,
164
+ contrastRatio: p.contrastRatio,
165
+ gamutCoverage: p.gamutCoverage,
166
+ bits: p.bits,
167
+ frc: p.frc,
168
+ nits: p.nits,
169
+ summary: p.contrastRatio === null
170
+ ? 'no panel simulation (pass-through)'
171
+ : `contrast ${p.contrastRatio}:1, ${Math.round(p.gamutCoverage * 100)}% sRGB, ` +
172
+ `${p.bits}-bit${p.frc ? '+FRC' : ''}, ${p.nits} nits`,
173
+ })),
174
+ };
175
+ }