getobsrv 0.4.1 → 0.6.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 +34 -4
- package/out/cli/args.js +5 -1
- package/out/main/cli.js +51 -4
- package/out/main/index.js +249 -53
- package/out/main/{targetSource-DkXWE0ha.js → targetSource-BKW32VA5.js} +15 -5
- package/out/mcp/lib.js +8 -4
- package/out/mcp/server.js +173 -12
- package/out/preload/sync.js +102 -5
- 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 +208 -0
- package/out/shared/types.js +3 -0
- package/package.json +3 -2
- package/skills/obsrv-screens/SKILL.md +3 -2
|
@@ -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,208 @@
|
|
|
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
|
+
exports.parseScrollRequest = parseScrollRequest;
|
|
12
|
+
exports.parseScrollReport = parseScrollReport;
|
|
13
|
+
const types_1 = require("./types");
|
|
14
|
+
/**
|
|
15
|
+
* Parsers for everything the renderer sends main over IPC. Each returns a
|
|
16
|
+
* fresh, fully-typed value or `null`; nothing from the wire is passed through
|
|
17
|
+
* by reference, so unknown keys never reach Electron, disk or `getSettings`.
|
|
18
|
+
* Main must never crash on a renderer message — every handler drops a `null`
|
|
19
|
+
* silently (or, for request/response channels, rejects the call).
|
|
20
|
+
*/
|
|
21
|
+
/** Largest coordinate or size a pane rect may carry; far beyond any real window. */
|
|
22
|
+
exports.MAX_RECT = 16384;
|
|
23
|
+
/** Most warnings a pane's scroll reply may carry; the preload sends at most one. */
|
|
24
|
+
const MAX_SCROLL_WARNINGS = 4;
|
|
25
|
+
const isFiniteNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
26
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
27
|
+
function parseRect(raw) {
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
return null;
|
|
30
|
+
const { x, y, width, height } = raw;
|
|
31
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height))
|
|
32
|
+
return null;
|
|
33
|
+
const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
|
34
|
+
for (const v of [r.x, r.y, r.width, r.height])
|
|
35
|
+
if (v < 0 || v > exports.MAX_RECT)
|
|
36
|
+
return null;
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
const MODIFIERS = new Set([
|
|
40
|
+
'shift',
|
|
41
|
+
'control',
|
|
42
|
+
'alt',
|
|
43
|
+
'meta',
|
|
44
|
+
'leftButtonDown',
|
|
45
|
+
'middleButtonDown',
|
|
46
|
+
'rightButtonDown',
|
|
47
|
+
]);
|
|
48
|
+
const BUTTONS = new Set(['left', 'middle', 'right']);
|
|
49
|
+
/** Unknown entries are dropped; a missing or non-array list means no modifiers. */
|
|
50
|
+
function parseModifiers(raw) {
|
|
51
|
+
if (!Array.isArray(raw))
|
|
52
|
+
return [];
|
|
53
|
+
return raw.filter((m) => typeof m === 'string' && MODIFIERS.has(m));
|
|
54
|
+
}
|
|
55
|
+
function parseInputEvent(raw) {
|
|
56
|
+
if (!isRecord(raw))
|
|
57
|
+
return null;
|
|
58
|
+
const modifiers = parseModifiers(raw.modifiers);
|
|
59
|
+
switch (raw.type) {
|
|
60
|
+
case 'mouseDown':
|
|
61
|
+
case 'mouseUp':
|
|
62
|
+
case 'mouseMove': {
|
|
63
|
+
const { x, y, button, clickCount } = raw;
|
|
64
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(clickCount))
|
|
65
|
+
return null;
|
|
66
|
+
if (typeof button !== 'string' || !BUTTONS.has(button))
|
|
67
|
+
return null;
|
|
68
|
+
return { type: raw.type, x, y, button: button, clickCount, modifiers };
|
|
69
|
+
}
|
|
70
|
+
case 'mouseWheel': {
|
|
71
|
+
const { x, y, deltaX, deltaY } = raw;
|
|
72
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(deltaX) || !isFiniteNumber(deltaY))
|
|
73
|
+
return null;
|
|
74
|
+
return { type: 'mouseWheel', x, y, deltaX, deltaY, modifiers };
|
|
75
|
+
}
|
|
76
|
+
case 'keyDown':
|
|
77
|
+
case 'keyUp':
|
|
78
|
+
case 'char': {
|
|
79
|
+
const { keyCode } = raw;
|
|
80
|
+
if (typeof keyCode !== 'string')
|
|
81
|
+
return null;
|
|
82
|
+
return { type: raw.type, keyCode, modifiers };
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `setViewport`'s device scale factor. Real screens run 1x-3x; 4 leaves
|
|
90
|
+
* headroom without letting a renderer ask for an absurd raster. A missing
|
|
91
|
+
* value means 1 (the pre-mobile wire shape); anything else out of range is
|
|
92
|
+
* refused, never clamped — main must not guess at a malformed payload.
|
|
93
|
+
*/
|
|
94
|
+
function parseDeviceScaleFactor(raw) {
|
|
95
|
+
if (raw === undefined)
|
|
96
|
+
return 1;
|
|
97
|
+
if (!isFiniteNumber(raw) || raw < 1 || raw > 4)
|
|
98
|
+
return null;
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Copies exactly the three known keys; the numbers must be finite and
|
|
103
|
+
* positive. A missing `agentControl` means false (the pre-live-drive wire
|
|
104
|
+
* shape); any non-boolean value is refused, never coerced.
|
|
105
|
+
*/
|
|
106
|
+
function parseSettings(raw) {
|
|
107
|
+
if (!isRecord(raw))
|
|
108
|
+
return null;
|
|
109
|
+
const { hostDiagonalInches, hostNits } = raw;
|
|
110
|
+
if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0)
|
|
111
|
+
return null;
|
|
112
|
+
if (!isFiniteNumber(hostNits) || hostNits <= 0)
|
|
113
|
+
return null;
|
|
114
|
+
const agentControl = raw.agentControl ?? false;
|
|
115
|
+
if (typeof agentControl !== 'boolean')
|
|
116
|
+
return null;
|
|
117
|
+
return { hostDiagonalInches, hostNits, agentControl };
|
|
118
|
+
}
|
|
119
|
+
function parseMode(raw) {
|
|
120
|
+
return raw === 'url' || raw === 'image' ? raw : null;
|
|
121
|
+
}
|
|
122
|
+
/** Longest preset/profile id the UI-state mirror will store. */
|
|
123
|
+
const MAX_UI_ID = 64;
|
|
124
|
+
/**
|
|
125
|
+
* The renderer's UI-state report (`IPC.uiState`), mirrored main-side so the
|
|
126
|
+
* agent-control server can answer `status` without a renderer round-trip.
|
|
127
|
+
* Ids are copied as opaque strings (bounded — the mirror must not store an
|
|
128
|
+
* arbitrarily long one) rather than checked against the preset table: the
|
|
129
|
+
* report *describes* renderer state, and refusing an id main does not know
|
|
130
|
+
* would leave the mirror lying about it.
|
|
131
|
+
*
|
|
132
|
+
* `targetBounds` (the pane rect `captureTarget` crops to) is advisory:
|
|
133
|
+
* malformed or missing bounds become null — the capture falls back to the
|
|
134
|
+
* full window — rather than dropping the whole report and starving the
|
|
135
|
+
* mirror of the state it *is* sure about.
|
|
136
|
+
*/
|
|
137
|
+
function parseUiState(raw) {
|
|
138
|
+
if (!isRecord(raw))
|
|
139
|
+
return null;
|
|
140
|
+
const { presetId, profileId, viewMode, mode } = raw;
|
|
141
|
+
if (typeof presetId !== 'string' || presetId.length === 0 || presetId.length > MAX_UI_ID)
|
|
142
|
+
return null;
|
|
143
|
+
if (typeof profileId !== 'string' || profileId.length === 0 || profileId.length > MAX_UI_ID)
|
|
144
|
+
return null;
|
|
145
|
+
if (viewMode !== '1:1' && viewMode !== 'fit')
|
|
146
|
+
return null;
|
|
147
|
+
if (mode !== 'url' && mode !== 'image')
|
|
148
|
+
return null;
|
|
149
|
+
return { presetId, profileId, viewMode, mode, targetBounds: parseRect(raw.targetBounds) };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A scroll offset reported by the sync preload in a page webContents. Both
|
|
153
|
+
* axes must be finite and non-negative; anything else is dropped rather than
|
|
154
|
+
* relayed to the other pane.
|
|
155
|
+
*/
|
|
156
|
+
function parseScrollPos(raw) {
|
|
157
|
+
if (!isRecord(raw))
|
|
158
|
+
return null;
|
|
159
|
+
const { x, y } = raw;
|
|
160
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0)
|
|
161
|
+
return null;
|
|
162
|
+
return { x, y };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A `scroll` command payload: an offset plus the optional `scrollSelector`
|
|
166
|
+
* escape hatch. The selector is only ever handed to `document.querySelector`
|
|
167
|
+
* in the preload's isolated world — never evaluated — but it is still bounded
|
|
168
|
+
* and type-checked here so a malformed one is refused with an explanation
|
|
169
|
+
* rather than silently ignored by the page. Returns the parsed request, or the
|
|
170
|
+
* error message.
|
|
171
|
+
*/
|
|
172
|
+
function parseScrollRequest(raw) {
|
|
173
|
+
const pos = parseScrollPos(raw);
|
|
174
|
+
if (!pos)
|
|
175
|
+
return 'scroll payload must be { x, y } with finite, non-negative CSS-pixel offsets';
|
|
176
|
+
const selector = raw.scrollSelector;
|
|
177
|
+
if (selector === undefined || selector === null)
|
|
178
|
+
return pos;
|
|
179
|
+
if (typeof selector !== 'string')
|
|
180
|
+
return 'scrollSelector must be a CSS selector string';
|
|
181
|
+
const trimmed = selector.trim();
|
|
182
|
+
if (trimmed === '')
|
|
183
|
+
return 'scrollSelector must not be empty';
|
|
184
|
+
if (trimmed.length > types_1.MAX_SCROLL_SELECTOR)
|
|
185
|
+
return `scrollSelector must be at most ${types_1.MAX_SCROLL_SELECTOR} characters`;
|
|
186
|
+
return { ...pos, selector: trimmed };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A pane's `IPC.scrollResult` reply. Sent by the sync preload, which runs
|
|
190
|
+
* beside a third-party page, so it is parsed exactly like any renderer
|
|
191
|
+
* message; anything malformed is dropped and the caller times out rather than
|
|
192
|
+
* reporting an offset it cannot trust.
|
|
193
|
+
*/
|
|
194
|
+
function parseScrollReport(raw) {
|
|
195
|
+
if (!isRecord(raw))
|
|
196
|
+
return null;
|
|
197
|
+
const { id, x, y, scroller } = raw;
|
|
198
|
+
if (!isFiniteNumber(id))
|
|
199
|
+
return null;
|
|
200
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y))
|
|
201
|
+
return null;
|
|
202
|
+
if (scroller !== 'root' && scroller !== 'element')
|
|
203
|
+
return null;
|
|
204
|
+
const warnings = Array.isArray(raw.warnings)
|
|
205
|
+
? raw.warnings.filter((w) => typeof w === 'string').slice(0, MAX_SCROLL_WARNINGS)
|
|
206
|
+
: [];
|
|
207
|
+
return { id, x, y, scroller, warnings };
|
|
208
|
+
}
|
package/out/shared/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getobsrv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
|