getobsrv 0.4.1 → 0.5.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 +14 -3
- package/out/main/index.js +186 -52
- package/out/mcp/lib.js +8 -4
- package/out/mcp/server.js +114 -9
- package/out/renderer/assets/{index-FtoKShe_.css → index-CHF-G97L.css} +21 -2
- package/out/renderer/assets/{index-P496vEhv.js → index-DyCD4ih_.js} +123 -38
- package/out/renderer/index.html +2 -2
- package/out/shared/api.js +2 -0
- package/out/shared/control.js +74 -1
- package/out/shared/ipcPayloads.js +158 -0
- package/package.json +3 -2
- package/skills/obsrv-screens/SKILL.md +3 -2
package/README.md
CHANGED
|
@@ -83,8 +83,10 @@ raster density — the PNG comes back as an inline image up to 1.5 MiB),
|
|
|
83
83
|
If the desktop app is open with the toolbar's **Agent control** toggle on,
|
|
84
84
|
`obsrv_snap` drives the *visible* window instead: you watch the URL load and
|
|
85
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).
|
|
87
|
-
|
|
86
|
+
see it (plus `obsrv_drive` to flip URL/preset/profile directly). Agents can
|
|
87
|
+
also scroll, click, pan and highlight while you watch — a drive session works
|
|
88
|
+
as a guided demo. With no app running, everything falls back to the headless
|
|
89
|
+
render automatically.
|
|
88
90
|
|
|
89
91
|
Build first, then register:
|
|
90
92
|
|
|
@@ -119,7 +121,16 @@ xattr -cr /Applications/Obsrv.app
|
|
|
119
121
|
|
|
120
122
|
## Distribution
|
|
121
123
|
|
|
122
|
-
|
|
124
|
+
Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
|
|
125
|
+
package.json before lifecycle hooks run, which silently skips the prepack
|
|
126
|
+
electron dev→prod dependency swap (this shipped a broken 0.4.0). The flow is:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm run release:pack
|
|
130
|
+
npm publish ./getobsrv-<version>.tgz
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Obsrv publishes to npm as **`getobsrv`** (the installed commands remain `obsrv`
|
|
123
134
|
and `obsrv-mcp`; the app's display name remains Obsrv). The bare `obsrv` npm name
|
|
124
135
|
belongs to an unrelated package.
|
|
125
136
|
|
package/out/main/index.js
CHANGED
|
@@ -68,51 +68,11 @@ function attachFrameBus(target, win) {
|
|
|
68
68
|
}
|
|
69
69
|
};
|
|
70
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
|
-
}
|
|
111
71
|
const MAX_RECT = 16384;
|
|
112
72
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
113
|
-
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
73
|
+
const isRecord$1 = (v) => typeof v === "object" && v !== null;
|
|
114
74
|
function parseRect(raw) {
|
|
115
|
-
if (!isRecord(raw)) return null;
|
|
75
|
+
if (!isRecord$1(raw)) return null;
|
|
116
76
|
const { x, y, width, height } = raw;
|
|
117
77
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
|
|
118
78
|
const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
|
@@ -134,7 +94,7 @@ function parseModifiers(raw) {
|
|
|
134
94
|
return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
|
|
135
95
|
}
|
|
136
96
|
function parseInputEvent(raw) {
|
|
137
|
-
if (!isRecord(raw)) return null;
|
|
97
|
+
if (!isRecord$1(raw)) return null;
|
|
138
98
|
const modifiers = parseModifiers(raw.modifiers);
|
|
139
99
|
switch (raw.type) {
|
|
140
100
|
case "mouseDown":
|
|
@@ -167,7 +127,7 @@ function parseDeviceScaleFactor(raw) {
|
|
|
167
127
|
return raw;
|
|
168
128
|
}
|
|
169
129
|
function parseSettings(raw) {
|
|
170
|
-
if (!isRecord(raw)) return null;
|
|
130
|
+
if (!isRecord$1(raw)) return null;
|
|
171
131
|
const { hostDiagonalInches, hostNits } = raw;
|
|
172
132
|
if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
|
|
173
133
|
if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
|
|
@@ -180,20 +140,104 @@ function parseMode(raw) {
|
|
|
180
140
|
}
|
|
181
141
|
const MAX_UI_ID = 64;
|
|
182
142
|
function parseUiState(raw) {
|
|
183
|
-
if (!isRecord(raw)) return null;
|
|
143
|
+
if (!isRecord$1(raw)) return null;
|
|
184
144
|
const { presetId, profileId, viewMode, mode } = raw;
|
|
185
145
|
if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
|
|
186
146
|
if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
|
|
187
147
|
if (viewMode !== "1:1" && viewMode !== "fit") return null;
|
|
188
148
|
if (mode !== "url" && mode !== "image") return null;
|
|
189
|
-
return { presetId, profileId, viewMode, mode };
|
|
149
|
+
return { presetId, profileId, viewMode, mode, targetBounds: parseRect(raw.targetBounds) };
|
|
190
150
|
}
|
|
191
151
|
function parseScrollPos(raw) {
|
|
192
|
-
if (!isRecord(raw)) return null;
|
|
152
|
+
if (!isRecord$1(raw)) return null;
|
|
193
153
|
const { x, y } = raw;
|
|
194
154
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
|
|
195
155
|
return { x, y };
|
|
196
156
|
}
|
|
157
|
+
const CONTROL_FILE_NAME = "control.json";
|
|
158
|
+
const CONTROL_TOKEN_BYTES = 32;
|
|
159
|
+
const CONTROL_COMMANDS = [
|
|
160
|
+
"status",
|
|
161
|
+
"navigate",
|
|
162
|
+
"setPreset",
|
|
163
|
+
"setProfile",
|
|
164
|
+
"setViewMode",
|
|
165
|
+
"captureVisible",
|
|
166
|
+
// v0.5 drive controls (spec §14 "Drive controls").
|
|
167
|
+
"scroll",
|
|
168
|
+
"panTo",
|
|
169
|
+
"click",
|
|
170
|
+
"highlight",
|
|
171
|
+
"back",
|
|
172
|
+
"forward",
|
|
173
|
+
"reload",
|
|
174
|
+
"setPixelExact",
|
|
175
|
+
"captureTarget",
|
|
176
|
+
"focusWindow"
|
|
177
|
+
];
|
|
178
|
+
function isControlCommand(v) {
|
|
179
|
+
return typeof v === "string" && CONTROL_COMMANDS.includes(v);
|
|
180
|
+
}
|
|
181
|
+
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
182
|
+
function tokenEqual(expected, provided) {
|
|
183
|
+
if (typeof provided !== "string") return false;
|
|
184
|
+
const a = node_crypto.createHash("sha256").update(expected).digest();
|
|
185
|
+
const b = node_crypto.createHash("sha256").update(provided).digest();
|
|
186
|
+
return node_crypto.timingSafeEqual(a, b);
|
|
187
|
+
}
|
|
188
|
+
const idList = (ids) => ids.join(", ");
|
|
189
|
+
function presetApplyError(id) {
|
|
190
|
+
if (typeof id !== "string") return "setPreset payload must be { id: string }";
|
|
191
|
+
if (id === "custom") {
|
|
192
|
+
return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
|
|
193
|
+
}
|
|
194
|
+
if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
|
|
195
|
+
return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
function profileApplyError(id) {
|
|
200
|
+
if (typeof id !== "string") return "setProfile payload must be { id: string }";
|
|
201
|
+
if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
|
|
202
|
+
return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function viewModeApplyError(v) {
|
|
207
|
+
return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
|
|
208
|
+
}
|
|
209
|
+
function pixelExactApplyError(v) {
|
|
210
|
+
return typeof v === "boolean" ? null : "setPixelExact payload must be { on: boolean }";
|
|
211
|
+
}
|
|
212
|
+
function parseClick(raw, viewport) {
|
|
213
|
+
const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
|
|
214
|
+
if (!isRecord(raw)) return shape;
|
|
215
|
+
const { x, y } = raw;
|
|
216
|
+
if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
|
|
217
|
+
if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
|
|
218
|
+
return `click (${x}, ${y}) is outside the current CSS viewport ${viewport.width}x${viewport.height}`;
|
|
219
|
+
}
|
|
220
|
+
const button = raw.button ?? "left";
|
|
221
|
+
if (button !== "left" && button !== "middle" && button !== "right") {
|
|
222
|
+
return "click button must be left, middle or right";
|
|
223
|
+
}
|
|
224
|
+
return { x, y, button };
|
|
225
|
+
}
|
|
226
|
+
const HIGHLIGHT_DURATION_DEFAULT_MS = 2e3;
|
|
227
|
+
const HIGHLIGHT_DURATION_MIN_MS = 250;
|
|
228
|
+
const HIGHLIGHT_DURATION_MAX_MS = 1e4;
|
|
229
|
+
function parseHighlight(raw) {
|
|
230
|
+
const rect = parseRect(raw);
|
|
231
|
+
if (!rect) return "highlight payload must be { x, y, width, height, durationMs? } with finite, non-negative target-pixel bounds";
|
|
232
|
+
if (rect.width < 1 || rect.height < 1) return "highlight rect must be at least 1x1 target pixels";
|
|
233
|
+
const d = raw.durationMs;
|
|
234
|
+
if (d === void 0) return { ...rect, durationMs: HIGHLIGHT_DURATION_DEFAULT_MS };
|
|
235
|
+
if (typeof d !== "number" || !Number.isFinite(d)) return "highlight durationMs must be a finite number of milliseconds";
|
|
236
|
+
return {
|
|
237
|
+
...rect,
|
|
238
|
+
durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
197
241
|
const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
198
242
|
function loadSettings(file) {
|
|
199
243
|
try {
|
|
@@ -325,6 +369,52 @@ class ControlServer {
|
|
|
325
369
|
const capture = await this.deps.captureVisible();
|
|
326
370
|
return reply(200, { ok: true, ...capture });
|
|
327
371
|
}
|
|
372
|
+
case "captureTarget": {
|
|
373
|
+
const capture = await this.deps.captureTarget();
|
|
374
|
+
return reply(200, { ok: true, ...capture });
|
|
375
|
+
}
|
|
376
|
+
case "scroll": {
|
|
377
|
+
const pos = parseScrollPos(payload);
|
|
378
|
+
if (!pos) return reply(400, { error: "scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets" });
|
|
379
|
+
this.deps.scroll(pos);
|
|
380
|
+
return reply(200, { ok: true });
|
|
381
|
+
}
|
|
382
|
+
case "panTo": {
|
|
383
|
+
const pos = parseScrollPos(payload);
|
|
384
|
+
if (!pos) return reply(400, { error: "panTo payload must be { x, y } with finite, non-negative target-pixel coordinates" });
|
|
385
|
+
this.deps.apply({ panTo: pos });
|
|
386
|
+
return reply(200, { ok: true });
|
|
387
|
+
}
|
|
388
|
+
case "click": {
|
|
389
|
+
const click = parseClick(payload, this.deps.viewport());
|
|
390
|
+
if (typeof click === "string") return reply(400, { error: click });
|
|
391
|
+
this.deps.click(click);
|
|
392
|
+
return reply(200, { ok: true });
|
|
393
|
+
}
|
|
394
|
+
case "highlight": {
|
|
395
|
+
const highlight = parseHighlight(payload);
|
|
396
|
+
if (typeof highlight === "string") return reply(400, { error: highlight });
|
|
397
|
+
this.deps.apply({ highlight });
|
|
398
|
+
return reply(200, { ok: true });
|
|
399
|
+
}
|
|
400
|
+
case "back":
|
|
401
|
+
this.deps.back();
|
|
402
|
+
return reply(200, { ok: true });
|
|
403
|
+
case "forward":
|
|
404
|
+
this.deps.forward();
|
|
405
|
+
return reply(200, { ok: true });
|
|
406
|
+
case "reload":
|
|
407
|
+
this.deps.reload();
|
|
408
|
+
return reply(200, { ok: true });
|
|
409
|
+
case "setPixelExact": {
|
|
410
|
+
const err = pixelExactApplyError(payload.on);
|
|
411
|
+
if (err) return reply(400, { error: err });
|
|
412
|
+
this.deps.apply({ pixelExact: payload.on });
|
|
413
|
+
return reply(200, { ok: true });
|
|
414
|
+
}
|
|
415
|
+
case "focusWindow":
|
|
416
|
+
this.deps.focusWindow();
|
|
417
|
+
return reply(200, { ok: true });
|
|
328
418
|
}
|
|
329
419
|
}
|
|
330
420
|
/**
|
|
@@ -399,18 +489,23 @@ function registerIpc(ctx) {
|
|
|
399
489
|
assertRenderer(e);
|
|
400
490
|
return navigateBoth(url);
|
|
401
491
|
});
|
|
402
|
-
|
|
403
|
-
if (!fromRenderer(e)) return;
|
|
492
|
+
const reloadBoth = () => {
|
|
404
493
|
native.reload();
|
|
405
494
|
target.reload();
|
|
495
|
+
};
|
|
496
|
+
const goBack = () => native.back();
|
|
497
|
+
const goForward = () => native.forward();
|
|
498
|
+
electron.ipcMain.on(IPC.reload, (e) => {
|
|
499
|
+
if (!fromRenderer(e)) return;
|
|
500
|
+
reloadBoth();
|
|
406
501
|
});
|
|
407
502
|
electron.ipcMain.on(IPC.back, (e) => {
|
|
408
503
|
if (!fromRenderer(e)) return;
|
|
409
|
-
|
|
504
|
+
goBack();
|
|
410
505
|
});
|
|
411
506
|
electron.ipcMain.on(IPC.forward, (e) => {
|
|
412
507
|
if (!fromRenderer(e)) return;
|
|
413
|
-
|
|
508
|
+
goForward();
|
|
414
509
|
});
|
|
415
510
|
electron.ipcMain.handle(IPC.setViewport, (e, width, height, rawDsf) => {
|
|
416
511
|
assertRenderer(e);
|
|
@@ -488,6 +583,7 @@ function registerIpc(ctx) {
|
|
|
488
583
|
if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
|
|
489
584
|
});
|
|
490
585
|
const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
|
|
586
|
+
let targetBounds = null;
|
|
491
587
|
const MAX_PENDING_APPLIES = 32;
|
|
492
588
|
let rendererReported = false;
|
|
493
589
|
let warnedPendingOverflow = false;
|
|
@@ -496,7 +592,9 @@ function registerIpc(ctx) {
|
|
|
496
592
|
if (!fromRenderer(e)) return;
|
|
497
593
|
const s = parseUiState(raw);
|
|
498
594
|
if (!s) return;
|
|
499
|
-
|
|
595
|
+
const { targetBounds: bounds, ...state } = s;
|
|
596
|
+
Object.assign(uiState, state);
|
|
597
|
+
targetBounds = bounds ?? null;
|
|
500
598
|
if (!rendererReported) {
|
|
501
599
|
rendererReported = true;
|
|
502
600
|
for (const patch of pendingApplies.splice(0)) {
|
|
@@ -542,6 +640,42 @@ function registerIpc(ctx) {
|
|
|
542
640
|
const size = image.getSize();
|
|
543
641
|
return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
|
|
544
642
|
},
|
|
643
|
+
captureTarget: async () => {
|
|
644
|
+
const bounds = targetBounds;
|
|
645
|
+
const known = bounds !== null && bounds.width >= 1 && bounds.height >= 1;
|
|
646
|
+
const image = await win.webContents.capturePage(known ? bounds : void 0);
|
|
647
|
+
const size = image.getSize();
|
|
648
|
+
return {
|
|
649
|
+
data: image.toPNG().toString("base64"),
|
|
650
|
+
width: size.width,
|
|
651
|
+
height: size.height,
|
|
652
|
+
warnings: known ? [] : ["the renderer has not reported the target pane bounds yet; captured the full window instead"]
|
|
653
|
+
};
|
|
654
|
+
},
|
|
655
|
+
viewport: () => target.getViewport(),
|
|
656
|
+
scroll: (pos) => {
|
|
657
|
+
for (const wc of [native.webContents, target.webContents]) {
|
|
658
|
+
if (!wc.isDestroyed()) wc.send(IPC.applyScroll, pos);
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
click: (c) => {
|
|
662
|
+
const down = parseInputEvent({ type: "mouseDown", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
663
|
+
const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
664
|
+
if (!down || !up) return;
|
|
665
|
+
try {
|
|
666
|
+
target.sendInput(down);
|
|
667
|
+
target.sendInput(up);
|
|
668
|
+
} catch {
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
back: goBack,
|
|
672
|
+
forward: goForward,
|
|
673
|
+
reload: reloadBoth,
|
|
674
|
+
focusWindow: () => {
|
|
675
|
+
if (win.isDestroyed()) return;
|
|
676
|
+
win.show();
|
|
677
|
+
win.focus();
|
|
678
|
+
},
|
|
545
679
|
activity: () => {
|
|
546
680
|
if (!win.isDestroyed()) win.webContents.send(IPC.agentActivity);
|
|
547
681
|
}
|
package/out/mcp/lib.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
|
|
3
|
+
exports.PANE_CAPTURE_HEADLESS_NOTE = exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
|
|
4
4
|
exports.buildSnapArgs = buildSnapArgs;
|
|
5
5
|
exports.buildDiffArgs = buildDiffArgs;
|
|
6
6
|
exports.shouldInlineImage = shouldInlineImage;
|
|
@@ -105,6 +105,7 @@ Object.defineProperty(exports, "ALLOWED_URL_SCHEMES", { enumerable: true, get: f
|
|
|
105
105
|
Object.defineProperty(exports, "urlSchemeError", { enumerable: true, get: function () { return url_1.urlSchemeError; } });
|
|
106
106
|
exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desktop app and enable "Agent control" in the toolbar ' +
|
|
107
107
|
'(or pass mode: "headless" to render without it).';
|
|
108
|
+
exports.PANE_CAPTURE_HEADLESS_NOTE = "capture: 'pane' applies to live mode only; the headless render is the page raster itself, so the option was ignored.";
|
|
108
109
|
/**
|
|
109
110
|
* Decides whether an `obsrv_snap` call drives the visible app or renders
|
|
110
111
|
* headlessly (spec §14 "Live drive"), given whether a control-enabled app
|
|
@@ -117,16 +118,19 @@ exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desk
|
|
|
117
118
|
* full page), with a note.
|
|
118
119
|
* - `waitMs` is honoured headlessly; on the live path it is ignored with a
|
|
119
120
|
* note (the live capture settles on the app's own committed navigation).
|
|
121
|
+
* - `capture: 'pane'` shapes the live capture only; any headless outcome
|
|
122
|
+
* notes that it was ignored.
|
|
120
123
|
* - `mode: 'live'` with no reachable app is an error, never a silent
|
|
121
124
|
* headless fallback — the caller asked to watch.
|
|
122
125
|
*/
|
|
123
126
|
function planSnapPath(input, mode, liveReachable) {
|
|
127
|
+
const paneNote = input.capture === 'pane' ? [exports.PANE_CAPTURE_HEADLESS_NOTE] : [];
|
|
124
128
|
if (mode === 'headless')
|
|
125
|
-
return { path: 'headless', notes:
|
|
129
|
+
return { path: 'headless', notes: paneNote };
|
|
126
130
|
if (!liveReachable) {
|
|
127
131
|
if (mode === 'live')
|
|
128
132
|
return { error: exports.APP_NOT_REACHABLE };
|
|
129
|
-
return { path: 'headless', notes:
|
|
133
|
+
return { path: 'headless', notes: paneNote };
|
|
130
134
|
}
|
|
131
135
|
const notes = [];
|
|
132
136
|
const custom = input.width !== undefined ||
|
|
@@ -138,7 +142,7 @@ function planSnapPath(input, mode, liveReachable) {
|
|
|
138
142
|
if (input.fullPage)
|
|
139
143
|
notes.push('fullPage is headless-only; rendered headlessly instead of driving the app.');
|
|
140
144
|
if (notes.length > 0)
|
|
141
|
-
return { path: 'headless', notes };
|
|
145
|
+
return { path: 'headless', notes: [...notes, ...paneNote] };
|
|
142
146
|
if (input.waitMs !== undefined)
|
|
143
147
|
notes.push('waitMs is headless-only and was ignored in live mode.');
|
|
144
148
|
return { path: 'live', notes };
|
package/out/mcp/server.js
CHANGED
|
@@ -123,6 +123,11 @@ const snapInputShape = {
|
|
|
123
123
|
.optional()
|
|
124
124
|
.describe('auto (default): drive the visible Obsrv app when it is open with Agent control on, else render headlessly. ' +
|
|
125
125
|
'live: require the app (error if unreachable). headless: never touch the app.'),
|
|
126
|
+
capture: zod_1.z
|
|
127
|
+
.enum(['window', 'pane'])
|
|
128
|
+
.optional()
|
|
129
|
+
.describe("Live mode only: what the returned PNG shows — 'window' (default) is the whole app window, 'pane' is just " +
|
|
130
|
+
'the target pane (its footer readout included). Ignored (with a note) when the render is headless.'),
|
|
126
131
|
};
|
|
127
132
|
const snapOutputShape = {
|
|
128
133
|
mode: zod_1.z
|
|
@@ -143,8 +148,16 @@ const snapOutputShape = {
|
|
|
143
148
|
presetId: zod_1.z.string().optional().describe('Live only: the screen preset selected in the app.'),
|
|
144
149
|
profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
|
|
145
150
|
viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
|
|
146
|
-
width: zod_1.z
|
|
147
|
-
|
|
151
|
+
width: zod_1.z
|
|
152
|
+
.number()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe('Live only: captured width in device-independent px (the app window, or the target pane under capture: ' +
|
|
155
|
+
'"pane"); the PNG raster is this times the display scale.'),
|
|
156
|
+
height: zod_1.z
|
|
157
|
+
.number()
|
|
158
|
+
.optional()
|
|
159
|
+
.describe('Live only: captured height in device-independent px (the app window, or the target pane under capture: ' +
|
|
160
|
+
'"pane"); the PNG raster is this times the display scale.'),
|
|
148
161
|
};
|
|
149
162
|
const diffInputShape = {
|
|
150
163
|
url: urlField,
|
|
@@ -214,6 +227,34 @@ const driveInputShape = {
|
|
|
214
227
|
preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
|
|
215
228
|
profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
|
|
216
229
|
viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
|
|
230
|
+
pixelExact: zod_1.z.boolean().optional().describe("Toggle the toolbar's pixel-exact checkbox (pins the magnification to the host scale)."),
|
|
231
|
+
focus: zod_1.z.boolean().optional().describe('true: bring the Obsrv window to the front first, so the user sees what follows.'),
|
|
232
|
+
reload: zod_1.z.boolean().optional().describe('true: reload both panes (the same action as the toolbar reload).'),
|
|
233
|
+
back: zod_1.z.boolean().optional().describe('true: history back (native pane history; the target mirrors the committed page).'),
|
|
234
|
+
forward: zod_1.z.boolean().optional().describe('true: history forward (native pane history; the target mirrors it).'),
|
|
235
|
+
scroll: zod_1.z
|
|
236
|
+
.object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
|
|
237
|
+
.optional()
|
|
238
|
+
.describe('Scroll both panes to this absolute page offset in CSS px.'),
|
|
239
|
+
panTo: zod_1.z
|
|
240
|
+
.object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
|
|
241
|
+
.optional()
|
|
242
|
+
.describe("Centre this target-pane pixel (device px of the render) in the pane's 1:1 view; from fit this jumps to 1:1 there."),
|
|
243
|
+
click: zod_1.z
|
|
244
|
+
.object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
|
|
245
|
+
.optional()
|
|
246
|
+
.describe('Left-click the live page at these CSS-viewport coordinates (may navigate; refused outside the viewport).'),
|
|
247
|
+
highlight: zod_1.z
|
|
248
|
+
.object({
|
|
249
|
+
x: zod_1.z.number().min(0),
|
|
250
|
+
y: zod_1.z.number().min(0),
|
|
251
|
+
width: zod_1.z.number().min(1),
|
|
252
|
+
height: zod_1.z.number().min(1),
|
|
253
|
+
durationMs: zod_1.z.number().optional(),
|
|
254
|
+
})
|
|
255
|
+
.optional()
|
|
256
|
+
.describe('Draw a temporary neutral marker over this target-pixel rect in the pane (durationMs default 2000, clamped ' +
|
|
257
|
+
'250-10000). A new highlight replaces the previous one.'),
|
|
217
258
|
};
|
|
218
259
|
const driveOutputShape = {
|
|
219
260
|
version: zod_1.z.string().describe('The running app version.'),
|
|
@@ -232,6 +273,13 @@ const LIVE_APPLY_TIMEOUT_MS = 5_000;
|
|
|
232
273
|
const LIVE_CAPTURE_TIMEOUT_MS = 30_000;
|
|
233
274
|
/** How long a live snap waits for `status.url` to reflect the navigation. */
|
|
234
275
|
const LIVE_SETTLE_MS = 5_000;
|
|
276
|
+
/**
|
|
277
|
+
* How long an `obsrv_drive` click waits for a navigation it may have caused,
|
|
278
|
+
* so the returned status reflects it. Deliberately short: most clicks do not
|
|
279
|
+
* navigate, and every non-navigating one pays this in full.
|
|
280
|
+
*/
|
|
281
|
+
const CLICK_SETTLE_MS = 2_000;
|
|
282
|
+
const CLICK_SETTLE_POLL_MS = 250;
|
|
235
283
|
function liveFailure(e) {
|
|
236
284
|
const msg = e instanceof Error ? e.message : String(e);
|
|
237
285
|
return (`${msg}. If the Obsrv app was closed or Agent control was toggled off mid-call, ` +
|
|
@@ -287,9 +335,13 @@ async function liveSnap(app, input, notes) {
|
|
|
287
335
|
// (and any preset resize) a frame or two after the store confirms, and a
|
|
288
336
|
// capture racing that would show a half-applied flip.
|
|
289
337
|
await sleep(300);
|
|
338
|
+
// `capture: 'pane'` crops to the target pane; the command answers with the
|
|
339
|
+
// same { data, width, height } shape plus its own warnings (e.g. the
|
|
340
|
+
// pre-mount full-window fallback), which join the tool's.
|
|
290
341
|
let capture;
|
|
291
342
|
try {
|
|
292
|
-
|
|
343
|
+
const command = input.capture === 'pane' ? 'captureTarget' : 'captureVisible';
|
|
344
|
+
capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
|
|
293
345
|
}
|
|
294
346
|
catch (e) {
|
|
295
347
|
return toolError(liveFailure(e));
|
|
@@ -298,6 +350,11 @@ async function liveSnap(app, input, notes) {
|
|
|
298
350
|
if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
|
|
299
351
|
return toolError('the control server returned a malformed capture');
|
|
300
352
|
}
|
|
353
|
+
if (Array.isArray(capture['warnings'])) {
|
|
354
|
+
for (const w of capture['warnings'])
|
|
355
|
+
if (typeof w === 'string')
|
|
356
|
+
warnings.push(w);
|
|
357
|
+
}
|
|
301
358
|
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
302
359
|
const pngPath = (0, node_path_1.join)(dir, 'live.png');
|
|
303
360
|
await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
|
|
@@ -336,7 +393,8 @@ server.registerTool('obsrv_snap', {
|
|
|
336
393
|
`Live drive: when the Obsrv desktop app is open with its "Agent control" toolbar toggle on, \`mode: "auto"\` ` +
|
|
337
394
|
`(the default) drives the *visible* app instead — the user watches the URL load and the preset flip, and the ` +
|
|
338
395
|
`returned PNG is the app window as they see it (\`mode: "live"\` in the result; \`mode: "headless"\` ` +
|
|
339
|
-
`otherwise).
|
|
396
|
+
`otherwise). \`capture: "pane"\` crops a live capture to just the target pane (headless renders ignore it ` +
|
|
397
|
+
`with a note). Custom width/height and \`fullPage\` always render headlessly (with a note); \`waitMs\` is ` +
|
|
340
398
|
`ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
|
|
341
399
|
`touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
|
|
342
400
|
`the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
|
|
@@ -362,6 +420,9 @@ server.registerTool('obsrv_snap', {
|
|
|
362
420
|
return liveSnap(live, input, plan.notes);
|
|
363
421
|
liveNotes = plan.notes;
|
|
364
422
|
}
|
|
423
|
+
else if (input.capture === 'pane') {
|
|
424
|
+
liveNotes = [lib_1.PANE_CAPTURE_HEADLESS_NOTE];
|
|
425
|
+
}
|
|
365
426
|
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
366
427
|
const pngPath = (0, node_path_1.join)(dir, 'snap.png');
|
|
367
428
|
let args;
|
|
@@ -436,12 +497,20 @@ server.registerTool('obsrv_diff', {
|
|
|
436
497
|
server.registerTool('obsrv_drive', {
|
|
437
498
|
title: 'Drive the visible Obsrv app',
|
|
438
499
|
description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, a panel ` +
|
|
439
|
-
`profile,
|
|
440
|
-
|
|
441
|
-
`
|
|
500
|
+
`profile, the target pane's 1:1/fit view or pixel-exact toggle — each exactly as clicking the toolbar would ` +
|
|
501
|
+
`— and steer the session like a guided demo: focus the window, step history (back/forward/reload), scroll ` +
|
|
502
|
+
`both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
|
|
503
|
+
`neutral marker, all while the user watches.\n\n` +
|
|
504
|
+
`Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
|
|
505
|
+
`preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight. ` +
|
|
506
|
+
`The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
|
|
507
|
+
`click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. ` +
|
|
508
|
+
`Coordinates: click takes CSS-viewport px of the page (the valid range is 0 up to but not including the ` +
|
|
509
|
+
`viewport size); panTo and highlight take target-pane pixels (device px of the render — identical to CSS px ` +
|
|
510
|
+
`on 1x presets); scroll takes page CSS px.\n\n` +
|
|
442
511
|
`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
|
|
444
|
-
`obsrv_snap for a capture.`,
|
|
512
|
+
`mutates visible app state (it changes what the user's window shows, and a click can act on the live page) ` +
|
|
513
|
+
`but renders nothing itself — use obsrv_snap for a capture.`,
|
|
445
514
|
inputSchema: driveInputShape,
|
|
446
515
|
outputSchema: driveOutputShape,
|
|
447
516
|
// Honest annotation: this changes what the user's window is showing.
|
|
@@ -456,6 +525,10 @@ server.registerTool('obsrv_drive', {
|
|
|
456
525
|
if (!live)
|
|
457
526
|
return toolError(lib_1.APP_NOT_REACHABLE);
|
|
458
527
|
try {
|
|
528
|
+
// The documented execution order: window attention first, then what is
|
|
529
|
+
// showing, then how it is shown, then the in-page steering.
|
|
530
|
+
if (input.focus)
|
|
531
|
+
await (0, control_2.controlCall)(live.info, 'focusWindow', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
459
532
|
if (input.url !== undefined) {
|
|
460
533
|
await (0, control_2.controlCall)(live.info, 'navigate', { url: input.url.trim() }, args_1.DEFAULT_TIMEOUT_MS + 10_000);
|
|
461
534
|
}
|
|
@@ -466,6 +539,38 @@ server.registerTool('obsrv_drive', {
|
|
|
466
539
|
if (input.viewMode !== undefined) {
|
|
467
540
|
await (0, control_2.controlCall)(live.info, 'setViewMode', { mode: input.viewMode }, LIVE_APPLY_TIMEOUT_MS);
|
|
468
541
|
}
|
|
542
|
+
if (input.pixelExact !== undefined) {
|
|
543
|
+
await (0, control_2.controlCall)(live.info, 'setPixelExact', { on: input.pixelExact }, LIVE_APPLY_TIMEOUT_MS);
|
|
544
|
+
}
|
|
545
|
+
if (input.reload)
|
|
546
|
+
await (0, control_2.controlCall)(live.info, 'reload', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
547
|
+
if (input.back)
|
|
548
|
+
await (0, control_2.controlCall)(live.info, 'back', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
549
|
+
if (input.forward)
|
|
550
|
+
await (0, control_2.controlCall)(live.info, 'forward', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
551
|
+
if (input.scroll !== undefined)
|
|
552
|
+
await (0, control_2.controlCall)(live.info, 'scroll', input.scroll, LIVE_APPLY_TIMEOUT_MS);
|
|
553
|
+
if (input.panTo !== undefined)
|
|
554
|
+
await (0, control_2.controlCall)(live.info, 'panTo', input.panTo, LIVE_APPLY_TIMEOUT_MS);
|
|
555
|
+
if (input.click !== undefined) {
|
|
556
|
+
// A click may navigate. Note the URL first, then wait — bounded and
|
|
557
|
+
// short, the same settle idea as a live snap — for the status to move
|
|
558
|
+
// off it, so the returned status reflects what the click did. A click
|
|
559
|
+
// that navigates nowhere simply rides out the short deadline.
|
|
560
|
+
const before = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS))?.url ?? '';
|
|
561
|
+
await (0, control_2.controlCall)(live.info, 'click', input.click, LIVE_APPLY_TIMEOUT_MS);
|
|
562
|
+
const deadline = Date.now() + CLICK_SETTLE_MS;
|
|
563
|
+
for (;;) {
|
|
564
|
+
const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
565
|
+
if (s && s.url !== before && s.url !== 'about:blank')
|
|
566
|
+
break;
|
|
567
|
+
if (Date.now() >= deadline)
|
|
568
|
+
break;
|
|
569
|
+
await sleep(CLICK_SETTLE_POLL_MS);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (input.highlight !== undefined)
|
|
573
|
+
await (0, control_2.controlCall)(live.info, 'highlight', input.highlight, LIVE_APPLY_TIMEOUT_MS);
|
|
469
574
|
const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
470
575
|
if (!status)
|
|
471
576
|
return toolError('the control server returned a malformed status');
|
|
@@ -377,6 +377,9 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
377
377
|
position: fixed;
|
|
378
378
|
right: 16px;
|
|
379
379
|
bottom: 16px;
|
|
380
|
+
/* Above the stall notice (2) and the agent highlight (1): these are the
|
|
381
|
+
app talking to the user and must not sit under an overlay in the pane. */
|
|
382
|
+
z-index: 3;
|
|
380
383
|
padding: 8px 12px;
|
|
381
384
|
background: var(--chrome-2);
|
|
382
385
|
color: var(--text-0);
|
|
@@ -393,12 +396,28 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
393
396
|
`left: 0` holds while a wide canvas is scrolled horizontally. The fill is a
|
|
394
397
|
neutral grey: only the rule under it is red, so the one chromatic thing on
|
|
395
398
|
screen is the error itself. */
|
|
396
|
-
|
|
399
|
+
/* position: relative anchors the agent highlight to the canvas origin. */
|
|
400
|
+
.target-wrap { min-width: min-content; position: relative; }
|
|
401
|
+
|
|
402
|
+
/* Agent-control highlight: a temporary neutral marker over the target-pixel
|
|
403
|
+
rect an agent is pointing at. Style-spec law: no hue — a light outline with
|
|
404
|
+
a dark dashed inner edge reads on any page without tinting it — and no
|
|
405
|
+
pointer interception over the pixels under inspection. */
|
|
406
|
+
.agent-highlight {
|
|
407
|
+
position: absolute;
|
|
408
|
+
z-index: 1;
|
|
409
|
+
pointer-events: none;
|
|
410
|
+
outline: 2px solid var(--text-0);
|
|
411
|
+
border: 1px dashed var(--chrome-0);
|
|
412
|
+
box-sizing: border-box;
|
|
413
|
+
}
|
|
397
414
|
.stall {
|
|
398
415
|
position: sticky;
|
|
399
416
|
top: 0;
|
|
400
417
|
left: 0;
|
|
401
|
-
z-index:
|
|
418
|
+
/* Above the agent highlight (z-index 1): an error notice outranks a
|
|
419
|
+
demo marker. */
|
|
420
|
+
z-index: 2;
|
|
402
421
|
display: flex;
|
|
403
422
|
align-items: center;
|
|
404
423
|
gap: 10px;
|
|
@@ -12777,11 +12777,17 @@ const useStore = create()((set) => ({
|
|
|
12777
12777
|
surround: "graphite",
|
|
12778
12778
|
viewMode: "1:1",
|
|
12779
12779
|
fitScale: null,
|
|
12780
|
+
agentPan: null,
|
|
12781
|
+
agentHighlight: null,
|
|
12780
12782
|
// Does not clear `error`: a failed load navigates to Chromium's error page,
|
|
12781
12783
|
// so clearing here would wipe the toolbar badge the moment it appeared.
|
|
12782
|
-
|
|
12783
|
-
|
|
12784
|
-
|
|
12784
|
+
// Does clear the agent highlight: it marked pixels of the page that was
|
|
12785
|
+
// showing, and a committed navigation (a reload included) replaces them.
|
|
12786
|
+
setUrl: (url) => set({ url, agentHighlight: null }),
|
|
12787
|
+
// A screen change re-rasters the target, so a highlight's target-pixel rect
|
|
12788
|
+
// no longer marks what it marked; the same for the custom fields below.
|
|
12789
|
+
setPreset: (presetId) => set({ presetId, agentHighlight: null }),
|
|
12790
|
+
setCustom: (c) => set((s) => ({ custom: { ...s.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null })),
|
|
12785
12791
|
setPixelExact: (pixelExact) => set({ pixelExact }),
|
|
12786
12792
|
// Picking a profile drops any hand-tuned slider values.
|
|
12787
12793
|
setProfile: (profileId) => set({ profileId, profileOverride: null }),
|
|
@@ -12797,9 +12803,15 @@ const useStore = create()((set) => ({
|
|
|
12797
12803
|
setSurround: (surround) => set({ surround }),
|
|
12798
12804
|
setViewMode: (viewMode) => set({ viewMode }),
|
|
12799
12805
|
setFitScale: (fitScale2) => set({ fitScale: fitScale2 }),
|
|
12806
|
+
requestAgentPan: (p) => set((s) => ({ agentPan: { ...p, seq: (s.agentPan?.seq ?? 0) + 1 } })),
|
|
12807
|
+
clearAgentPan: () => set({ agentPan: null }),
|
|
12808
|
+
showAgentHighlight: (h) => set((s) => ({ agentHighlight: { ...h, seq: (s.agentHighlight?.seq ?? 0) + 1 } })),
|
|
12809
|
+
clearAgentHighlight: (seq) => set((s) => seq === void 0 || s.agentHighlight?.seq === seq ? { agentHighlight: null } : {}),
|
|
12800
12810
|
// Spec §7: leaving image mode restores the URL that was showing before.
|
|
12811
|
+
// Either direction swaps what the target pane shows, so a highlight over
|
|
12812
|
+
// the old content is dropped with it.
|
|
12801
12813
|
setMode: (mode) => set(
|
|
12802
|
-
(s) => mode === s.mode ? {} : mode === "image" ? { mode, lastUrl: s.url } : { mode, url: s.lastUrl, image: null }
|
|
12814
|
+
(s) => mode === s.mode ? {} : mode === "image" ? { mode, lastUrl: s.url, agentHighlight: null } : { mode, url: s.lastUrl, image: null, agentHighlight: null }
|
|
12803
12815
|
)
|
|
12804
12816
|
}));
|
|
12805
12817
|
function selectScreen(s) {
|
|
@@ -13797,17 +13809,30 @@ function computeFitScale(paneW, paneH, dpr, vpW, vpH, oneToOneScale) {
|
|
|
13797
13809
|
if (!usable(paneW, paneH, dpr, vpW, vpH, oneToOneScale)) return 1;
|
|
13798
13810
|
return Math.min(paneW * dpr / vpW, paneH * dpr / vpH, oneToOneScale);
|
|
13799
13811
|
}
|
|
13800
|
-
function
|
|
13801
|
-
if (!usable(dpr,
|
|
13812
|
+
function centreScroll(x, y, dpr, oneToOneScale, paneW, paneH, vpW, vpH) {
|
|
13813
|
+
if (!usable(dpr, oneToOneScale, paneW, paneH, vpW, vpH) || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
13802
13814
|
return { left: 0, top: 0 };
|
|
13803
13815
|
}
|
|
13804
|
-
const axis = (
|
|
13805
|
-
const target = click * dpr / fitScale2;
|
|
13816
|
+
const axis = (target, pane, vp) => {
|
|
13806
13817
|
const want = target * oneToOneScale / dpr - pane / 2;
|
|
13807
13818
|
const max = vp * oneToOneScale / dpr - pane;
|
|
13808
13819
|
return Math.min(Math.max(want, 0), Math.max(max, 0));
|
|
13809
13820
|
};
|
|
13810
|
-
return { left: axis(
|
|
13821
|
+
return { left: axis(x, paneW, vpW), top: axis(y, paneH, vpH) };
|
|
13822
|
+
}
|
|
13823
|
+
function jumpScroll(clickX, clickY, dpr, fitScale2, oneToOneScale, paneW, paneH, vpW, vpH) {
|
|
13824
|
+
if (!usable(fitScale2) || !Number.isFinite(clickX) || !Number.isFinite(clickY)) return { left: 0, top: 0 };
|
|
13825
|
+
if (!usable(dpr)) return { left: 0, top: 0 };
|
|
13826
|
+
return centreScroll(
|
|
13827
|
+
clickX * dpr / fitScale2,
|
|
13828
|
+
clickY * dpr / fitScale2,
|
|
13829
|
+
dpr,
|
|
13830
|
+
oneToOneScale,
|
|
13831
|
+
paneW,
|
|
13832
|
+
paneH,
|
|
13833
|
+
vpW,
|
|
13834
|
+
vpH
|
|
13835
|
+
);
|
|
13811
13836
|
}
|
|
13812
13837
|
const STALL_MS = 2e3;
|
|
13813
13838
|
function TargetCanvas({ onFatal, imageFrame }) {
|
|
@@ -14075,6 +14100,30 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14075
14100
|
);
|
|
14076
14101
|
setViewMode("1:1");
|
|
14077
14102
|
};
|
|
14103
|
+
const agentPan = useStore((s) => s.agentPan);
|
|
14104
|
+
const clearAgentPan = useStore((s) => s.clearAgentPan);
|
|
14105
|
+
reactExports.useEffect(() => {
|
|
14106
|
+
if (!agentPan) return;
|
|
14107
|
+
clearAgentPan();
|
|
14108
|
+
const jump = centreScroll(agentPan.x, agentPan.y, dpr, oneToOne, pane.width, pane.height, source.width, source.height);
|
|
14109
|
+
if (viewMode === "fit") {
|
|
14110
|
+
pendingJump.current = jump;
|
|
14111
|
+
setViewMode("1:1");
|
|
14112
|
+
return;
|
|
14113
|
+
}
|
|
14114
|
+
const body = paneBody();
|
|
14115
|
+
if (body) {
|
|
14116
|
+
body.scrollLeft = jump.left;
|
|
14117
|
+
body.scrollTop = jump.top;
|
|
14118
|
+
}
|
|
14119
|
+
}, [agentPan]);
|
|
14120
|
+
const agentHighlight = useStore((s) => s.agentHighlight);
|
|
14121
|
+
const clearAgentHighlight = useStore((s) => s.clearAgentHighlight);
|
|
14122
|
+
reactExports.useEffect(() => {
|
|
14123
|
+
if (!agentHighlight) return;
|
|
14124
|
+
const t = window.setTimeout(() => clearAgentHighlight(agentHighlight.seq), agentHighlight.durationMs);
|
|
14125
|
+
return () => window.clearTimeout(t);
|
|
14126
|
+
}, [agentHighlight, clearAgentHighlight]);
|
|
14078
14127
|
const send = (type) => (e) => {
|
|
14079
14128
|
if (mode !== "url") return;
|
|
14080
14129
|
if (viewMode !== "1:1" || panRef.current || e.button === 1) return;
|
|
@@ -14103,33 +14152,52 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14103
14152
|
}
|
|
14104
14153
|
)
|
|
14105
14154
|
] }),
|
|
14106
|
-
/* @__PURE__ */ jsxRuntimeExports.
|
|
14107
|
-
|
|
14108
|
-
|
|
14109
|
-
|
|
14110
|
-
|
|
14111
|
-
|
|
14112
|
-
|
|
14113
|
-
|
|
14114
|
-
|
|
14115
|
-
|
|
14116
|
-
|
|
14117
|
-
|
|
14118
|
-
|
|
14119
|
-
|
|
14120
|
-
|
|
14121
|
-
|
|
14122
|
-
|
|
14123
|
-
|
|
14124
|
-
|
|
14125
|
-
|
|
14126
|
-
|
|
14127
|
-
|
|
14128
|
-
|
|
14129
|
-
|
|
14155
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "target-wrap", children: [
|
|
14156
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14157
|
+
"canvas",
|
|
14158
|
+
{
|
|
14159
|
+
ref: canvasRef,
|
|
14160
|
+
className: `target-canvas${fit ? " fit" : panning ? " panning" : altHeld ? " pan-ready" : ""}`,
|
|
14161
|
+
tabIndex: 0,
|
|
14162
|
+
style: { width: `${cssW}px`, height: `${cssH}px` },
|
|
14163
|
+
onClick: jumpTo1x,
|
|
14164
|
+
onPointerDown: startPan,
|
|
14165
|
+
onPointerMove: movePan,
|
|
14166
|
+
onPointerUp: (e) => endPan(e, false),
|
|
14167
|
+
onPointerCancel: (e) => endPan(e, true),
|
|
14168
|
+
onMouseDown: send("mouseDown"),
|
|
14169
|
+
onMouseUp: send("mouseUp"),
|
|
14170
|
+
onMouseMove: send("mouseMove"),
|
|
14171
|
+
onKeyDown: (e) => {
|
|
14172
|
+
if (mode !== "url" || viewMode !== "1:1") return;
|
|
14173
|
+
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
|
|
14174
|
+
for (const ev of keyDownEvents(e)) window.obsrv.sendInput(ev);
|
|
14175
|
+
},
|
|
14176
|
+
onKeyUp: (e) => {
|
|
14177
|
+
if (mode !== "url" || viewMode !== "1:1") return;
|
|
14178
|
+
const ev = keyUpEvent(e);
|
|
14179
|
+
if (ev) window.obsrv.sendInput(ev);
|
|
14180
|
+
}
|
|
14130
14181
|
}
|
|
14131
|
-
|
|
14132
|
-
|
|
14182
|
+
),
|
|
14183
|
+
agentHighlight && // The agent-control highlight: a target-pixel rect drawn at the
|
|
14184
|
+
// canvas's own scale, absolutely positioned inside the scroll
|
|
14185
|
+
// content so it rides the pane's scroll. Neutral by style-spec law
|
|
14186
|
+
// (no hue) and pointer-events: none, so it never intercepts the
|
|
14187
|
+
// input the canvas forwards.
|
|
14188
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14189
|
+
"div",
|
|
14190
|
+
{
|
|
14191
|
+
className: "agent-highlight",
|
|
14192
|
+
style: {
|
|
14193
|
+
left: `${agentHighlight.x * scale / dpr}px`,
|
|
14194
|
+
top: `${agentHighlight.y * scale / dpr}px`,
|
|
14195
|
+
width: `${agentHighlight.width * scale / dpr}px`,
|
|
14196
|
+
height: `${agentHighlight.height * scale / dpr}px`
|
|
14197
|
+
}
|
|
14198
|
+
}
|
|
14199
|
+
)
|
|
14200
|
+
] })
|
|
14133
14201
|
] });
|
|
14134
14202
|
}
|
|
14135
14203
|
const DISMISS_MS = 4e3;
|
|
@@ -14355,6 +14423,8 @@ function App() {
|
|
|
14355
14423
|
const [drawer, setDrawer] = reactExports.useState("none");
|
|
14356
14424
|
const [image, setImage] = reactExports.useState(null);
|
|
14357
14425
|
const dropToken = reactExports.useRef(0);
|
|
14426
|
+
const targetPaneRef = reactExports.useRef(null);
|
|
14427
|
+
const [targetBounds, setTargetBounds] = reactExports.useState(null);
|
|
14358
14428
|
const toggle = (which) => () => setDrawer((d) => d === which ? "none" : which);
|
|
14359
14429
|
const setHost = useStore((s) => s.setHost);
|
|
14360
14430
|
const setSettings = useStore((s) => s.setSettings);
|
|
@@ -14397,14 +14467,29 @@ function App() {
|
|
|
14397
14467
|
window.obsrv.setMode(mode);
|
|
14398
14468
|
}, [mode]);
|
|
14399
14469
|
reactExports.useEffect(() => {
|
|
14400
|
-
|
|
14401
|
-
|
|
14470
|
+
const el = targetPaneRef.current;
|
|
14471
|
+
if (!el) return;
|
|
14472
|
+
const measure = () => {
|
|
14473
|
+
const r = el.getBoundingClientRect();
|
|
14474
|
+
setTargetBounds({ x: r.x, y: r.y, width: r.width, height: r.height });
|
|
14475
|
+
};
|
|
14476
|
+
const ro = new ResizeObserver(measure);
|
|
14477
|
+
ro.observe(el);
|
|
14478
|
+
measure();
|
|
14479
|
+
return () => ro.disconnect();
|
|
14480
|
+
}, []);
|
|
14481
|
+
reactExports.useEffect(() => {
|
|
14482
|
+
window.obsrv.reportUiState({ presetId, profileId, viewMode, mode, targetBounds });
|
|
14483
|
+
}, [presetId, profileId, viewMode, mode, targetBounds]);
|
|
14402
14484
|
reactExports.useEffect(() => {
|
|
14403
14485
|
return window.obsrv.onAgentApply((patch) => {
|
|
14404
14486
|
const s = useStore.getState();
|
|
14405
14487
|
if (patch.presetId !== void 0) s.setPreset(patch.presetId);
|
|
14406
14488
|
if (patch.profileId !== void 0) s.setProfile(patch.profileId);
|
|
14407
14489
|
if (patch.viewMode !== void 0) s.setViewMode(patch.viewMode);
|
|
14490
|
+
if (patch.pixelExact !== void 0) s.setPixelExact(patch.pixelExact);
|
|
14491
|
+
if (patch.panTo !== void 0) s.requestAgentPan(patch.panTo);
|
|
14492
|
+
if (patch.highlight !== void 0) s.showAgentHighlight(patch.highlight);
|
|
14408
14493
|
});
|
|
14409
14494
|
}, []);
|
|
14410
14495
|
reactExports.useEffect(() => {
|
|
@@ -14476,7 +14561,7 @@ function App() {
|
|
|
14476
14561
|
height: image.natural.height
|
|
14477
14562
|
}
|
|
14478
14563
|
) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {}),
|
|
14479
|
-
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", children: [
|
|
14564
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
|
|
14480
14565
|
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
|
|
14481
14566
|
/* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
|
|
14482
14567
|
] })
|
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-DyCD4ih_.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CHF-G97L.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/out/shared/control.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CONTROL_COMMANDS = exports.CONTROL_TOKEN_BYTES = exports.CONTROL_FILE_NAME = void 0;
|
|
3
|
+
exports.HIGHLIGHT_DURATION_MAX_MS = exports.HIGHLIGHT_DURATION_MIN_MS = exports.HIGHLIGHT_DURATION_DEFAULT_MS = exports.CONTROL_COMMANDS = exports.CONTROL_TOKEN_BYTES = exports.CONTROL_FILE_NAME = void 0;
|
|
4
4
|
exports.isControlCommand = isControlCommand;
|
|
5
5
|
exports.parseControlFile = parseControlFile;
|
|
6
6
|
exports.controlFileModeOk = controlFileModeOk;
|
|
@@ -9,9 +9,13 @@ exports.defaultControlFilePath = defaultControlFilePath;
|
|
|
9
9
|
exports.presetApplyError = presetApplyError;
|
|
10
10
|
exports.profileApplyError = profileApplyError;
|
|
11
11
|
exports.viewModeApplyError = viewModeApplyError;
|
|
12
|
+
exports.pixelExactApplyError = pixelExactApplyError;
|
|
13
|
+
exports.parseClick = parseClick;
|
|
14
|
+
exports.parseHighlight = parseHighlight;
|
|
12
15
|
exports.parseControlStatus = parseControlStatus;
|
|
13
16
|
const node_crypto_1 = require("node:crypto");
|
|
14
17
|
const node_path_1 = require("node:path");
|
|
18
|
+
const ipcPayloads_1 = require("./ipcPayloads");
|
|
15
19
|
const presets_1 = require("./presets");
|
|
16
20
|
/**
|
|
17
21
|
* The agent-control protocol shared by the main-process control server
|
|
@@ -45,6 +49,17 @@ exports.CONTROL_COMMANDS = [
|
|
|
45
49
|
'setProfile',
|
|
46
50
|
'setViewMode',
|
|
47
51
|
'captureVisible',
|
|
52
|
+
// v0.5 drive controls (spec §14 "Drive controls").
|
|
53
|
+
'scroll',
|
|
54
|
+
'panTo',
|
|
55
|
+
'click',
|
|
56
|
+
'highlight',
|
|
57
|
+
'back',
|
|
58
|
+
'forward',
|
|
59
|
+
'reload',
|
|
60
|
+
'setPixelExact',
|
|
61
|
+
'captureTarget',
|
|
62
|
+
'focusWindow',
|
|
48
63
|
];
|
|
49
64
|
function isControlCommand(v) {
|
|
50
65
|
return typeof v === 'string' && exports.CONTROL_COMMANDS.includes(v);
|
|
@@ -136,6 +151,64 @@ function profileApplyError(id) {
|
|
|
136
151
|
function viewModeApplyError(v) {
|
|
137
152
|
return v === '1:1' || v === 'fit' ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
|
|
138
153
|
}
|
|
154
|
+
function pixelExactApplyError(v) {
|
|
155
|
+
return typeof v === 'boolean' ? null : 'setPixelExact payload must be { on: boolean }';
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validates a `click` payload against the target's *current* CSS viewport:
|
|
159
|
+
* `sendInputEvent` takes CSS coordinates, and a click past the viewport edge
|
|
160
|
+
* would land on nothing (or, worse, on whatever the page scrolled there),
|
|
161
|
+
* so it is refused rather than clamped. The coordinate space is
|
|
162
|
+
* `[0, width) × [0, height)` — pixel row `height` is the first one *outside*
|
|
163
|
+
* a `height`-pixel viewport. The button defaults to left. Returns the
|
|
164
|
+
* validated click, or the error message.
|
|
165
|
+
*/
|
|
166
|
+
function parseClick(raw, viewport) {
|
|
167
|
+
const shape = 'click payload must be { x, y, button? } with finite CSS-pixel coordinates';
|
|
168
|
+
if (!isRecord(raw))
|
|
169
|
+
return shape;
|
|
170
|
+
const { x, y } = raw;
|
|
171
|
+
if (typeof x !== 'number' || !Number.isFinite(x) || typeof y !== 'number' || !Number.isFinite(y))
|
|
172
|
+
return shape;
|
|
173
|
+
if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
|
|
174
|
+
return `click (${x}, ${y}) is outside the current CSS viewport ${viewport.width}x${viewport.height}`;
|
|
175
|
+
}
|
|
176
|
+
const button = raw.button ?? 'left';
|
|
177
|
+
if (button !== 'left' && button !== 'middle' && button !== 'right') {
|
|
178
|
+
return 'click button must be left, middle or right';
|
|
179
|
+
}
|
|
180
|
+
return { x, y, button };
|
|
181
|
+
}
|
|
182
|
+
/** How long a highlight overlay stays up when the payload does not say. */
|
|
183
|
+
exports.HIGHLIGHT_DURATION_DEFAULT_MS = 2_000;
|
|
184
|
+
/** Shorter would flash imperceptibly; the payload is clamped, not refused. */
|
|
185
|
+
exports.HIGHLIGHT_DURATION_MIN_MS = 250;
|
|
186
|
+
/** Longer would squat on the pixels under inspection; clamped likewise. */
|
|
187
|
+
exports.HIGHLIGHT_DURATION_MAX_MS = 10_000;
|
|
188
|
+
/**
|
|
189
|
+
* Validates a `highlight` payload: the rect is checked exactly like a pane
|
|
190
|
+
* rect (`parseRect` — finite, non-negative, bounded, rounded) and must be at
|
|
191
|
+
* least 1×1 after rounding (an invisible highlight answering ok would lie).
|
|
192
|
+
* `durationMs` defaults and clamps rather than erroring — the exact lifetime
|
|
193
|
+
* is presentation, not correctness — but a non-numeric one is refused, never
|
|
194
|
+
* guessed. Returns the validated highlight, or the error message.
|
|
195
|
+
*/
|
|
196
|
+
function parseHighlight(raw) {
|
|
197
|
+
const rect = (0, ipcPayloads_1.parseRect)(raw);
|
|
198
|
+
if (!rect)
|
|
199
|
+
return 'highlight payload must be { x, y, width, height, durationMs? } with finite, non-negative target-pixel bounds';
|
|
200
|
+
if (rect.width < 1 || rect.height < 1)
|
|
201
|
+
return 'highlight rect must be at least 1x1 target pixels';
|
|
202
|
+
const d = raw.durationMs;
|
|
203
|
+
if (d === undefined)
|
|
204
|
+
return { ...rect, durationMs: exports.HIGHLIGHT_DURATION_DEFAULT_MS };
|
|
205
|
+
if (typeof d !== 'number' || !Number.isFinite(d))
|
|
206
|
+
return 'highlight durationMs must be a finite number of milliseconds';
|
|
207
|
+
return {
|
|
208
|
+
...rect,
|
|
209
|
+
durationMs: Math.min(Math.max(Math.round(d), exports.HIGHLIGHT_DURATION_MIN_MS), exports.HIGHLIGHT_DURATION_MAX_MS),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
139
212
|
/** Validates a control server `status` response on the client side. */
|
|
140
213
|
function parseControlStatus(raw) {
|
|
141
214
|
if (!isRecord(raw))
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_RECT = void 0;
|
|
4
|
+
exports.parseRect = parseRect;
|
|
5
|
+
exports.parseInputEvent = parseInputEvent;
|
|
6
|
+
exports.parseDeviceScaleFactor = parseDeviceScaleFactor;
|
|
7
|
+
exports.parseSettings = parseSettings;
|
|
8
|
+
exports.parseMode = parseMode;
|
|
9
|
+
exports.parseUiState = parseUiState;
|
|
10
|
+
exports.parseScrollPos = parseScrollPos;
|
|
11
|
+
/**
|
|
12
|
+
* Parsers for everything the renderer sends main over IPC. Each returns a
|
|
13
|
+
* fresh, fully-typed value or `null`; nothing from the wire is passed through
|
|
14
|
+
* by reference, so unknown keys never reach Electron, disk or `getSettings`.
|
|
15
|
+
* Main must never crash on a renderer message — every handler drops a `null`
|
|
16
|
+
* silently (or, for request/response channels, rejects the call).
|
|
17
|
+
*/
|
|
18
|
+
/** Largest coordinate or size a pane rect may carry; far beyond any real window. */
|
|
19
|
+
exports.MAX_RECT = 16384;
|
|
20
|
+
const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
21
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
22
|
+
function parseRect(raw) {
|
|
23
|
+
if (!isRecord(raw))
|
|
24
|
+
return null;
|
|
25
|
+
const { x, y, width, height } = raw;
|
|
26
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height))
|
|
27
|
+
return null;
|
|
28
|
+
const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
|
29
|
+
for (const v of [r.x, r.y, r.width, r.height])
|
|
30
|
+
if (v < 0 || v > exports.MAX_RECT)
|
|
31
|
+
return null;
|
|
32
|
+
return r;
|
|
33
|
+
}
|
|
34
|
+
const MODIFIERS = new Set([
|
|
35
|
+
'shift',
|
|
36
|
+
'control',
|
|
37
|
+
'alt',
|
|
38
|
+
'meta',
|
|
39
|
+
'leftButtonDown',
|
|
40
|
+
'middleButtonDown',
|
|
41
|
+
'rightButtonDown',
|
|
42
|
+
]);
|
|
43
|
+
const BUTTONS = new Set(['left', 'middle', 'right']);
|
|
44
|
+
/** Unknown entries are dropped; a missing or non-array list means no modifiers. */
|
|
45
|
+
function parseModifiers(raw) {
|
|
46
|
+
if (!Array.isArray(raw))
|
|
47
|
+
return [];
|
|
48
|
+
return raw.filter((m) => typeof m === 'string' && MODIFIERS.has(m));
|
|
49
|
+
}
|
|
50
|
+
function parseInputEvent(raw) {
|
|
51
|
+
if (!isRecord(raw))
|
|
52
|
+
return null;
|
|
53
|
+
const modifiers = parseModifiers(raw.modifiers);
|
|
54
|
+
switch (raw.type) {
|
|
55
|
+
case 'mouseDown':
|
|
56
|
+
case 'mouseUp':
|
|
57
|
+
case 'mouseMove': {
|
|
58
|
+
const { x, y, button, clickCount } = raw;
|
|
59
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(clickCount))
|
|
60
|
+
return null;
|
|
61
|
+
if (typeof button !== 'string' || !BUTTONS.has(button))
|
|
62
|
+
return null;
|
|
63
|
+
return { type: raw.type, x, y, button: button, clickCount, modifiers };
|
|
64
|
+
}
|
|
65
|
+
case 'mouseWheel': {
|
|
66
|
+
const { x, y, deltaX, deltaY } = raw;
|
|
67
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(deltaX) || !isFiniteNumber(deltaY))
|
|
68
|
+
return null;
|
|
69
|
+
return { type: 'mouseWheel', x, y, deltaX, deltaY, modifiers };
|
|
70
|
+
}
|
|
71
|
+
case 'keyDown':
|
|
72
|
+
case 'keyUp':
|
|
73
|
+
case 'char': {
|
|
74
|
+
const { keyCode } = raw;
|
|
75
|
+
if (typeof keyCode !== 'string')
|
|
76
|
+
return null;
|
|
77
|
+
return { type: raw.type, keyCode, modifiers };
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* `setViewport`'s device scale factor. Real screens run 1x-3x; 4 leaves
|
|
85
|
+
* headroom without letting a renderer ask for an absurd raster. A missing
|
|
86
|
+
* value means 1 (the pre-mobile wire shape); anything else out of range is
|
|
87
|
+
* refused, never clamped — main must not guess at a malformed payload.
|
|
88
|
+
*/
|
|
89
|
+
function parseDeviceScaleFactor(raw) {
|
|
90
|
+
if (raw === undefined)
|
|
91
|
+
return 1;
|
|
92
|
+
if (!isFiniteNumber(raw) || raw < 1 || raw > 4)
|
|
93
|
+
return null;
|
|
94
|
+
return raw;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Copies exactly the three known keys; the numbers must be finite and
|
|
98
|
+
* positive. A missing `agentControl` means false (the pre-live-drive wire
|
|
99
|
+
* shape); any non-boolean value is refused, never coerced.
|
|
100
|
+
*/
|
|
101
|
+
function parseSettings(raw) {
|
|
102
|
+
if (!isRecord(raw))
|
|
103
|
+
return null;
|
|
104
|
+
const { hostDiagonalInches, hostNits } = raw;
|
|
105
|
+
if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0)
|
|
106
|
+
return null;
|
|
107
|
+
if (!isFiniteNumber(hostNits) || hostNits <= 0)
|
|
108
|
+
return null;
|
|
109
|
+
const agentControl = raw.agentControl ?? false;
|
|
110
|
+
if (typeof agentControl !== 'boolean')
|
|
111
|
+
return null;
|
|
112
|
+
return { hostDiagonalInches, hostNits, agentControl };
|
|
113
|
+
}
|
|
114
|
+
function parseMode(raw) {
|
|
115
|
+
return raw === 'url' || raw === 'image' ? raw : null;
|
|
116
|
+
}
|
|
117
|
+
/** Longest preset/profile id the UI-state mirror will store. */
|
|
118
|
+
const MAX_UI_ID = 64;
|
|
119
|
+
/**
|
|
120
|
+
* The renderer's UI-state report (`IPC.uiState`), mirrored main-side so the
|
|
121
|
+
* agent-control server can answer `status` without a renderer round-trip.
|
|
122
|
+
* Ids are copied as opaque strings (bounded — the mirror must not store an
|
|
123
|
+
* arbitrarily long one) rather than checked against the preset table: the
|
|
124
|
+
* report *describes* renderer state, and refusing an id main does not know
|
|
125
|
+
* would leave the mirror lying about it.
|
|
126
|
+
*
|
|
127
|
+
* `targetBounds` (the pane rect `captureTarget` crops to) is advisory:
|
|
128
|
+
* malformed or missing bounds become null — the capture falls back to the
|
|
129
|
+
* full window — rather than dropping the whole report and starving the
|
|
130
|
+
* mirror of the state it *is* sure about.
|
|
131
|
+
*/
|
|
132
|
+
function parseUiState(raw) {
|
|
133
|
+
if (!isRecord(raw))
|
|
134
|
+
return null;
|
|
135
|
+
const { presetId, profileId, viewMode, mode } = raw;
|
|
136
|
+
if (typeof presetId !== 'string' || presetId.length === 0 || presetId.length > MAX_UI_ID)
|
|
137
|
+
return null;
|
|
138
|
+
if (typeof profileId !== 'string' || profileId.length === 0 || profileId.length > MAX_UI_ID)
|
|
139
|
+
return null;
|
|
140
|
+
if (viewMode !== '1:1' && viewMode !== 'fit')
|
|
141
|
+
return null;
|
|
142
|
+
if (mode !== 'url' && mode !== 'image')
|
|
143
|
+
return null;
|
|
144
|
+
return { presetId, profileId, viewMode, mode, targetBounds: parseRect(raw.targetBounds) };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A scroll offset reported by the sync preload in a page webContents. Both
|
|
148
|
+
* axes must be finite and non-negative; anything else is dropped rather than
|
|
149
|
+
* relayed to the other pane.
|
|
150
|
+
*/
|
|
151
|
+
function parseScrollPos(raw) {
|
|
152
|
+
if (!isRecord(raw))
|
|
153
|
+
return null;
|
|
154
|
+
const { x, y } = raw;
|
|
155
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0)
|
|
156
|
+
return null;
|
|
157
|
+
return { x, y };
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getobsrv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "See your site the way 1x screens see it",
|
|
5
5
|
"main": "./out/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"build:mcp": "tsc -p tsconfig.mcp.json",
|
|
22
22
|
"prepublishOnly": "npm run build",
|
|
23
23
|
"prepack": "node scripts/electron-dep.js to-prod",
|
|
24
|
-
"postpack": "node scripts/electron-dep.js to-dev"
|
|
24
|
+
"postpack": "node scripts/electron-dep.js to-dev",
|
|
25
|
+
"release:pack": "npm run build && npm pack"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
|
@@ -50,8 +50,9 @@ If the obsrv MCP tools are connected (`obsrv_snap` / `obsrv_diff` /
|
|
|
50
50
|
`obsrv_presets`), prefer them over shelling out — same pipeline, and the PNG
|
|
51
51
|
comes back inline. If the Obsrv desktop app is open with "Agent control" on
|
|
52
52
|
(toolbar toggle), snaps drive the visible window — the user watches — and
|
|
53
|
-
`obsrv_drive` flips its URL/preset/profile directly
|
|
54
|
-
|
|
53
|
+
`obsrv_drive` flips its URL/preset/profile directly, and can also scroll,
|
|
54
|
+
click, pan and highlight to walk the user through what it found; no app
|
|
55
|
+
means the usual headless render.
|
|
55
56
|
|
|
56
57
|
## The loop that catches real regressions
|
|
57
58
|
|