getobsrv 0.3.1 → 0.4.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 +23 -1
- package/out/main/cli.js +1 -1
- package/out/main/index.js +292 -7
- package/out/main/{targetSource-w_vWw7zd.js → targetSource-DkXWE0ha.js} +12 -1
- package/out/mcp/control.js +105 -0
- package/out/mcp/lib.js +45 -24
- package/out/mcp/server.js +215 -10
- package/out/preload/app.js +14 -2
- package/out/renderer/assets/{index-VleEBVgL.css → index-FtoKShe_.css} +15 -1
- package/out/renderer/assets/{index-BAEO9_6W.js → index-P496vEhv.js} +49 -1
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +153 -0
- package/out/shared/presets.js +1 -1
- package/out/shared/url.js +54 -0
- package/package.json +8 -6
- package/skills/obsrv-screens/SKILL.md +4 -1
package/out/mcp/server.js
CHANGED
|
@@ -9,7 +9,9 @@ const node_os_1 = require("node:os");
|
|
|
9
9
|
const node_path_1 = require("node:path");
|
|
10
10
|
const zod_1 = require("zod");
|
|
11
11
|
const args_1 = require("../cli/args");
|
|
12
|
+
const control_1 = require("../shared/control");
|
|
12
13
|
const presets_1 = require("../shared/presets");
|
|
14
|
+
const control_2 = require("./control");
|
|
13
15
|
const lib_1 = require("./lib");
|
|
14
16
|
/**
|
|
15
17
|
* Obsrv MCP server (stdio, stateless): three read-only tools wrapping the
|
|
@@ -116,17 +118,33 @@ const snapInputShape = {
|
|
|
116
118
|
.min(1)
|
|
117
119
|
.optional()
|
|
118
120
|
.describe(`Per-render budget for load + paint quiescence, in ms. Default ${args_1.DEFAULT_TIMEOUT_MS}.`),
|
|
121
|
+
mode: zod_1.z
|
|
122
|
+
.enum(['auto', 'headless', 'live'])
|
|
123
|
+
.optional()
|
|
124
|
+
.describe('auto (default): drive the visible Obsrv app when it is open with Agent control on, else render headlessly. ' +
|
|
125
|
+
'live: require the app (error if unreachable). headless: never touch the app.'),
|
|
119
126
|
};
|
|
120
127
|
const snapOutputShape = {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
mode: zod_1.z
|
|
129
|
+
.enum(['headless', 'live'])
|
|
130
|
+
.describe('How the snap was produced: a headless render, or a capture of the visible Obsrv app window (live drive).'),
|
|
131
|
+
out: zod_1.z.string().optional().describe('Headless only: PNG path the CLI wrote (same file as pngPath).'),
|
|
132
|
+
preset: zod_1.z.string().optional().describe('Headless only: preset id, or "custom" for width/height runs.'),
|
|
133
|
+
cssWidth: zod_1.z.number().optional().describe('Headless only: applied CSS viewport width.'),
|
|
134
|
+
cssHeight: zod_1.z.number().optional().describe('Headless only: applied CSS viewport height (grown under fullPage).'),
|
|
135
|
+
deviceScaleFactor: zod_1.z.number().optional().describe('Headless only.'),
|
|
136
|
+
profile: zod_1.z.string().optional().describe('Headless only: applied panel profile id.'),
|
|
137
|
+
settled: zod_1.z
|
|
138
|
+
.boolean()
|
|
139
|
+
.describe('Headless: the page went paint-quiet. Live: the app confirmed the navigation before the capture.'),
|
|
128
140
|
warnings: zod_1.z.array(zod_1.z.string()),
|
|
129
141
|
pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
|
|
142
|
+
url: zod_1.z.string().optional().describe('Live only: the URL the app reports showing.'),
|
|
143
|
+
presetId: zod_1.z.string().optional().describe('Live only: the screen preset selected in the app.'),
|
|
144
|
+
profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
|
|
145
|
+
viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
|
|
146
|
+
width: zod_1.z.number().optional().describe('Live only: captured app-window width in px.'),
|
|
147
|
+
height: zod_1.z.number().optional().describe('Live only: captured app-window height in px.'),
|
|
130
148
|
};
|
|
131
149
|
const diffInputShape = {
|
|
132
150
|
url: urlField,
|
|
@@ -187,6 +205,122 @@ const presetsOutputShape = {
|
|
|
187
205
|
summary: zod_1.z.string(),
|
|
188
206
|
})),
|
|
189
207
|
};
|
|
208
|
+
const driveInputShape = {
|
|
209
|
+
url: zod_1.z
|
|
210
|
+
.string()
|
|
211
|
+
.min(1)
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('Navigate the app (both panes) to this http://, https:// or file:// URL (bare hosts also work).'),
|
|
214
|
+
preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
|
|
215
|
+
profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
|
|
216
|
+
viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
|
|
217
|
+
};
|
|
218
|
+
const driveOutputShape = {
|
|
219
|
+
version: zod_1.z.string().describe('The running app version.'),
|
|
220
|
+
url: zod_1.z.string().describe('The URL the target pane reports showing.'),
|
|
221
|
+
presetId: zod_1.z.string(),
|
|
222
|
+
profileId: zod_1.z.string(),
|
|
223
|
+
viewMode: zod_1.z.string(),
|
|
224
|
+
mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
|
|
225
|
+
};
|
|
226
|
+
// --- live drive --------------------------------------------------------------
|
|
227
|
+
/** Budget for one control `status` round-trip once the app is known live. */
|
|
228
|
+
const LIVE_STATUS_TIMEOUT_MS = 2_000;
|
|
229
|
+
/** Budget for a preset/profile/view-mode apply (the server confirms, bounded). */
|
|
230
|
+
const LIVE_APPLY_TIMEOUT_MS = 5_000;
|
|
231
|
+
/** Budget for `captureVisible` (a full-window PNG over loopback). */
|
|
232
|
+
const LIVE_CAPTURE_TIMEOUT_MS = 30_000;
|
|
233
|
+
/** How long a live snap waits for `status.url` to reflect the navigation. */
|
|
234
|
+
const LIVE_SETTLE_MS = 5_000;
|
|
235
|
+
function liveFailure(e) {
|
|
236
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
237
|
+
return (`${msg}. If the Obsrv app was closed or Agent control was toggled off mid-call, ` +
|
|
238
|
+
`re-open the app and re-enable the toolbar toggle — or pass mode: "headless".`);
|
|
239
|
+
}
|
|
240
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
241
|
+
/**
|
|
242
|
+
* The live `obsrv_snap` path: navigate the visible app (plus preset/profile
|
|
243
|
+
* when given), wait — bounded — for the app to report the navigation, then
|
|
244
|
+
* capture the window exactly as the user sees it.
|
|
245
|
+
*/
|
|
246
|
+
async function liveSnap(app, input, notes) {
|
|
247
|
+
const { info } = app;
|
|
248
|
+
const warnings = [...notes];
|
|
249
|
+
const before = app.status.url;
|
|
250
|
+
let applied = '';
|
|
251
|
+
try {
|
|
252
|
+
// The navigate command answers once both panes finished loading, so it
|
|
253
|
+
// carries the same per-render budget the headless path polices.
|
|
254
|
+
const nav = await (0, control_2.controlCall)(info, 'navigate', { url: input.url.trim() }, (input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS) + 10_000);
|
|
255
|
+
applied = typeof nav['url'] === 'string' ? nav['url'] : '';
|
|
256
|
+
if (input.preset !== undefined)
|
|
257
|
+
await (0, control_2.controlCall)(info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
|
|
258
|
+
if (input.profile !== undefined)
|
|
259
|
+
await (0, control_2.controlCall)(info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
return toolError(liveFailure(e));
|
|
263
|
+
}
|
|
264
|
+
// The app settles when it reports the applied URL — or, after a redirect,
|
|
265
|
+
// any committed non-blank URL that is no longer the pre-navigation one.
|
|
266
|
+
let status = app.status;
|
|
267
|
+
let settled = false;
|
|
268
|
+
const deadline = Date.now() + LIVE_SETTLE_MS;
|
|
269
|
+
for (;;) {
|
|
270
|
+
try {
|
|
271
|
+
const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
272
|
+
if (s) {
|
|
273
|
+
status = s;
|
|
274
|
+
settled = s.url === applied || (applied !== '' && s.url !== before && s.url !== 'about:blank');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return toolError(liveFailure(e));
|
|
279
|
+
}
|
|
280
|
+
if (settled || Date.now() >= deadline)
|
|
281
|
+
break;
|
|
282
|
+
await sleep(250);
|
|
283
|
+
}
|
|
284
|
+
if (!settled)
|
|
285
|
+
warnings.push('the app did not confirm the navigation before capture; the PNG may show the previous page.');
|
|
286
|
+
// One short grace after the state settles: the renderer repaints the pane
|
|
287
|
+
// (and any preset resize) a frame or two after the store confirms, and a
|
|
288
|
+
// capture racing that would show a half-applied flip.
|
|
289
|
+
await sleep(300);
|
|
290
|
+
let capture;
|
|
291
|
+
try {
|
|
292
|
+
capture = await (0, control_2.controlCall)(info, 'captureVisible', {}, LIVE_CAPTURE_TIMEOUT_MS);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
return toolError(liveFailure(e));
|
|
296
|
+
}
|
|
297
|
+
const { data, width, height } = capture;
|
|
298
|
+
if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
|
|
299
|
+
return toolError('the control server returned a malformed capture');
|
|
300
|
+
}
|
|
301
|
+
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
302
|
+
const pngPath = (0, node_path_1.join)(dir, 'live.png');
|
|
303
|
+
await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
|
|
304
|
+
const structured = {
|
|
305
|
+
mode: 'live',
|
|
306
|
+
url: status.url,
|
|
307
|
+
presetId: status.presetId,
|
|
308
|
+
profileId: status.profileId,
|
|
309
|
+
viewMode: status.viewMode,
|
|
310
|
+
width,
|
|
311
|
+
height,
|
|
312
|
+
settled,
|
|
313
|
+
warnings,
|
|
314
|
+
pngPath,
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
content: [
|
|
318
|
+
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
319
|
+
await imageOrNote(pngPath, 'The captured app window', 'read the file at pngPath'),
|
|
320
|
+
],
|
|
321
|
+
structuredContent: structured,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
190
324
|
// --- server ------------------------------------------------------------------
|
|
191
325
|
const server = new mcp_js_1.McpServer({ name: 'obsrv-mcp-server', version: VERSION });
|
|
192
326
|
server.registerTool('obsrv_snap', {
|
|
@@ -198,7 +332,15 @@ server.registerTool('obsrv_snap', {
|
|
|
198
332
|
`Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both. ` +
|
|
199
333
|
`Returns structured metadata (applied viewport, profile, \`settled\`, warnings, and \`pngPath\` — the PNG ` +
|
|
200
334
|
`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
|
|
335
|
+
`captures (typically fullPage) stay on disk with a note.\n\n` +
|
|
336
|
+
`Live drive: when the Obsrv desktop app is open with its "Agent control" toolbar toggle on, \`mode: "auto"\` ` +
|
|
337
|
+
`(the default) drives the *visible* app instead — the user watches the URL load and the preset flip, and the ` +
|
|
338
|
+
`returned PNG is the app window as they see it (\`mode: "live"\` in the result; \`mode: "headless"\` ` +
|
|
339
|
+
`otherwise). Custom width/height and \`fullPage\` always render headlessly (with a note); \`waitMs\` is ` +
|
|
340
|
+
`ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
|
|
341
|
+
`touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
|
|
342
|
+
`the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
|
|
343
|
+
`capture; that visible steering is the point of live mode.`,
|
|
202
344
|
inputSchema: snapInputShape,
|
|
203
345
|
outputSchema: snapOutputShape,
|
|
204
346
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -206,6 +348,20 @@ server.registerTool('obsrv_snap', {
|
|
|
206
348
|
const badScheme = (0, lib_1.urlSchemeError)(input.url);
|
|
207
349
|
if (badScheme)
|
|
208
350
|
return toolError(badScheme);
|
|
351
|
+
// The live path first (spec §14 "Live drive"): a reachable control-enabled
|
|
352
|
+
// app wins under auto, is required under live, and is never probed under
|
|
353
|
+
// headless. planSnapPath documents the fallback rules.
|
|
354
|
+
const requestedMode = input.mode ?? 'auto';
|
|
355
|
+
let liveNotes = [];
|
|
356
|
+
if (requestedMode !== 'headless') {
|
|
357
|
+
const live = await (0, control_2.discoverControl)();
|
|
358
|
+
const plan = (0, lib_1.planSnapPath)(input, requestedMode, live !== null);
|
|
359
|
+
if ('error' in plan)
|
|
360
|
+
return toolError(plan.error);
|
|
361
|
+
if (plan.path === 'live' && live)
|
|
362
|
+
return liveSnap(live, input, plan.notes);
|
|
363
|
+
liveNotes = plan.notes;
|
|
364
|
+
}
|
|
209
365
|
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
210
366
|
const pngPath = (0, node_path_1.join)(dir, 'snap.png');
|
|
211
367
|
let args;
|
|
@@ -225,7 +381,8 @@ server.registerTool('obsrv_snap', {
|
|
|
225
381
|
const meta = (0, lib_1.extractTrailingJson)(run.stdout);
|
|
226
382
|
if (!meta)
|
|
227
383
|
return toolError(`obsrv snap exited 0 but printed unparseable JSON: ${(0, lib_1.stderrTail)(run.stdout)}`);
|
|
228
|
-
const
|
|
384
|
+
const cliWarnings = Array.isArray(meta['warnings']) ? meta['warnings'] : [];
|
|
385
|
+
const structured = { ...meta, mode: 'headless', warnings: [...cliWarnings, ...liveNotes], pngPath };
|
|
229
386
|
return {
|
|
230
387
|
content: [
|
|
231
388
|
{ type: 'text', text: JSON.stringify(structured, null, 2) },
|
|
@@ -245,7 +402,10 @@ server.registerTool('obsrv_diff', {
|
|
|
245
402
|
`paths of target.png / reference.png in a per-call temp dir. \`includeImages: true\` also inlines both ` +
|
|
246
403
|
`PNGs (1.5 MiB cap each).\n\n` +
|
|
247
404
|
`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
|
|
405
|
+
`refused with an explanatory error — use obsrv_snap for those.\n\n` +
|
|
406
|
+
`Headless-only: a diff always performs its own two renders and never drives a running Obsrv app window ` +
|
|
407
|
+
`(the comparison needs both rasters, which the visible app cannot show) — use obsrv_snap or obsrv_drive ` +
|
|
408
|
+
`for live drive.`,
|
|
249
409
|
inputSchema: diffInputShape,
|
|
250
410
|
outputSchema: diffOutputShape,
|
|
251
411
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -273,6 +433,51 @@ server.registerTool('obsrv_diff', {
|
|
|
273
433
|
}
|
|
274
434
|
return { content, structuredContent: metrics };
|
|
275
435
|
});
|
|
436
|
+
server.registerTool('obsrv_drive', {
|
|
437
|
+
title: 'Drive the visible Obsrv app',
|
|
438
|
+
description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, a panel ` +
|
|
439
|
+
`profile, or the target pane's 1:1/fit view — each applied exactly as clicking the toolbar would, while the ` +
|
|
440
|
+
`user watches. Applies whichever inputs are given (none = just read the current state) and returns the ` +
|
|
441
|
+
`resulting status: app version, the URL showing, and the selected preset/profile/view.\n\n` +
|
|
442
|
+
`Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
|
|
443
|
+
`mutates visible app state (it changes what the user's window shows) but renders nothing itself — use ` +
|
|
444
|
+
`obsrv_snap for a capture.`,
|
|
445
|
+
inputSchema: driveInputShape,
|
|
446
|
+
outputSchema: driveOutputShape,
|
|
447
|
+
// Honest annotation: this changes what the user's window is showing.
|
|
448
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
449
|
+
}, async (input) => {
|
|
450
|
+
if (input.url !== undefined) {
|
|
451
|
+
const badScheme = (0, lib_1.urlSchemeError)(input.url);
|
|
452
|
+
if (badScheme)
|
|
453
|
+
return toolError(badScheme);
|
|
454
|
+
}
|
|
455
|
+
const live = await (0, control_2.discoverControl)();
|
|
456
|
+
if (!live)
|
|
457
|
+
return toolError(lib_1.APP_NOT_REACHABLE);
|
|
458
|
+
try {
|
|
459
|
+
if (input.url !== undefined) {
|
|
460
|
+
await (0, control_2.controlCall)(live.info, 'navigate', { url: input.url.trim() }, args_1.DEFAULT_TIMEOUT_MS + 10_000);
|
|
461
|
+
}
|
|
462
|
+
if (input.preset !== undefined)
|
|
463
|
+
await (0, control_2.controlCall)(live.info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
|
|
464
|
+
if (input.profile !== undefined)
|
|
465
|
+
await (0, control_2.controlCall)(live.info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
|
|
466
|
+
if (input.viewMode !== undefined) {
|
|
467
|
+
await (0, control_2.controlCall)(live.info, 'setViewMode', { mode: input.viewMode }, LIVE_APPLY_TIMEOUT_MS);
|
|
468
|
+
}
|
|
469
|
+
const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
470
|
+
if (!status)
|
|
471
|
+
return toolError('the control server returned a malformed status');
|
|
472
|
+
return {
|
|
473
|
+
content: [{ type: 'text', text: JSON.stringify(status, null, 2) }],
|
|
474
|
+
structuredContent: { ...status },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
catch (e) {
|
|
478
|
+
return toolError(liveFailure(e));
|
|
479
|
+
}
|
|
480
|
+
});
|
|
276
481
|
server.registerTool('obsrv_presets', {
|
|
277
482
|
title: 'List screen presets and panel profiles',
|
|
278
483
|
description: `List every screen preset (id, label, group, CSS dims, deviceScaleFactor, panel diagonal, derived physical ` +
|
package/out/preload/app.js
CHANGED
|
@@ -22,7 +22,10 @@ const IPC = {
|
|
|
22
22
|
openImage: "obsrv:open-image",
|
|
23
23
|
focusUrl: "obsrv:focus-url",
|
|
24
24
|
openImagePath: "obsrv:open-image-path",
|
|
25
|
-
readImageFile: "obsrv:read-image-file"
|
|
25
|
+
readImageFile: "obsrv:read-image-file",
|
|
26
|
+
uiState: "obsrv:ui-state",
|
|
27
|
+
agentApply: "obsrv:agent-apply",
|
|
28
|
+
agentActivity: "obsrv:agent-activity"
|
|
26
29
|
};
|
|
27
30
|
function subscribe(channel, cb) {
|
|
28
31
|
const listener = (_e, v) => cb(v);
|
|
@@ -82,6 +85,15 @@ const api = {
|
|
|
82
85
|
};
|
|
83
86
|
},
|
|
84
87
|
onOpenImagePath: (cb) => subscribe(IPC.openImagePath, cb),
|
|
85
|
-
readImageFile: (path) => electron.ipcRenderer.invoke(IPC.readImageFile, path)
|
|
88
|
+
readImageFile: (path) => electron.ipcRenderer.invoke(IPC.readImageFile, path),
|
|
89
|
+
reportUiState: (s) => electron.ipcRenderer.send(IPC.uiState, s),
|
|
90
|
+
onAgentApply: (cb) => subscribe(IPC.agentApply, cb),
|
|
91
|
+
onAgentActivity: (cb) => {
|
|
92
|
+
const listener = () => cb();
|
|
93
|
+
electron.ipcRenderer.on(IPC.agentActivity, listener);
|
|
94
|
+
return () => {
|
|
95
|
+
electron.ipcRenderer.removeListener(IPC.agentActivity, listener);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
86
98
|
};
|
|
87
99
|
electron.contextBridge.exposeInMainWorld("obsrv", api);
|
|
@@ -251,11 +251,25 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
251
251
|
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
252
252
|
|
|
253
253
|
/* An open drawer's button: weight and a 1px border, not hue. */
|
|
254
|
-
.toolbar :is(.toggle-panel, .toggle-settings)[aria-pressed='true'] {
|
|
254
|
+
.toolbar :is(.toggle-panel, .toggle-settings, .agent-toggle)[aria-pressed='true'] {
|
|
255
255
|
border-color: var(--text-0);
|
|
256
256
|
background: var(--chrome-1);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/* Agent control: same neutral chrome as every other toggle; the activity
|
|
260
|
+
badge is text-weight only — an agent driving the window is a status, not
|
|
261
|
+
an alert. */
|
|
262
|
+
.toolbar .agent-toggle { width: auto; padding: 0 8px; white-space: nowrap; }
|
|
263
|
+
.agent-activity {
|
|
264
|
+
color: var(--text-1);
|
|
265
|
+
border: 1px solid var(--line);
|
|
266
|
+
border-radius: 4px;
|
|
267
|
+
padding: 1px 6px;
|
|
268
|
+
font-size: 11px;
|
|
269
|
+
letter-spacing: 0.5px;
|
|
270
|
+
white-space: nowrap;
|
|
271
|
+
}
|
|
272
|
+
|
|
259
273
|
/* Drawers sit beside the panes, never over them: the native WebContentsView is
|
|
260
274
|
an OS-level overlay and would cover anything painted on top of it. */
|
|
261
275
|
.body { flex: 1 1 auto; display: flex; min-height: 0; }
|
|
@@ -12688,7 +12688,7 @@ const createImpl = (createState) => {
|
|
|
12688
12688
|
};
|
|
12689
12689
|
const create = ((createState) => createImpl);
|
|
12690
12690
|
const MAX_VIEWPORT = 4096;
|
|
12691
|
-
const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
|
|
12691
|
+
const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
|
|
12692
12692
|
const SCREEN_PRESETS = [
|
|
12693
12693
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
12694
12694
|
{ id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
|
|
@@ -14172,8 +14172,29 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14172
14172
|
const setSurround = useStore((s) => s.setSurround);
|
|
14173
14173
|
const viewMode = useStore((s) => s.viewMode);
|
|
14174
14174
|
const setViewMode = useStore((s) => s.setViewMode);
|
|
14175
|
+
const agentControl = useStore((s) => s.settings.agentControl);
|
|
14176
|
+
const setSettings = useStore((s) => s.setSettings);
|
|
14175
14177
|
const inputRef = reactExports.useRef(null);
|
|
14176
14178
|
const [draft, setDraft] = reactExports.useState(barText);
|
|
14179
|
+
const [agentActive, setAgentActive] = reactExports.useState(false);
|
|
14180
|
+
reactExports.useEffect(() => {
|
|
14181
|
+
let timer;
|
|
14182
|
+
const off = window.obsrv.onAgentActivity(() => {
|
|
14183
|
+
setAgentActive(true);
|
|
14184
|
+
clearTimeout(timer);
|
|
14185
|
+
timer = setTimeout(() => setAgentActive(false), 3e3);
|
|
14186
|
+
});
|
|
14187
|
+
return () => {
|
|
14188
|
+
clearTimeout(timer);
|
|
14189
|
+
off();
|
|
14190
|
+
};
|
|
14191
|
+
}, []);
|
|
14192
|
+
const toggleAgent = () => {
|
|
14193
|
+
const current = useStore.getState().settings;
|
|
14194
|
+
const next = { ...current, agentControl: !current.agentControl };
|
|
14195
|
+
setSettings(next);
|
|
14196
|
+
window.obsrv.setSettings(next).catch(() => setSettings(current));
|
|
14197
|
+
};
|
|
14177
14198
|
const lastMode = reactExports.useRef(mode);
|
|
14178
14199
|
reactExports.useEffect(() => {
|
|
14179
14200
|
const modeChanged = lastMode.current !== mode;
|
|
@@ -14290,6 +14311,19 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14290
14311
|
},
|
|
14291
14312
|
s.id
|
|
14292
14313
|
)) }),
|
|
14314
|
+
agentActive && /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "agent-activity", children: "AGENT" }),
|
|
14315
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14316
|
+
"button",
|
|
14317
|
+
{
|
|
14318
|
+
className: "agent-toggle",
|
|
14319
|
+
type: "button",
|
|
14320
|
+
title: "Agent control — let a local agent drive this window",
|
|
14321
|
+
"aria-label": "Agent control",
|
|
14322
|
+
"aria-pressed": agentControl,
|
|
14323
|
+
onClick: toggleAgent,
|
|
14324
|
+
children: "Agent"
|
|
14325
|
+
}
|
|
14326
|
+
),
|
|
14293
14327
|
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14294
14328
|
"button",
|
|
14295
14329
|
{
|
|
@@ -14334,6 +14368,9 @@ function App() {
|
|
|
14334
14368
|
const surround = useStore((s) => s.surround);
|
|
14335
14369
|
const viewport = useStore(useShallow(selectViewport));
|
|
14336
14370
|
const deviceScaleFactor = useStore(selectDeviceScaleFactor);
|
|
14371
|
+
const presetId = useStore((s) => s.presetId);
|
|
14372
|
+
const profileId = useStore((s) => s.profileId);
|
|
14373
|
+
const viewMode = useStore((s) => s.viewMode);
|
|
14337
14374
|
reactExports.useEffect(() => {
|
|
14338
14375
|
window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
|
|
14339
14376
|
window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
|
|
@@ -14359,6 +14396,17 @@ function App() {
|
|
|
14359
14396
|
reactExports.useEffect(() => {
|
|
14360
14397
|
window.obsrv.setMode(mode);
|
|
14361
14398
|
}, [mode]);
|
|
14399
|
+
reactExports.useEffect(() => {
|
|
14400
|
+
window.obsrv.reportUiState({ presetId, profileId, viewMode, mode });
|
|
14401
|
+
}, [presetId, profileId, viewMode, mode]);
|
|
14402
|
+
reactExports.useEffect(() => {
|
|
14403
|
+
return window.obsrv.onAgentApply((patch) => {
|
|
14404
|
+
const s = useStore.getState();
|
|
14405
|
+
if (patch.presetId !== void 0) s.setPreset(patch.presetId);
|
|
14406
|
+
if (patch.profileId !== void 0) s.setProfile(patch.profileId);
|
|
14407
|
+
if (patch.viewMode !== void 0) s.setViewMode(patch.viewMode);
|
|
14408
|
+
});
|
|
14409
|
+
}, []);
|
|
14362
14410
|
reactExports.useEffect(() => {
|
|
14363
14411
|
document.documentElement.dataset.surround = surround;
|
|
14364
14412
|
}, [surround]);
|
package/out/renderer/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:" />
|
|
6
6
|
<title>Obsrv</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-P496vEhv.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-FtoKShe_.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONTROL_COMMANDS = exports.CONTROL_TOKEN_BYTES = exports.CONTROL_FILE_NAME = void 0;
|
|
4
|
+
exports.isControlCommand = isControlCommand;
|
|
5
|
+
exports.parseControlFile = parseControlFile;
|
|
6
|
+
exports.controlFileModeOk = controlFileModeOk;
|
|
7
|
+
exports.tokenEqual = tokenEqual;
|
|
8
|
+
exports.defaultControlFilePath = defaultControlFilePath;
|
|
9
|
+
exports.presetApplyError = presetApplyError;
|
|
10
|
+
exports.profileApplyError = profileApplyError;
|
|
11
|
+
exports.viewModeApplyError = viewModeApplyError;
|
|
12
|
+
exports.parseControlStatus = parseControlStatus;
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
const presets_1 = require("./presets");
|
|
16
|
+
/**
|
|
17
|
+
* The agent-control protocol shared by the main-process control server
|
|
18
|
+
* (`src/main/controlServer.ts`) and the MCP discovery client
|
|
19
|
+
* (`src/mcp/control.ts`): discovery-file shape, token comparison, command
|
|
20
|
+
* names and payload validation. Pure node — no Electron, no I/O — so
|
|
21
|
+
* everything here runs under plain node and is unit-tested in
|
|
22
|
+
* tests/unit/control.test.ts.
|
|
23
|
+
*
|
|
24
|
+
* Renderer code imports *types* from this module only; the `node:crypto`
|
|
25
|
+
* import never reaches a browser bundle.
|
|
26
|
+
*
|
|
27
|
+
* Security decisions (spec §14 "Live drive"):
|
|
28
|
+
* - The server binds 127.0.0.1 only, on an ephemeral port.
|
|
29
|
+
* - Every command — `status` included — carries the bearer token from the
|
|
30
|
+
* discovery file. The file is mode 0600 in the app's own userData dir, so
|
|
31
|
+
* possession already proves "same user"; a token-free status would only
|
|
32
|
+
* leak app state to other local users for no benefit.
|
|
33
|
+
* - No command accepts file paths, JavaScript, or IPC channel names; every
|
|
34
|
+
* payload is validated against the same tables the app itself uses.
|
|
35
|
+
*/
|
|
36
|
+
/** Discovery file the app writes to `app.getPath('userData')` while agent control is on. */
|
|
37
|
+
exports.CONTROL_FILE_NAME = 'control.json';
|
|
38
|
+
/** Bearer-token entropy; hex-encoded in the discovery file (64 chars). */
|
|
39
|
+
exports.CONTROL_TOKEN_BYTES = 32;
|
|
40
|
+
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
41
|
+
exports.CONTROL_COMMANDS = [
|
|
42
|
+
'status',
|
|
43
|
+
'navigate',
|
|
44
|
+
'setPreset',
|
|
45
|
+
'setProfile',
|
|
46
|
+
'setViewMode',
|
|
47
|
+
'captureVisible',
|
|
48
|
+
];
|
|
49
|
+
function isControlCommand(v) {
|
|
50
|
+
return typeof v === 'string' && exports.CONTROL_COMMANDS.includes(v);
|
|
51
|
+
}
|
|
52
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
53
|
+
/**
|
|
54
|
+
* Parses a discovery file's contents. Strict: a malformed file (bad JSON,
|
|
55
|
+
* out-of-range port, a token that is not 64 hex chars) yields null — the
|
|
56
|
+
* client must treat the app as not reachable rather than send credentials
|
|
57
|
+
* derived from a file something else may have written.
|
|
58
|
+
*/
|
|
59
|
+
function parseControlFile(raw) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(parsed))
|
|
68
|
+
return null;
|
|
69
|
+
const { port, token } = parsed;
|
|
70
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof token !== 'string' || !TOKEN_RE.test(token))
|
|
73
|
+
return null;
|
|
74
|
+
return { port, token };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a discovery file's permission bits are acceptable: no group or
|
|
78
|
+
* other access on POSIX (the app writes it 0600). Windows has no POSIX mode
|
|
79
|
+
* bits worth reading, so everything passes there.
|
|
80
|
+
*/
|
|
81
|
+
function controlFileModeOk(mode, platform) {
|
|
82
|
+
if (platform === 'win32')
|
|
83
|
+
return true;
|
|
84
|
+
return (mode & 0o077) === 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Constant-time bearer-token comparison. Both sides are hashed first so
|
|
88
|
+
* `timingSafeEqual` always gets equal-length inputs — a length mismatch must
|
|
89
|
+
* not throw or short-circuit into a timing signal.
|
|
90
|
+
*/
|
|
91
|
+
function tokenEqual(expected, provided) {
|
|
92
|
+
if (typeof provided !== 'string')
|
|
93
|
+
return false;
|
|
94
|
+
const a = (0, node_crypto_1.createHash)('sha256').update(expected).digest();
|
|
95
|
+
const b = (0, node_crypto_1.createHash)('sha256').update(provided).digest();
|
|
96
|
+
return (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Where the app's discovery file lives for a given platform, derived the way
|
|
100
|
+
* Electron derives `app.getPath('userData')` for productName "Obsrv" — the
|
|
101
|
+
* MCP server runs under plain node and cannot ask Electron.
|
|
102
|
+
*/
|
|
103
|
+
function defaultControlFilePath(platform, env, home) {
|
|
104
|
+
const appDir = platform === 'darwin'
|
|
105
|
+
? (0, node_path_1.join)(home, 'Library', 'Application Support', 'Obsrv')
|
|
106
|
+
: platform === 'win32'
|
|
107
|
+
? (0, node_path_1.join)(env['APPDATA'] ?? (0, node_path_1.join)(home, 'AppData', 'Roaming'), 'Obsrv')
|
|
108
|
+
: (0, node_path_1.join)(env['XDG_CONFIG_HOME'] ?? (0, node_path_1.join)(home, '.config'), 'Obsrv');
|
|
109
|
+
return (0, node_path_1.join)(appDir, exports.CONTROL_FILE_NAME);
|
|
110
|
+
}
|
|
111
|
+
const idList = (ids) => ids.join(', ');
|
|
112
|
+
/**
|
|
113
|
+
* Validates a `setPreset` payload id. The custom preset is refused: it is
|
|
114
|
+
* defined by the renderer's own width/height/diagonal fields, so "apply
|
|
115
|
+
* custom" from outside would apply whatever happened to be typed there.
|
|
116
|
+
*/
|
|
117
|
+
function presetApplyError(id) {
|
|
118
|
+
if (typeof id !== 'string')
|
|
119
|
+
return 'setPreset payload must be { id: string }';
|
|
120
|
+
if (id === 'custom') {
|
|
121
|
+
return 'the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id';
|
|
122
|
+
}
|
|
123
|
+
if (!presets_1.SCREEN_PRESETS.some(p => p.id === id)) {
|
|
124
|
+
return `unknown preset "${id}" — valid ids: ${idList(presets_1.SCREEN_PRESETS.map(p => p.id))}`;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function profileApplyError(id) {
|
|
129
|
+
if (typeof id !== 'string')
|
|
130
|
+
return 'setProfile payload must be { id: string }';
|
|
131
|
+
if (!presets_1.PANEL_PROFILES.some(p => p.id === id)) {
|
|
132
|
+
return `unknown profile "${id}" — valid ids: ${idList(presets_1.PANEL_PROFILES.map(p => p.id))}`;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function viewModeApplyError(v) {
|
|
137
|
+
return v === '1:1' || v === 'fit' ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
|
|
138
|
+
}
|
|
139
|
+
/** Validates a control server `status` response on the client side. */
|
|
140
|
+
function parseControlStatus(raw) {
|
|
141
|
+
if (!isRecord(raw))
|
|
142
|
+
return null;
|
|
143
|
+
const { version, url, presetId, profileId, viewMode, mode } = raw;
|
|
144
|
+
if (typeof version !== 'string' || typeof url !== 'string')
|
|
145
|
+
return null;
|
|
146
|
+
if (typeof presetId !== 'string' || typeof profileId !== 'string')
|
|
147
|
+
return null;
|
|
148
|
+
if (viewMode !== '1:1' && viewMode !== 'fit')
|
|
149
|
+
return null;
|
|
150
|
+
if (mode !== 'url' && mode !== 'image')
|
|
151
|
+
return null;
|
|
152
|
+
return { version, url, presetId, profileId, viewMode, mode };
|
|
153
|
+
}
|
package/out/shared/presets.js
CHANGED
|
@@ -4,7 +4,7 @@ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exp
|
|
|
4
4
|
exports.findPreset = findPreset;
|
|
5
5
|
exports.findProfile = findProfile;
|
|
6
6
|
exports.MAX_VIEWPORT = 4096;
|
|
7
|
-
exports.DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
|
|
7
|
+
exports.DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
|
|
8
8
|
exports.SCREEN_PRESETS = [
|
|
9
9
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
10
10
|
{ id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ALLOWED_URL_SCHEMES = void 0;
|
|
4
|
+
exports.normalizeUrl = normalizeUrl;
|
|
5
|
+
exports.urlSchemeError = urlSchemeError;
|
|
6
|
+
/** `scheme:` prefix, e.g. `https:`, `about:`, `file:`. */
|
|
7
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
8
|
+
/** Loopback host with optional port, e.g. `localhost:5173`, `127.0.0.1/a`. */
|
|
9
|
+
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i;
|
|
10
|
+
/**
|
|
11
|
+
* Turn URL-bar input into an absolute, loadable URL.
|
|
12
|
+
* Loopback is checked before the scheme test because `localhost:5173`
|
|
13
|
+
* parses as scheme `localhost` otherwise.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeUrl(input) {
|
|
16
|
+
const s = input.trim();
|
|
17
|
+
if (s === '')
|
|
18
|
+
throw new Error('empty url');
|
|
19
|
+
if (/\s/.test(s))
|
|
20
|
+
throw new Error('invalid URL');
|
|
21
|
+
if (s.startsWith('/'))
|
|
22
|
+
return `file://${s}`;
|
|
23
|
+
if (LOOPBACK.test(s))
|
|
24
|
+
return `http://${s}`;
|
|
25
|
+
if (SCHEME.test(s))
|
|
26
|
+
return s;
|
|
27
|
+
return `https://${s}`;
|
|
28
|
+
}
|
|
29
|
+
/** Schemes an agent-facing entry point (MCP tool, control server) may load. */
|
|
30
|
+
exports.ALLOWED_URL_SCHEMES = ['http:', 'https:', 'file:'];
|
|
31
|
+
/**
|
|
32
|
+
* Rejects URLs whose explicit scheme is outside the allowlist (javascript:,
|
|
33
|
+
* data:, chrome:, …) with an actionable message, or returns null when the URL
|
|
34
|
+
* may proceed. Scheme-relative (`//host`), bare-host (`example.com/page`) and
|
|
35
|
+
* host:port (`localhost:5173`) forms pass — they normalise to http(s)
|
|
36
|
+
* downstream. Shared by the MCP tools and the agent-control server, so the
|
|
37
|
+
* app's URL bar stays the only surface that can reach another scheme.
|
|
38
|
+
*/
|
|
39
|
+
function urlSchemeError(url) {
|
|
40
|
+
const trimmed = url.trim();
|
|
41
|
+
const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
|
|
42
|
+
if (!match)
|
|
43
|
+
return null; // bare host or scheme-relative
|
|
44
|
+
const scheme = `${match[1].toLowerCase()}:`;
|
|
45
|
+
if (exports.ALLOWED_URL_SCHEMES.includes(scheme))
|
|
46
|
+
return null;
|
|
47
|
+
// `localhost:5173`-style host:port, not a scheme: the "scheme" is followed
|
|
48
|
+
// by a bare port number.
|
|
49
|
+
if (/^[a-z0-9.-]+:\d+(\/|$)/i.test(trimmed))
|
|
50
|
+
return null;
|
|
51
|
+
return (`unsupported URL scheme "${scheme}" — obsrv renders ` +
|
|
52
|
+
`${exports.ALLOWED_URL_SCHEMES.map(s => `${s}//`).join(', ')} URLs only ` +
|
|
53
|
+
`(bare hosts like example.com also work; they normalise to http(s)).`);
|
|
54
|
+
}
|