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.
@@ -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
- setUrl: (url) => set({ url }),
12783
- setPreset: (presetId) => set({ presetId }),
12784
- setCustom: (c) => set((s) => ({ custom: { ...s.custom, ...c }, presetId: CUSTOM_PRESET_ID })),
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 jumpScroll(clickX, clickY, dpr, fitScale2, oneToOneScale, paneW, paneH, vpW, vpH) {
13801
- if (!usable(dpr, fitScale2, oneToOneScale, paneW, paneH, vpW, vpH) || !Number.isFinite(clickX) || !Number.isFinite(clickY)) {
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 = (click, pane, vp) => {
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(clickX, paneW, vpW), top: axis(clickY, paneH, vpH) };
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.jsx("div", { className: "target-wrap", children: /* @__PURE__ */ jsxRuntimeExports.jsx(
14107
- "canvas",
14108
- {
14109
- ref: canvasRef,
14110
- className: `target-canvas${fit ? " fit" : panning ? " panning" : altHeld ? " pan-ready" : ""}`,
14111
- tabIndex: 0,
14112
- style: { width: `${cssW}px`, height: `${cssH}px` },
14113
- onClick: jumpTo1x,
14114
- onPointerDown: startPan,
14115
- onPointerMove: movePan,
14116
- onPointerUp: (e) => endPan(e, false),
14117
- onPointerCancel: (e) => endPan(e, true),
14118
- onMouseDown: send("mouseDown"),
14119
- onMouseUp: send("mouseUp"),
14120
- onMouseMove: send("mouseMove"),
14121
- onKeyDown: (e) => {
14122
- if (mode !== "url" || viewMode !== "1:1") return;
14123
- if (!e.metaKey && !e.ctrlKey) e.preventDefault();
14124
- for (const ev of keyDownEvents(e)) window.obsrv.sendInput(ev);
14125
- },
14126
- onKeyUp: (e) => {
14127
- if (mode !== "url" || viewMode !== "1:1") return;
14128
- const ev = keyUpEvent(e);
14129
- if (ev) window.obsrv.sendInput(ev);
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
- window.obsrv.reportUiState({ presetId, profileId, viewMode, mode });
14401
- }, [presetId, profileId, viewMode, mode]);
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
  ] })
@@ -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-P496vEhv.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-FtoKShe_.css">
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>
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
+ }
@@ -1,2 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_SCROLL_SELECTOR = void 0;
4
+ /** Longest `scrollSelector` accepted; a CSS selector far beyond any real one. */
5
+ exports.MAX_SCROLL_SELECTOR = 512;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.4.1",
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; no app means the usual
54
- headless render.
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