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 +30 -8
- package/bin/obsrv.js +8 -0
- 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 -5
- package/skills/obsrv-screens/SKILL.md +17 -11
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Obsrv
|
|
2
2
|
|
|
3
|
+
[](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/getobsrv)
|
|
5
|
+
[](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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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).
|
|
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 --
|
|
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-
|
|
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
|
|
6
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|