getobsrv 0.3.0 → 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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Obsrv
2
2
 
3
+ [![CI](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml/badge.svg)](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/getobsrv)](https://www.npmjs.com/package/getobsrv)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
3
7
  See your site the way 1x screens see it.
4
8
 
5
9
  Designers and developers work on HiDPI (2x–3x) monitors. A large share of users are on
@@ -52,18 +56,18 @@ a 1366×768 laptop / budget Android?" without the GUI. Build first
52
56
 
53
57
  ```bash
54
58
  # One PNG at a preset's true raster density (+ metadata JSON on stdout):
55
- node bin/obsrv.js snap http://localhost:5173 --preset laptop-768 --out shot.png
59
+ npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --out shot.png
56
60
 
57
61
  # A matrix of screens, cheap-panel simulation, full-page capture:
58
- node bin/obsrv.js snap http://localhost:5173 --matrix laptop-768,android-65,1080p-24 --out shots/
59
- node bin/obsrv.js snap http://localhost:5173 --preset laptop-768 --profile budget-tn --out tn.png
60
- node bin/obsrv.js snap http://localhost:5173 --preset laptop-768 --full-page --out full.png
62
+ npx -y getobsrv snap http://localhost:5173 --matrix laptop-768,android-65,1080p-24 --out shots/
63
+ npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --profile budget-tn --out tn.png
64
+ npx -y getobsrv snap http://localhost:5173 --preset laptop-768 --full-page --out full.png
61
65
 
62
66
  # Machine-readable 1x-vs-2x comparison (ink coverage, row ratios, band deltas):
63
- node bin/obsrv.js diff http://localhost:5173 --preset laptop-768 --out-dir diffout
67
+ npx -y getobsrv diff http://localhost:5173 --preset laptop-768 --out-dir diffout
64
68
  ```
65
69
 
66
- `node bin/obsrv.js --help` lists every preset, profile and flag. Diff findings
70
+ `npx -y getobsrv --help` (or `node bin/obsrv.js --help` in a checkout) lists every preset, profile and flag. Diff findings
67
71
  are informational (exit 0); CI thresholds are the caller's job. A ready-made
68
72
  Claude Code skill that wraps the loop (snap matrix → read the PNGs → diff →
69
73
  fix → re-snap) lives at [skills/obsrv-screens/SKILL.md](skills/obsrv-screens/SKILL.md).
@@ -74,10 +78,18 @@ The same CLI is also wrapped as an MCP server (stdio, stateless) so MCP
74
78
  clients get the tools natively: `obsrv_snap` (render a URL at a preset's true
75
79
  raster density — the PNG comes back as an inline image up to 1.5 MiB),
76
80
  `obsrv_diff` (the 1x-vs-2x metrics as structured output) and `obsrv_presets`
77
- (every preset and panel profile, no render). Build first, then register:
81
+ (every preset and panel profile, no render).
82
+
83
+ If the desktop app is open with the toolbar's **Agent control** toggle on,
84
+ `obsrv_snap` drives the *visible* window instead: you watch the URL load and
85
+ the preset flip, and the agent gets back a capture of the app exactly as you
86
+ see it (plus `obsrv_drive` to flip URL/preset/profile directly). With no app
87
+ running, everything falls back to the headless render automatically.
88
+
89
+ Build first, then register:
78
90
 
79
91
  ```bash
80
- claude mcp add --scope user obsrv -- node /Users/opeyemiajagbe/Documents/Projects/Obsrv/bin/obsrv-mcp.js
92
+ claude mcp add --scope user obsrv -- npx -y getobsrv mcp
81
93
  ```
82
94
 
83
95
  ## Develop
@@ -95,6 +107,16 @@ Architecture, decisions and the full spec live in
95
107
  the UI style rationale (why the chrome is strictly neutral) is in
96
108
  [docs/superpowers/specs/2026-08-23-obsrv-ui-style.md](docs/superpowers/specs/2026-08-23-obsrv-ui-style.md).
97
109
 
110
+ ## Install (desktop app)
111
+
112
+ Grab the DMG for your chip from [Releases](https://github.com/vibesyemmy/obsrv/releases),
113
+ drag Obsrv.app to Applications, then clear the quarantine flag once (the build is not
114
+ yet notarised, so macOS falsely reports it as "damaged"):
115
+
116
+ ```bash
117
+ xattr -cr /Applications/Obsrv.app
118
+ ```
119
+
98
120
  ## Distribution
99
121
 
100
122
  Obsrv will publish to npm as **`getobsrv`** (the installed commands remain `obsrv`
package/bin/obsrv.js CHANGED
@@ -12,6 +12,14 @@ const { existsSync, mkdtempSync, rmSync } = require('node:fs')
12
12
  const { tmpdir } = require('node:os')
13
13
  const { join } = require('node:path')
14
14
 
15
+ // `obsrv mcp` serves the MCP server (plain node, no Electron) so one npx
16
+ // invocation covers both: `npx -y getobsrv mcp`.
17
+ if (process.argv[2] === 'mcp') {
18
+ process.argv.splice(2, 1)
19
+ require('./obsrv-mcp.js')
20
+ return
21
+ }
22
+
15
23
  const cliEntry = join(__dirname, '..', 'out', 'main', 'cli.js')
16
24
  if (!existsSync(cliEntry)) {
17
25
  console.error('obsrv: out/main/cli.js is missing — run `npm run build` in the Obsrv repo first')
package/out/main/cli.js CHANGED
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_fs = require("node:fs");
4
4
  const node_os = require("node:os");
5
5
  const node_path = require("node:path");
6
- const targetSource = require("./targetSource-w_vWw7zd.js");
6
+ const targetSource = require("./targetSource-DkXWE0ha.js");
7
7
  function boxDownsample(src, factor) {
8
8
  if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
9
9
  const width = Math.floor(src.width / factor);
package/out/main/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  const electron = require("electron");
3
+ const node_fs = require("node:fs");
3
4
  const promises = require("node:fs/promises");
4
5
  const node_path = require("node:path");
5
- const targetSource = require("./targetSource-w_vWw7zd.js");
6
- const node_fs = require("node:fs");
6
+ const node_crypto = require("node:crypto");
7
+ const targetSource = require("./targetSource-DkXWE0ha.js");
8
+ const node_http = require("node:http");
7
9
  const node_url = require("node:url");
8
10
  const IPC = {
9
11
  navigate: "obsrv:navigate",
@@ -29,7 +31,10 @@ const IPC = {
29
31
  openImage: "obsrv:open-image",
30
32
  focusUrl: "obsrv:focus-url",
31
33
  openImagePath: "obsrv:open-image-path",
32
- readImageFile: "obsrv:read-image-file"
34
+ readImageFile: "obsrv:read-image-file",
35
+ uiState: "obsrv:ui-state",
36
+ agentApply: "obsrv:agent-apply",
37
+ agentActivity: "obsrv:agent-activity"
33
38
  };
34
39
  function attachFrameBus(target, win) {
35
40
  let ready = false;
@@ -63,6 +68,46 @@ function attachFrameBus(target, win) {
63
68
  }
64
69
  };
65
70
  }
71
+ const CONTROL_FILE_NAME = "control.json";
72
+ const CONTROL_TOKEN_BYTES = 32;
73
+ const CONTROL_COMMANDS = [
74
+ "status",
75
+ "navigate",
76
+ "setPreset",
77
+ "setProfile",
78
+ "setViewMode",
79
+ "captureVisible"
80
+ ];
81
+ function isControlCommand(v) {
82
+ return typeof v === "string" && CONTROL_COMMANDS.includes(v);
83
+ }
84
+ function tokenEqual(expected, provided) {
85
+ if (typeof provided !== "string") return false;
86
+ const a = node_crypto.createHash("sha256").update(expected).digest();
87
+ const b = node_crypto.createHash("sha256").update(provided).digest();
88
+ return node_crypto.timingSafeEqual(a, b);
89
+ }
90
+ const idList = (ids) => ids.join(", ");
91
+ function presetApplyError(id) {
92
+ if (typeof id !== "string") return "setPreset payload must be { id: string }";
93
+ if (id === "custom") {
94
+ return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
95
+ }
96
+ if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
97
+ return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
98
+ }
99
+ return null;
100
+ }
101
+ function profileApplyError(id) {
102
+ if (typeof id !== "string") return "setProfile payload must be { id: string }";
103
+ if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
104
+ return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
105
+ }
106
+ return null;
107
+ }
108
+ function viewModeApplyError(v) {
109
+ return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
110
+ }
66
111
  const MAX_RECT = 16384;
67
112
  const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
68
113
  const isRecord = (v) => typeof v === "object" && v !== null;
@@ -126,11 +171,23 @@ function parseSettings(raw) {
126
171
  const { hostDiagonalInches, hostNits } = raw;
127
172
  if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
128
173
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
129
- return { hostDiagonalInches, hostNits };
174
+ const agentControl = raw.agentControl ?? false;
175
+ if (typeof agentControl !== "boolean") return null;
176
+ return { hostDiagonalInches, hostNits, agentControl };
130
177
  }
131
178
  function parseMode(raw) {
132
179
  return raw === "url" || raw === "image" ? raw : null;
133
180
  }
181
+ const MAX_UI_ID = 64;
182
+ function parseUiState(raw) {
183
+ if (!isRecord(raw)) return null;
184
+ const { presetId, profileId, viewMode, mode } = raw;
185
+ if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
186
+ if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
187
+ if (viewMode !== "1:1" && viewMode !== "fit") return null;
188
+ if (mode !== "url" && mode !== "image") return null;
189
+ return { presetId, profileId, viewMode, mode };
190
+ }
134
191
  function parseScrollPos(raw) {
135
192
  if (!isRecord(raw)) return null;
136
193
  const { x, y } = raw;
@@ -143,7 +200,10 @@ function loadSettings(file) {
143
200
  const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
144
201
  return {
145
202
  hostDiagonalInches: isPositive(raw.hostDiagonalInches) ? raw.hostDiagonalInches : targetSource.DEFAULT_SETTINGS.hostDiagonalInches,
146
- hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits
203
+ hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits,
204
+ // Anything but a literal true (older files have no key at all) means off:
205
+ // a network-facing capability must never be enabled by a malformed file.
206
+ agentControl: raw.agentControl === true
147
207
  };
148
208
  } catch {
149
209
  return { ...targetSource.DEFAULT_SETTINGS };
@@ -151,9 +211,158 @@ function loadSettings(file) {
151
211
  }
152
212
  function saveSettings(file, s) {
153
213
  if (!isPositive(s.hostDiagonalInches) || !isPositive(s.hostNits)) throw new RangeError("settings values must be finite and > 0");
214
+ if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
154
215
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
155
216
  node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
156
217
  }
218
+ const MAX_BODY_BYTES = 64 * 1024;
219
+ const APPLY_WAIT_MS = 2e3;
220
+ const APPLY_POLL_MS = 25;
221
+ const reply = (code, body) => ({ code, body });
222
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
223
+ class ControlServer {
224
+ constructor(file, deps) {
225
+ this.file = file;
226
+ this.deps = deps;
227
+ }
228
+ server = null;
229
+ token = "";
230
+ get running() {
231
+ return this.server !== null;
232
+ }
233
+ /** Starts the server on an ephemeral loopback port and writes the discovery file. */
234
+ async start() {
235
+ if (this.server) return;
236
+ this.token = node_crypto.randomBytes(CONTROL_TOKEN_BYTES).toString("hex");
237
+ const server = node_http.createServer((req, res) => void this.handle(req, res));
238
+ await new Promise((resolve, reject) => {
239
+ server.once("error", reject);
240
+ server.listen(0, "127.0.0.1", resolve);
241
+ });
242
+ this.server = server;
243
+ const { port } = server.address();
244
+ node_fs.rmSync(this.file, { force: true });
245
+ node_fs.writeFileSync(this.file, JSON.stringify({ port, token: this.token }), { mode: 384 });
246
+ }
247
+ /**
248
+ * Stops the server and removes the discovery file. Synchronous on purpose:
249
+ * the quit path must not race the process teardown, and the file — the
250
+ * part that outlives the process — goes first.
251
+ */
252
+ stop() {
253
+ const server = this.server;
254
+ this.server = null;
255
+ this.token = "";
256
+ node_fs.rmSync(this.file, { force: true });
257
+ if (server) {
258
+ server.closeAllConnections();
259
+ server.close();
260
+ }
261
+ }
262
+ async handle(req, res) {
263
+ let out;
264
+ try {
265
+ out = await this.route(req);
266
+ } catch (e) {
267
+ out = reply(500, { error: e instanceof Error ? e.message : "internal error" });
268
+ }
269
+ const payload = JSON.stringify(out.body);
270
+ res.writeHead(out.code, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
271
+ res.end(payload);
272
+ }
273
+ async route(req) {
274
+ if (req.method !== "POST" || req.url !== "/") return reply(404, { error: "POST / only" });
275
+ if (req.headers.origin !== void 0) return reply(403, { error: "cross-origin requests are refused" });
276
+ const contentType = req.headers["content-type"] ?? "";
277
+ if (!/^application\/json\b/i.test(contentType)) {
278
+ return reply(415, { error: "content-type must be application/json" });
279
+ }
280
+ const raw = await this.readBody(req);
281
+ if (raw === null) return reply(413, { error: `body over ${MAX_BODY_BYTES} bytes` });
282
+ let parsed;
283
+ try {
284
+ parsed = JSON.parse(raw);
285
+ } catch {
286
+ return reply(400, { error: "body must be JSON" });
287
+ }
288
+ if (typeof parsed !== "object" || parsed === null) return reply(400, { error: "body must be a JSON object" });
289
+ const body = parsed;
290
+ if (!tokenEqual(this.token, body.token)) return reply(403, { error: "forbidden" });
291
+ const command = body.command;
292
+ if (!isControlCommand(command)) {
293
+ return reply(400, { error: `unknown command — allowed: ${CONTROL_COMMANDS.join(", ")}` });
294
+ }
295
+ this.deps.activity();
296
+ const payload = typeof body.payload === "object" && body.payload !== null ? body.payload : {};
297
+ switch (command) {
298
+ case "status":
299
+ return reply(200, { ok: true, ...this.deps.status() });
300
+ case "navigate": {
301
+ const url = payload.url;
302
+ if (typeof url !== "string" || url.trim() === "") return reply(400, { error: "navigate payload must be { url: string }" });
303
+ const bad = targetSource.urlSchemeError(url);
304
+ if (bad) return reply(400, { error: bad });
305
+ const applied = await this.deps.navigate(url.trim());
306
+ return reply(200, { ok: true, url: applied });
307
+ }
308
+ case "setPreset": {
309
+ const err = presetApplyError(payload.id);
310
+ if (err) return reply(400, { error: err });
311
+ return this.applyAndConfirm({ presetId: payload.id }, (s) => s.presetId === payload.id);
312
+ }
313
+ case "setProfile": {
314
+ const err = profileApplyError(payload.id);
315
+ if (err) return reply(400, { error: err });
316
+ return this.applyAndConfirm({ profileId: payload.id }, (s) => s.profileId === payload.id);
317
+ }
318
+ case "setViewMode": {
319
+ const err = viewModeApplyError(payload.mode);
320
+ if (err) return reply(400, { error: err });
321
+ const mode = payload.mode;
322
+ return this.applyAndConfirm({ viewMode: mode }, (s) => s.viewMode === mode);
323
+ }
324
+ case "captureVisible": {
325
+ const capture = await this.deps.captureVisible();
326
+ return reply(200, { ok: true, ...capture });
327
+ }
328
+ }
329
+ }
330
+ /**
331
+ * Forwards a patch to the renderer and waits (bounded) for the UI mirror to
332
+ * reflect it, so a 200 means "applied", not "sent". `applied: false` after
333
+ * the wait is not an error — the renderer may be busy — the caller can poll
334
+ * `status`.
335
+ */
336
+ async applyAndConfirm(patch, confirmed) {
337
+ this.deps.apply(patch);
338
+ const deadline = Date.now() + APPLY_WAIT_MS;
339
+ let applied = confirmed(this.deps.status());
340
+ while (!applied && Date.now() < deadline) {
341
+ await sleep(APPLY_POLL_MS);
342
+ applied = confirmed(this.deps.status());
343
+ }
344
+ return reply(200, { ok: true, applied, ...this.deps.status() });
345
+ }
346
+ /** The request body, or null when it exceeds the cap. */
347
+ readBody(req) {
348
+ return new Promise((resolve, rejectOnError) => {
349
+ const chunks = [];
350
+ let size = 0;
351
+ req.on("data", (chunk) => {
352
+ size += chunk.byteLength;
353
+ if (size > MAX_BODY_BYTES) {
354
+ req.removeAllListeners("data");
355
+ req.removeAllListeners("end");
356
+ resolve(null);
357
+ return;
358
+ }
359
+ chunks.push(chunk);
360
+ });
361
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
362
+ req.on("error", rejectOnError);
363
+ });
364
+ }
365
+ }
157
366
  const TOOLBAR_H = 44;
158
367
  const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
159
368
  function hostInfo(win) {
@@ -176,8 +385,7 @@ function registerIpc(ctx) {
176
385
  const assertRenderer = (e) => {
177
386
  if (!fromRenderer(e)) throw new Error("ipc: unexpected sender");
178
387
  };
179
- electron.ipcMain.handle(IPC.navigate, async (e, url) => {
180
- assertRenderer(e);
388
+ const navigateBoth = async (url) => {
181
389
  let wanted = url;
182
390
  try {
183
391
  wanted = targetSource.normalizeUrl(url);
@@ -186,6 +394,10 @@ function registerIpc(ctx) {
186
394
  }
187
395
  const [applied] = await Promise.all([native.load(wanted), target.load(wanted)]);
188
396
  return applied;
397
+ };
398
+ electron.ipcMain.handle(IPC.navigate, (e, url) => {
399
+ assertRenderer(e);
400
+ return navigateBoth(url);
189
401
  });
190
402
  electron.ipcMain.on(IPC.reload, (e) => {
191
403
  if (!fromRenderer(e)) return;
@@ -271,8 +483,81 @@ function registerIpc(ctx) {
271
483
  const s = parseSettings(raw);
272
484
  if (!s) throw new Error("invalid settings");
273
485
  saveSettings(settingsFile, s);
486
+ const wasEnabled = settings.agentControl;
274
487
  settings = s;
488
+ if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
489
+ });
490
+ const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
491
+ const MAX_PENDING_APPLIES = 32;
492
+ let rendererReported = false;
493
+ let warnedPendingOverflow = false;
494
+ const pendingApplies = [];
495
+ electron.ipcMain.on(IPC.uiState, (e, raw) => {
496
+ if (!fromRenderer(e)) return;
497
+ const s = parseUiState(raw);
498
+ if (!s) return;
499
+ Object.assign(uiState, s);
500
+ if (!rendererReported) {
501
+ rendererReported = true;
502
+ for (const patch of pendingApplies.splice(0)) {
503
+ if (!win.isDestroyed()) win.webContents.send(IPC.agentApply, patch);
504
+ }
505
+ }
275
506
  });
507
+ const appVersion = (() => {
508
+ try {
509
+ const pkg = JSON.parse(node_fs.readFileSync(node_path.join(__dirname, "..", "..", "package.json"), "utf8"));
510
+ return pkg.version ?? electron.app.getVersion();
511
+ } catch {
512
+ return electron.app.getVersion();
513
+ }
514
+ })();
515
+ const control = new ControlServer(node_path.join(electron.app.getPath("userData"), CONTROL_FILE_NAME), {
516
+ status: () => {
517
+ let url = "";
518
+ try {
519
+ url = target.webContents.getURL();
520
+ } catch {
521
+ }
522
+ return { version: appVersion, url, ...uiState };
523
+ },
524
+ navigate: navigateBoth,
525
+ apply: (patch) => {
526
+ if (win.isDestroyed()) return;
527
+ if (!rendererReported) {
528
+ if (pendingApplies.length >= MAX_PENDING_APPLIES) {
529
+ if (!warnedPendingOverflow) {
530
+ warnedPendingOverflow = true;
531
+ console.warn("obsrv: agent-apply queue full before the renderer mounted; dropping oldest entries");
532
+ }
533
+ pendingApplies.shift();
534
+ }
535
+ pendingApplies.push(patch);
536
+ return;
537
+ }
538
+ win.webContents.send(IPC.agentApply, patch);
539
+ },
540
+ captureVisible: async () => {
541
+ const image = await win.webContents.capturePage();
542
+ const size = image.getSize();
543
+ return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
544
+ },
545
+ activity: () => {
546
+ if (!win.isDestroyed()) win.webContents.send(IPC.agentActivity);
547
+ }
548
+ });
549
+ const applyAgentControl = (enabled) => {
550
+ if (enabled) {
551
+ control.start().catch((e) => {
552
+ console.error("obsrv: agent-control server failed to start", e);
553
+ });
554
+ } else {
555
+ control.stop();
556
+ }
557
+ };
558
+ if (process.env.OBSRV_AGENT_CONTROL === "1") settings = { ...settings, agentControl: true };
559
+ if (settings.agentControl) applyAgentControl(true);
560
+ electron.app.on("will-quit", () => control.stop());
276
561
  electron.ipcMain.handle(IPC.readImageFile, async (e, raw) => {
277
562
  assertRenderer(e);
278
563
  if (typeof raw !== "string" || !targetSource.IMAGE_EXTENSIONS.test(raw)) throw new Error("Unsupported file type");
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_events = require("node:events");
4
4
  const node_path = require("node:path");
5
5
  const MAX_VIEWPORT = 4096;
6
- const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
6
+ const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
7
7
  const SCREEN_PRESETS = [
8
8
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
9
9
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -70,6 +70,16 @@ function normalizeUrl(input) {
70
70
  if (SCHEME.test(s)) return s;
71
71
  return `https://${s}`;
72
72
  }
73
+ const ALLOWED_URL_SCHEMES = ["http:", "https:", "file:"];
74
+ function urlSchemeError(url) {
75
+ const trimmed = url.trim();
76
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
77
+ if (!match) return null;
78
+ const scheme = `${match[1].toLowerCase()}:`;
79
+ if (ALLOWED_URL_SCHEMES.includes(scheme)) return null;
80
+ if (/^[a-z0-9.-]+:\d+(\/|$)/i.test(trimmed)) return null;
81
+ return `unsupported URL scheme "${scheme}" — obsrv renders ${ALLOWED_URL_SCHEMES.map((s) => `${s}//`).join(", ")} URLs only (bare hosts like example.com also work; they normalise to http(s)).`;
82
+ }
73
83
  const ERR_ABORTED = -3;
74
84
  const DEFAULT_FPS = 30;
75
85
  const DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
@@ -349,3 +359,4 @@ exports.findPreset = findPreset;
349
359
  exports.findProfile = findProfile;
350
360
  exports.maxCssViewport = maxCssViewport;
351
361
  exports.normalizeUrl = normalizeUrl;
362
+ exports.urlSchemeError = urlSchemeError;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ControlCallError = exports.CONTROL_FILE_ENV = void 0;
4
+ exports.controlFilePath = controlFilePath;
5
+ exports.controlCall = controlCall;
6
+ exports.discoverControl = discoverControl;
7
+ const promises_1 = require("node:fs/promises");
8
+ const node_http_1 = require("node:http");
9
+ const node_os_1 = require("node:os");
10
+ const control_1 = require("../shared/control");
11
+ /**
12
+ * MCP-side client for the app's agent-control server (spec §14 "Live
13
+ * drive"): discovery-file lookup, liveness check, and the one-shot POST the
14
+ * protocol speaks. Runs under plain node — the userData path is derived
15
+ * per-platform in shared/control.ts, never asked of Electron.
16
+ */
17
+ /**
18
+ * Overrides where the discovery file is looked for. The e2e harness sets it
19
+ * (its app runs with an isolated --user-data-dir, so its control.json is not
20
+ * at the standard path); it also keeps those tests hermetic against a real
21
+ * Obsrv the developer may have open.
22
+ */
23
+ exports.CONTROL_FILE_ENV = 'OBSRV_CONTROL_FILE';
24
+ function controlFilePath() {
25
+ return process.env[exports.CONTROL_FILE_ENV] ?? (0, control_1.defaultControlFilePath)(process.platform, process.env, (0, node_os_1.homedir)());
26
+ }
27
+ /** A non-2xx answer from the control server, carrying its error message. */
28
+ class ControlCallError extends Error {
29
+ statusCode;
30
+ constructor(message, statusCode) {
31
+ super(message);
32
+ this.statusCode = statusCode;
33
+ }
34
+ }
35
+ exports.ControlCallError = ControlCallError;
36
+ /**
37
+ * One control-protocol command. Rejects on transport failure, timeout, or a
38
+ * non-200 answer (with the server's error message when it sent one).
39
+ */
40
+ function controlCall(info, command, payload = {}, timeoutMs = 10_000) {
41
+ return new Promise((resolve, reject) => {
42
+ const body = JSON.stringify({ token: info.token, command, payload });
43
+ const req = (0, node_http_1.request)({
44
+ host: '127.0.0.1',
45
+ port: info.port,
46
+ method: 'POST',
47
+ path: '/',
48
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
49
+ timeout: timeoutMs,
50
+ }, res => {
51
+ let text = '';
52
+ res.on('data', d => (text += String(d)));
53
+ res.on('end', () => {
54
+ let parsed = null;
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ }
58
+ catch {
59
+ // Handled by the shape check below.
60
+ }
61
+ const rec = typeof parsed === 'object' && parsed !== null ? parsed : {};
62
+ if (res.statusCode === 200) {
63
+ resolve(rec);
64
+ }
65
+ else {
66
+ const detail = typeof rec['error'] === 'string' ? rec['error'] : `control server answered ${res.statusCode ?? '?'}`;
67
+ reject(new ControlCallError(`obsrv control ${command}: ${detail}`, res.statusCode));
68
+ }
69
+ });
70
+ });
71
+ req.on('timeout', () => req.destroy(new Error(`obsrv control ${command} timed out after ${timeoutMs} ms`)));
72
+ req.on('error', reject);
73
+ req.end(body);
74
+ });
75
+ }
76
+ /**
77
+ * Finds a running, control-enabled Obsrv app: discovery file present, sanely
78
+ * permissioned (no group/other access on POSIX — the app writes it 0600) and
79
+ * well-formed, and a tokened `status` answered within `timeoutMs`. Null on
80
+ * any failure — every path where the app cannot be *proven* live is "not
81
+ * reachable", so auto mode falls back to headless instead of erroring.
82
+ */
83
+ async function discoverControl(timeoutMs = 500) {
84
+ const file = controlFilePath();
85
+ let raw;
86
+ try {
87
+ const s = await (0, promises_1.stat)(file);
88
+ if (!(0, control_1.controlFileModeOk)(s.mode, process.platform))
89
+ return null;
90
+ raw = await (0, promises_1.readFile)(file, 'utf8');
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ const info = (0, control_1.parseControlFile)(raw);
96
+ if (!info)
97
+ return null;
98
+ try {
99
+ const status = (0, control_1.parseControlStatus)(await controlCall(info, 'status', {}, timeoutMs));
100
+ return status ? { info, status } : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }