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/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
- 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
+ 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 structured = { ...meta, pngPath };
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 ` +
@@ -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]);
@@ -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-BAEO9_6W.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-VleEBVgL.css">
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
+ }
@@ -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
+ }