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,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
4
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_os_1 = require("node:os");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const zod_1 = require("zod");
|
|
11
|
+
const args_1 = require("../cli/args");
|
|
12
|
+
const presets_1 = require("../shared/presets");
|
|
13
|
+
const lib_1 = require("./lib");
|
|
14
|
+
/**
|
|
15
|
+
* Obsrv MCP server (stdio, stateless): three read-only tools wrapping the
|
|
16
|
+
* headless CLI. Rendering stays in `bin/obsrv.js` — it already owns signals,
|
|
17
|
+
* per-run user-data isolation, temp-dir cleanup and crash fast-fail — the
|
|
18
|
+
* server only maps tool input to argv, spawns, and shapes the result.
|
|
19
|
+
* Launched by `bin/obsrv-mcp.js` from the build at `out/mcp/server.js`.
|
|
20
|
+
*/
|
|
21
|
+
const REPO_ROOT = (0, node_path_1.resolve)(__dirname, '..', '..');
|
|
22
|
+
const CLI_BIN = (0, node_path_1.join)(REPO_ROOT, 'bin', 'obsrv.js');
|
|
23
|
+
const VERSION = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(REPO_ROOT, 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '0.0.0';
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
/** Grace between SIGTERM and SIGKILL for a run that ignores the former. */
|
|
32
|
+
const SIGKILL_GRACE_MS = 10_000;
|
|
33
|
+
/**
|
|
34
|
+
* Spawns `node bin/obsrv.js <args>`; SIGTERMs a wedged run after
|
|
35
|
+
* `killAfterMs`, and SIGKILLs it if it still has not exited after
|
|
36
|
+
* SIGKILL_GRACE_MS more.
|
|
37
|
+
*/
|
|
38
|
+
function runCli(args, killAfterMs) {
|
|
39
|
+
return new Promise((done, fail) => {
|
|
40
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [CLI_BIN, ...args], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
41
|
+
let stdout = '';
|
|
42
|
+
let stderr = '';
|
|
43
|
+
let killed = false;
|
|
44
|
+
let killTimer;
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
killed = true;
|
|
47
|
+
child.kill('SIGTERM');
|
|
48
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS);
|
|
49
|
+
}, killAfterMs);
|
|
50
|
+
child.stdout.on('data', d => (stdout += String(d)));
|
|
51
|
+
child.stderr.on('data', d => (stderr += String(d)));
|
|
52
|
+
child.on('error', err => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
clearTimeout(killTimer);
|
|
55
|
+
fail(err);
|
|
56
|
+
});
|
|
57
|
+
child.on('close', code => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
clearTimeout(killTimer);
|
|
60
|
+
done({ code, stdout, stderr, killed });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const toolError = (text) => ({ isError: true, content: [{ type: 'text', text }] });
|
|
65
|
+
function cliFailure(command, run, killAfterMs) {
|
|
66
|
+
if (run.killed) {
|
|
67
|
+
return toolError(`obsrv ${command} did not exit within ${killAfterMs} ms and was terminated. ` +
|
|
68
|
+
`Raise timeoutMs, or try a smaller preset / non-full-page snap. ` +
|
|
69
|
+
(run.stderr.trim() ? `stderr: ${(0, lib_1.stderrTail)(run.stderr)}` : 'No stderr output.'));
|
|
70
|
+
}
|
|
71
|
+
return toolError(`obsrv ${command} failed (exit ${run.code ?? 'unknown'}): ${(0, lib_1.stderrTail)(run.stderr)}`);
|
|
72
|
+
}
|
|
73
|
+
/** An inline image block for a PNG within the cap, else a text block saying why not. */
|
|
74
|
+
async function imageOrNote(pngPath, label, suggestion) {
|
|
75
|
+
const png = await (0, promises_1.readFile)(pngPath);
|
|
76
|
+
if ((0, lib_1.shouldInlineImage)(png.byteLength)) {
|
|
77
|
+
return { type: 'image', data: png.toString('base64'), mimeType: 'image/png' };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
type: 'text',
|
|
81
|
+
text: `${label} is ${png.byteLength} bytes — over the ${lib_1.MAX_INLINE_IMAGE_BYTES}-byte inline cap, so it is not ` +
|
|
82
|
+
`inlined. Read the file at ${pngPath} instead${suggestion ? `, or ${suggestion}` : ''}.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// --- schemas -----------------------------------------------------------------
|
|
86
|
+
const PRESET_IDS = presets_1.SCREEN_PRESETS.map(p => p.id);
|
|
87
|
+
const PROFILE_IDS = presets_1.PANEL_PROFILES.map(p => p.id);
|
|
88
|
+
const urlField = zod_1.z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1)
|
|
91
|
+
.describe('Page to render: an http://, https:// or file:// URL (bare hosts also work). Other schemes are rejected.');
|
|
92
|
+
const profileField = zod_1.z
|
|
93
|
+
.enum(PROFILE_IDS)
|
|
94
|
+
.optional()
|
|
95
|
+
.describe('Panel simulation (contrast floor, gamut, bit depth, brightness). Default: reference (off).');
|
|
96
|
+
const snapInputShape = {
|
|
97
|
+
url: urlField,
|
|
98
|
+
preset: zod_1.z
|
|
99
|
+
.enum(PRESET_IDS)
|
|
100
|
+
.optional()
|
|
101
|
+
.describe('Screen preset id (list them with obsrv_presets). Mutually exclusive with width/height. Default: 1080p-24.'),
|
|
102
|
+
width: zod_1.z.number().int().min(1).optional().describe('Custom CSS viewport width in px. Needs height; mutually exclusive with preset.'),
|
|
103
|
+
height: zod_1.z.number().int().min(1).optional().describe('Custom CSS viewport height in px. Needs width.'),
|
|
104
|
+
deviceScaleFactor: zod_1.z
|
|
105
|
+
.number()
|
|
106
|
+
.min(1)
|
|
107
|
+
.optional()
|
|
108
|
+
.describe('Raster density for custom dims (device px per CSS px, default 1). Device pixels are capped at 4096 per axis.'),
|
|
109
|
+
diagonalInches: zod_1.z.number().min(0.1).optional().describe('Simulated panel diagonal in inches, for custom dims.'),
|
|
110
|
+
profile: profileField,
|
|
111
|
+
fullPage: zod_1.z.boolean().optional().describe('Capture the full page height (device px capped at 4096; a warning reports clamping).'),
|
|
112
|
+
waitMs: zod_1.z.number().int().min(0).optional().describe('Extra settle time after load, in ms, for late-settling content. Default 0.'),
|
|
113
|
+
timeoutMs: zod_1.z
|
|
114
|
+
.number()
|
|
115
|
+
.int()
|
|
116
|
+
.min(1)
|
|
117
|
+
.optional()
|
|
118
|
+
.describe(`Per-render budget for load + paint quiescence, in ms. Default ${args_1.DEFAULT_TIMEOUT_MS}.`),
|
|
119
|
+
};
|
|
120
|
+
const snapOutputShape = {
|
|
121
|
+
out: zod_1.z.string().describe('PNG path the CLI wrote (same file as pngPath).'),
|
|
122
|
+
preset: zod_1.z.string().describe('Preset id, or "custom" for width/height runs.'),
|
|
123
|
+
cssWidth: zod_1.z.number().describe('Applied CSS viewport width.'),
|
|
124
|
+
cssHeight: zod_1.z.number().describe('Applied CSS viewport height (grown under fullPage).'),
|
|
125
|
+
deviceScaleFactor: zod_1.z.number(),
|
|
126
|
+
profile: zod_1.z.string(),
|
|
127
|
+
settled: zod_1.z.boolean().describe('False: the page never went paint-quiet (e.g. animation) and the capture is best-effort.'),
|
|
128
|
+
warnings: zod_1.z.array(zod_1.z.string()),
|
|
129
|
+
pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
|
|
130
|
+
};
|
|
131
|
+
const diffInputShape = {
|
|
132
|
+
url: urlField,
|
|
133
|
+
preset: zod_1.z
|
|
134
|
+
.enum(PRESET_IDS)
|
|
135
|
+
.optional()
|
|
136
|
+
.describe('Screen preset id — 1x presets only (e.g. laptop-768, 1080p-24); dense presets are refused. Default: 1080p-24.'),
|
|
137
|
+
profile: profileField,
|
|
138
|
+
includeImages: zod_1.z
|
|
139
|
+
.boolean()
|
|
140
|
+
.optional()
|
|
141
|
+
.describe('Also inline target.png and reference.png as images (each subject to the 1.5 MiB cap). Default: paths only.'),
|
|
142
|
+
waitMs: zod_1.z.number().int().min(0).optional().describe('Extra settle time after load, in ms, applied to both renders. Default 0.'),
|
|
143
|
+
timeoutMs: zod_1.z
|
|
144
|
+
.number()
|
|
145
|
+
.int()
|
|
146
|
+
.min(1)
|
|
147
|
+
.optional()
|
|
148
|
+
.describe(`Per-render budget for load + paint quiescence, in ms. Default ${args_1.DEFAULT_TIMEOUT_MS}.`),
|
|
149
|
+
};
|
|
150
|
+
const diffOutputShape = {
|
|
151
|
+
url: zod_1.z.string(),
|
|
152
|
+
preset: zod_1.z.string(),
|
|
153
|
+
profile: zod_1.z.string(),
|
|
154
|
+
// Always present: buildDiffArgs unconditionally passes --out-dir, and the
|
|
155
|
+
// CLI writes both PNGs whenever it is given one.
|
|
156
|
+
files: zod_1.z
|
|
157
|
+
.object({ target: zod_1.z.string(), reference: zod_1.z.string() })
|
|
158
|
+
.describe('PNGs on the same 1x grid, kept in a per-call temp dir.'),
|
|
159
|
+
inkCoverage: zod_1.z
|
|
160
|
+
.object({ target: zod_1.z.number(), reference: zod_1.z.number(), delta: zod_1.z.number() })
|
|
161
|
+
.describe('Fraction of pixels darker than the ink threshold; negative delta = the 1x render is losing ink.'),
|
|
162
|
+
rows: zod_1.z
|
|
163
|
+
.object({ target: zod_1.z.number(), reference: zod_1.z.number(), ratio: zod_1.z.number().nullable() })
|
|
164
|
+
.describe('Ink rows: target on the 1x raster, reference on the raw 2x raster. Ratio ≈0.5 is normal glyph scaling.'),
|
|
165
|
+
bands: zod_1.z.array(zod_1.z.object({ y0: zod_1.z.number(), y1: zod_1.z.number(), targetInk: zod_1.z.number(), referenceInk: zod_1.z.number(), delta: zod_1.z.number() })),
|
|
166
|
+
findings: zod_1.z.array(zod_1.z.string()).describe('Humanised per-band findings. Informational — thresholds are the caller\'s job.'),
|
|
167
|
+
};
|
|
168
|
+
const presetsOutputShape = {
|
|
169
|
+
presets: zod_1.z.array(zod_1.z.object({
|
|
170
|
+
id: zod_1.z.string(),
|
|
171
|
+
label: zod_1.z.string(),
|
|
172
|
+
group: zod_1.z.string(),
|
|
173
|
+
cssWidth: zod_1.z.number(),
|
|
174
|
+
cssHeight: zod_1.z.number(),
|
|
175
|
+
deviceScaleFactor: zod_1.z.number(),
|
|
176
|
+
diagonalInches: zod_1.z.number(),
|
|
177
|
+
ppi: zod_1.z.number().describe('Physical pixel density of the simulated panel (device px per inch).'),
|
|
178
|
+
})),
|
|
179
|
+
profiles: zod_1.z.array(zod_1.z.object({
|
|
180
|
+
id: zod_1.z.string(),
|
|
181
|
+
label: zod_1.z.string(),
|
|
182
|
+
contrastRatio: zod_1.z.number().nullable(),
|
|
183
|
+
gamutCoverage: zod_1.z.number(),
|
|
184
|
+
bits: zod_1.z.number(),
|
|
185
|
+
frc: zod_1.z.boolean(),
|
|
186
|
+
nits: zod_1.z.number().nullable(),
|
|
187
|
+
summary: zod_1.z.string(),
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
// --- server ------------------------------------------------------------------
|
|
191
|
+
const server = new mcp_js_1.McpServer({ name: 'obsrv-mcp-server', version: VERSION });
|
|
192
|
+
server.registerTool('obsrv_snap', {
|
|
193
|
+
title: 'Render a URL on a target screen',
|
|
194
|
+
description: `Render a URL headlessly at a target screen's true raster density — a real 1x raster for laptop/desktop ` +
|
|
195
|
+
`presets, the device's 2x/3x DPR plus mobile UA and viewport semantics for phone presets — optionally ` +
|
|
196
|
+
`through a cheap-panel simulation, and return the PNG. Use it to judge how a page actually looks on the ` +
|
|
197
|
+
`screens users own (1366×768 laptops, 1080p desktops, budget Androids) before declaring frontend work done.\n\n` +
|
|
198
|
+
`Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both. ` +
|
|
199
|
+
`Returns structured metadata (applied viewport, profile, \`settled\`, warnings, and \`pngPath\` — the PNG ` +
|
|
200
|
+
`kept in a per-call temp dir) plus the PNG as an inline image when it is within the 1.5 MiB cap; larger ` +
|
|
201
|
+
`captures (typically fullPage) stay on disk with a note.`,
|
|
202
|
+
inputSchema: snapInputShape,
|
|
203
|
+
outputSchema: snapOutputShape,
|
|
204
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
205
|
+
}, async (input) => {
|
|
206
|
+
const badScheme = (0, lib_1.urlSchemeError)(input.url);
|
|
207
|
+
if (badScheme)
|
|
208
|
+
return toolError(badScheme);
|
|
209
|
+
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
210
|
+
const pngPath = (0, node_path_1.join)(dir, 'snap.png');
|
|
211
|
+
let args;
|
|
212
|
+
try {
|
|
213
|
+
args = (0, lib_1.buildSnapArgs)({ ...input, url: input.url.trim() }, pngPath);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
await (0, promises_1.rm)(dir, { recursive: true, force: true });
|
|
217
|
+
if (e instanceof lib_1.UsageError)
|
|
218
|
+
return toolError(e.message);
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
const killAfterMs = (0, lib_1.killBudgetMs)(1, input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS, input.waitMs ?? 0);
|
|
222
|
+
const run = await runCli(args, killAfterMs);
|
|
223
|
+
if (run.killed || run.code !== 0)
|
|
224
|
+
return cliFailure('snap', run, killAfterMs);
|
|
225
|
+
const meta = (0, lib_1.extractTrailingJson)(run.stdout);
|
|
226
|
+
if (!meta)
|
|
227
|
+
return toolError(`obsrv snap exited 0 but printed unparseable JSON: ${(0, lib_1.stderrTail)(run.stdout)}`);
|
|
228
|
+
const structured = { ...meta, pngPath };
|
|
229
|
+
return {
|
|
230
|
+
content: [
|
|
231
|
+
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
232
|
+
await imageOrNote(pngPath, 'The captured PNG', 'retry without fullPage / with a smaller preset for an inline image'),
|
|
233
|
+
],
|
|
234
|
+
structuredContent: structured,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
server.registerTool('obsrv_diff', {
|
|
238
|
+
title: 'Diff a 1x render against its 2x reference',
|
|
239
|
+
description: `Numerically compare how a URL renders on a 1x screen against a 2x reference (the same CSS viewport at ` +
|
|
240
|
+
`dsf 2 — what a HiDPI dev sees — box-downsampled onto the target's 1x grid). Use it to confirm suspected ` +
|
|
241
|
+
`low-DPI legibility problems: thinning strokes, weakening hairlines, fading grey text.\n\n` +
|
|
242
|
+
`Returns ink coverage for target and reference (negative delta = the 1x render is losing ink), ink-row ` +
|
|
243
|
+
`counts and their ratio (≈0.5 is normal glyph scaling; a hairline contributes one row at any density), ` +
|
|
244
|
+
`8 horizontal band deltas with humanised findings (informational — apply your own thresholds), and the ` +
|
|
245
|
+
`paths of target.png / reference.png in a per-call temp dir. \`includeImages: true\` also inlines both ` +
|
|
246
|
+
`PNGs (1.5 MiB cap each).\n\n` +
|
|
247
|
+
`1x presets only (e.g. laptop-768, 1080p-24): dense presets (phones) and CSS viewports over 2048px are ` +
|
|
248
|
+
`refused with an explanatory error — use obsrv_snap for those.`,
|
|
249
|
+
inputSchema: diffInputShape,
|
|
250
|
+
outputSchema: diffOutputShape,
|
|
251
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
252
|
+
}, async (input) => {
|
|
253
|
+
const badScheme = (0, lib_1.urlSchemeError)(input.url);
|
|
254
|
+
if (badScheme)
|
|
255
|
+
return toolError(badScheme);
|
|
256
|
+
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
257
|
+
const args = (0, lib_1.buildDiffArgs)({ url: input.url.trim(), preset: input.preset, profile: input.profile, waitMs: input.waitMs, timeoutMs: input.timeoutMs }, dir);
|
|
258
|
+
// Two renders per diff: the target and its 2x reference.
|
|
259
|
+
const killAfterMs = (0, lib_1.killBudgetMs)(2, input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS, input.waitMs ?? 0);
|
|
260
|
+
const run = await runCli(args, killAfterMs);
|
|
261
|
+
if (run.killed || run.code !== 0)
|
|
262
|
+
return cliFailure('diff', run, killAfterMs);
|
|
263
|
+
const metrics = (0, lib_1.extractTrailingJson)(run.stdout);
|
|
264
|
+
if (!metrics)
|
|
265
|
+
return toolError(`obsrv diff exited 0 but printed unparseable JSON: ${(0, lib_1.stderrTail)(run.stdout)}`);
|
|
266
|
+
const content = [{ type: 'text', text: JSON.stringify(metrics, null, 2) }];
|
|
267
|
+
if (input.includeImages) {
|
|
268
|
+
const files = metrics['files'];
|
|
269
|
+
content.push({ type: 'text', text: 'target.png (the 1x render, profile applied):' });
|
|
270
|
+
content.push(await imageOrNote(files.target, 'target.png', ''));
|
|
271
|
+
content.push({ type: 'text', text: 'reference.png (the 2x render downsampled onto the 1x grid):' });
|
|
272
|
+
content.push(await imageOrNote(files.reference, 'reference.png', ''));
|
|
273
|
+
}
|
|
274
|
+
return { content, structuredContent: metrics };
|
|
275
|
+
});
|
|
276
|
+
server.registerTool('obsrv_presets', {
|
|
277
|
+
title: 'List screen presets and panel profiles',
|
|
278
|
+
description: `List every screen preset (id, label, group, CSS dims, deviceScaleFactor, panel diagonal, derived physical ` +
|
|
279
|
+
`ppi) and panel profile (id, label, simulation params) accepted by obsrv_snap and obsrv_diff. Read straight ` +
|
|
280
|
+
`from the app's preset table — nothing is rendered.`,
|
|
281
|
+
inputSchema: {},
|
|
282
|
+
outputSchema: presetsOutputShape,
|
|
283
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
284
|
+
}, async () => {
|
|
285
|
+
const catalog = (0, lib_1.listCatalog)();
|
|
286
|
+
return {
|
|
287
|
+
content: [{ type: 'text', text: JSON.stringify(catalog, null, 2) }],
|
|
288
|
+
structuredContent: { ...catalog },
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
// --- boot --------------------------------------------------------------------
|
|
292
|
+
async function main() {
|
|
293
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
294
|
+
await server.connect(transport);
|
|
295
|
+
// stdout is the protocol channel; the one boot line goes to stderr.
|
|
296
|
+
process.stderr.write(`obsrv-mcp-server ${VERSION} running on stdio\n`);
|
|
297
|
+
}
|
|
298
|
+
main().catch((e) => {
|
|
299
|
+
process.stderr.write(`obsrv-mcp: fatal: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const electron = require("electron");
|
|
3
|
+
const IPC = {
|
|
4
|
+
navigate: "obsrv:navigate",
|
|
5
|
+
reload: "obsrv:reload",
|
|
6
|
+
back: "obsrv:back",
|
|
7
|
+
forward: "obsrv:forward",
|
|
8
|
+
setViewport: "obsrv:set-viewport",
|
|
9
|
+
setNativeBounds: "obsrv:set-native-bounds",
|
|
10
|
+
setMode: "obsrv:set-mode",
|
|
11
|
+
sendInput: "obsrv:send-input",
|
|
12
|
+
getHostInfo: "obsrv:get-host-info",
|
|
13
|
+
getSettings: "obsrv:get-settings",
|
|
14
|
+
setSettings: "obsrv:set-settings",
|
|
15
|
+
frame: "obsrv:frame",
|
|
16
|
+
frameSubscribe: "obsrv:frame-subscribe",
|
|
17
|
+
urlChanged: "obsrv:url-changed",
|
|
18
|
+
loadError: "obsrv:load-error",
|
|
19
|
+
hostChanged: "obsrv:host-changed",
|
|
20
|
+
targetLoading: "obsrv:target-loading",
|
|
21
|
+
targetNavigating: "obsrv:target-navigating",
|
|
22
|
+
openImage: "obsrv:open-image",
|
|
23
|
+
focusUrl: "obsrv:focus-url",
|
|
24
|
+
openImagePath: "obsrv:open-image-path",
|
|
25
|
+
readImageFile: "obsrv:read-image-file"
|
|
26
|
+
};
|
|
27
|
+
function subscribe(channel, cb) {
|
|
28
|
+
const listener = (_e, v) => cb(v);
|
|
29
|
+
electron.ipcRenderer.on(channel, listener);
|
|
30
|
+
return () => {
|
|
31
|
+
electron.ipcRenderer.removeListener(channel, listener);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
let frameSubscribers = 0;
|
|
35
|
+
function subscribeFrames(cb) {
|
|
36
|
+
const off = subscribe(IPC.frame, cb);
|
|
37
|
+
if (++frameSubscribers === 1) electron.ipcRenderer.send(IPC.frameSubscribe);
|
|
38
|
+
let active = true;
|
|
39
|
+
return () => {
|
|
40
|
+
if (!active) return;
|
|
41
|
+
active = false;
|
|
42
|
+
frameSubscribers--;
|
|
43
|
+
off();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const api = {
|
|
47
|
+
navigate: (url) => electron.ipcRenderer.invoke(IPC.navigate, url),
|
|
48
|
+
reload: () => electron.ipcRenderer.send(IPC.reload),
|
|
49
|
+
back: () => electron.ipcRenderer.send(IPC.back),
|
|
50
|
+
forward: () => electron.ipcRenderer.send(IPC.forward),
|
|
51
|
+
setViewport: (width, height, deviceScaleFactor) => electron.ipcRenderer.invoke(IPC.setViewport, width, height, deviceScaleFactor),
|
|
52
|
+
setNativeBounds: (rect) => electron.ipcRenderer.send(IPC.setNativeBounds, rect),
|
|
53
|
+
setMode: (mode) => electron.ipcRenderer.send(IPC.setMode, mode),
|
|
54
|
+
sendInput: (ev) => electron.ipcRenderer.send(IPC.sendInput, ev),
|
|
55
|
+
getHostInfo: () => electron.ipcRenderer.invoke(IPC.getHostInfo),
|
|
56
|
+
getSettings: () => electron.ipcRenderer.invoke(IPC.getSettings),
|
|
57
|
+
setSettings: (s) => electron.ipcRenderer.invoke(IPC.setSettings, s),
|
|
58
|
+
onFrame: subscribeFrames,
|
|
59
|
+
onUrlChanged: (cb) => subscribe(IPC.urlChanged, cb),
|
|
60
|
+
onLoadError: (cb) => subscribe(IPC.loadError, cb),
|
|
61
|
+
onHostChanged: (cb) => subscribe(IPC.hostChanged, cb),
|
|
62
|
+
onTargetLoading: (cb) => subscribe(IPC.targetLoading, cb),
|
|
63
|
+
onTargetNavigating: (cb) => {
|
|
64
|
+
const listener = () => cb();
|
|
65
|
+
electron.ipcRenderer.on(IPC.targetNavigating, listener);
|
|
66
|
+
return () => {
|
|
67
|
+
electron.ipcRenderer.removeListener(IPC.targetNavigating, listener);
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
onOpenImage: (cb) => {
|
|
71
|
+
const listener = () => cb();
|
|
72
|
+
electron.ipcRenderer.on(IPC.openImage, listener);
|
|
73
|
+
return () => {
|
|
74
|
+
electron.ipcRenderer.removeListener(IPC.openImage, listener);
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
onFocusUrl: (cb) => {
|
|
78
|
+
const listener = () => cb();
|
|
79
|
+
electron.ipcRenderer.on(IPC.focusUrl, listener);
|
|
80
|
+
return () => {
|
|
81
|
+
electron.ipcRenderer.removeListener(IPC.focusUrl, listener);
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
onOpenImagePath: (cb) => subscribe(IPC.openImagePath, cb),
|
|
85
|
+
readImageFile: (path) => electron.ipcRenderer.invoke(IPC.readImageFile, path)
|
|
86
|
+
};
|
|
87
|
+
electron.contextBridge.exposeInMainWorld("obsrv", api);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const electron = require("electron");
|
|
3
|
+
const SUPPRESS_MS = 120;
|
|
4
|
+
const SYNC_SCROLL = "obsrv:sync-scroll";
|
|
5
|
+
const APPLY_SCROLL = "obsrv:apply-scroll";
|
|
6
|
+
let suppressUntil = 0;
|
|
7
|
+
let lastApplied = null;
|
|
8
|
+
let rafId = 0;
|
|
9
|
+
let deferred = null;
|
|
10
|
+
function report() {
|
|
11
|
+
rafId = 0;
|
|
12
|
+
const remaining = suppressUntil - performance.now();
|
|
13
|
+
if (remaining > 0) {
|
|
14
|
+
if (deferred === null) {
|
|
15
|
+
deferred = setTimeout(() => {
|
|
16
|
+
deferred = null;
|
|
17
|
+
report();
|
|
18
|
+
}, remaining);
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const x = window.scrollX;
|
|
23
|
+
const y = window.scrollY;
|
|
24
|
+
if (lastApplied && lastApplied.x === x && lastApplied.y === y) return;
|
|
25
|
+
lastApplied = null;
|
|
26
|
+
electron.ipcRenderer.send(SYNC_SCROLL, { x, y });
|
|
27
|
+
}
|
|
28
|
+
window.addEventListener(
|
|
29
|
+
"scroll",
|
|
30
|
+
() => {
|
|
31
|
+
if (rafId !== 0) return;
|
|
32
|
+
rafId = requestAnimationFrame(report);
|
|
33
|
+
},
|
|
34
|
+
{ passive: true }
|
|
35
|
+
);
|
|
36
|
+
electron.ipcRenderer.on(APPLY_SCROLL, (_e, pos) => {
|
|
37
|
+
lastApplied = pos;
|
|
38
|
+
if (window.scrollX === pos.x && window.scrollY === pos.y) return;
|
|
39
|
+
suppressUntil = performance.now() + SUPPRESS_MS;
|
|
40
|
+
window.scrollTo(pos.x, pos.y);
|
|
41
|
+
});
|
|
Binary file
|
|
Binary file
|