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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/obsrv-mcp.js +19 -0
- package/bin/obsrv.js +73 -0
- package/out/cli/args.js +204 -0
- package/out/main/cli.js +600 -0
- package/out/main/index.js +539 -0
- package/out/main/targetSource-w_vWw7zd.js +351 -0
- package/out/mcp/lib.js +175 -0
- package/out/mcp/server.js +301 -0
- package/out/preload/app.js +87 -0
- package/out/preload/sync.js +41 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- package/out/renderer/assets/index-BAEO9_6W.js +14460 -0
- package/out/renderer/assets/index-VleEBVgL.css +446 -0
- package/out/renderer/index.html +13 -0
- package/out/shared/calibration.js +41 -0
- package/out/shared/presets.js +47 -0
- package/out/shared/types.js +2 -0
- package/package.json +62 -0
- 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
|
+
}
|