getobsrv 0.11.0 → 0.12.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.
@@ -5,6 +5,8 @@ const node_path = require("node:path");
5
5
  const MAX_VIEWPORT = 4096;
6
6
  const SPLIT_MIN = 0.1;
7
7
  const SPLIT_MAX = 0.9;
8
+ const MAX_TABS_MIN = 2;
9
+ const MAX_TABS_MAX = 32;
8
10
  const DEFAULT_SETTINGS = {
9
11
  hostDiagonalInches: 27,
10
12
  hostNits: 500,
@@ -12,7 +14,8 @@ const DEFAULT_SETTINGS = {
12
14
  updateCheck: true,
13
15
  lastUpdateCheck: 0,
14
16
  recordHistory: true,
15
- split: 0.5
17
+ split: 0.5,
18
+ maxTabs: 12
16
19
  };
17
20
  const SCREEN_PRESETS = [
18
21
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -144,6 +147,12 @@ class TargetSource extends node_events.EventEmitter {
144
147
  */
145
148
  internal = false;
146
149
  disposed = false;
150
+ /**
151
+ * What the owner asked for, not what the current window happens to be doing.
152
+ * See `setPainting` — `recreate()` swaps in a fresh webContents that starts
153
+ * painting, so the wish has to outlive the window that was serving it.
154
+ */
155
+ paintingWanted = true;
147
156
  constructor(fps = DEFAULT_FPS, options = {}) {
148
157
  super();
149
158
  this.fps = fps;
@@ -171,6 +180,7 @@ class TargetSource extends node_events.EventEmitter {
171
180
  this.firstNavDone = false;
172
181
  const wc = win.webContents;
173
182
  wc.setFrameRate(this.fps);
183
+ if (!this.paintingWanted) wc.stopPainting();
174
184
  wc.setAudioMuted(true);
175
185
  this.defaultUserAgent ??= wc.getUserAgent();
176
186
  wc.setUserAgent(this.dsf > 1 && this.mobileEmulation ? MOBILE_USER_AGENT : this.defaultUserAgent);
@@ -245,6 +255,16 @@ class TargetSource extends node_events.EventEmitter {
245
255
  if (this.win === win) this.firstNavDone = true;
246
256
  });
247
257
  }
258
+ /**
259
+ * Resolves once this window's own initial `about:blank` has committed.
260
+ * A navigation issued before that lands is undone by it — the commit
261
+ * arrives late and `SyncBus` mirrors it into the native pane — so a caller
262
+ * that builds a source and immediately drives it waits here first. Follows
263
+ * the current window: a dsf change swaps in a fresh one with a fresh gate.
264
+ */
265
+ get ready() {
266
+ return this.firstNavigation;
267
+ }
248
268
  /**
249
269
  * Mobile viewport semantics for dsf > 1 (see class doc). Post-commit only:
250
270
  * enabling emulation before a window's first navigation commits segfaults
@@ -344,6 +364,26 @@ class TargetSource extends node_events.EventEmitter {
344
364
  getDeviceScaleFactor() {
345
365
  return this.dsf;
346
366
  }
367
+ /**
368
+ * Stops or resumes rasterisation without touching the page. Offscreen
369
+ * rendering runs at a fixed frame rate with `backgroundThrottling: false`,
370
+ * so a source nobody is looking at would otherwise paint a full viewport
371
+ * forever for nobody. The page keeps its DOM, timers, network and scroll —
372
+ * only pixel production stops.
373
+ */
374
+ setPainting(painting) {
375
+ if (this.paintingWanted === painting) return;
376
+ this.paintingWanted = painting;
377
+ if (this.win.isDestroyed()) return;
378
+ const wc = this.win.webContents;
379
+ if (wc.isDestroyed()) return;
380
+ if (painting) wc.startPainting();
381
+ else wc.stopPainting();
382
+ }
383
+ /** What was last asked of `setPainting`, not what the window is doing. */
384
+ get painting() {
385
+ return this.paintingWanted;
386
+ }
347
387
  /** Forces a full-frame repaint, e.g. after the renderer loses its texture. */
348
388
  invalidate() {
349
389
  if (!this.win.isDestroyed()) this.win.webContents.invalidate();
@@ -372,6 +412,8 @@ class TargetSource extends node_events.EventEmitter {
372
412
  exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
373
413
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
374
414
  exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
415
+ exports.MAX_TABS_MAX = MAX_TABS_MAX;
416
+ exports.MAX_TABS_MIN = MAX_TABS_MIN;
375
417
  exports.PANEL_PROFILES = PANEL_PROFILES;
376
418
  exports.SCREEN_PRESETS = SCREEN_PRESETS;
377
419
  exports.SPLIT_MAX = SPLIT_MAX;
package/out/mcp/server.js CHANGED
@@ -161,6 +161,8 @@ const snapOutputShape = {
161
161
  profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
162
162
  viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
163
163
  panes: zod_1.z.string().optional().describe("Live only: 'both' (native pane beside the target) or 'target' (the target render has the whole window)."),
164
+ tabId: zod_1.z.string().optional().describe('Live only: which of the app\'s tabs was captured (the active one). Empty from an app older than tabs.'),
165
+ tabIndex: zod_1.z.number().optional().describe("Live only: that tab's 0-based position in the strip."),
164
166
  width: zod_1.z
165
167
  .number()
166
168
  .optional()
@@ -310,6 +312,12 @@ const driveOutputShape = {
310
312
  viewMode: zod_1.z.string(),
311
313
  panes: zod_1.z.string(),
312
314
  mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
315
+ tabId: zod_1.z
316
+ .string()
317
+ .describe('Which of the app\'s tabs this acted on. Every command resolves the active tab as it arrives, so a tabId ' +
318
+ 'that changed between two calls means the user switched tabs under you. Empty string from an app older ' +
319
+ 'than tabs, which has only one.'),
320
+ tabIndex: zod_1.z.number().describe('That tab\'s 0-based position in the strip.'),
313
321
  scrolled: zod_1.z
314
322
  .object({ x: zod_1.z.number(), y: zod_1.z.number() })
315
323
  .nullable()
@@ -482,6 +490,8 @@ async function liveSnap(app, input, notes) {
482
490
  profileId: status.profileId,
483
491
  viewMode: status.viewMode,
484
492
  panes: status.panes,
493
+ tabId: status.tabId,
494
+ tabIndex: status.tabIndex,
485
495
  width,
486
496
  height,
487
497
  settled,
@@ -520,7 +530,11 @@ server.registerTool('obsrv_snap', {
520
530
  `capture; that visible steering is the point of live mode.\n\n` +
521
531
  `A live snap only navigates when the app is showing a different URL; the result's \`navigated\` says which ` +
522
532
  `happened. Navigating is a fresh load, so it starts at the top of the page — to photograph a scrolled or ` +
523
- `panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.`,
533
+ `panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.\n\n` +
534
+ `Tabs: the app can hold several sessions open as tabs, and a live snap acts on the one in front — which the ` +
535
+ `user can change at any moment. The result names it (\`tabId\`, \`tabIndex\`); compare across calls if you ` +
536
+ `need to know it did not move. There is no way to name a different tab, and no way to open, close or ` +
537
+ `switch tabs — those are the user's.`,
524
538
  inputSchema: snapInputShape,
525
539
  outputSchema: snapOutputShape,
526
540
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -641,6 +655,12 @@ server.registerTool('obsrv_drive', {
641
655
  `pass \`url\`, so this is how you photograph a scrolled or panned state: scroll, then capture, in one call. ` +
642
656
  `obsrv_snap is the other way round — it points the app at a URL first, and pointing it somewhere new is a ` +
643
657
  `fresh load that starts at the top.\n\n` +
658
+ `Tabs: the app can hold several sessions open as tabs, each with its own URL, screen preset and page state. ` +
659
+ `Every command here acts on whichever tab is in front *when that command arrives* — nothing is bound to a ` +
660
+ `tab for the length of the call — and the returned status names it (\`tabId\`, \`tabIndex\`). A \`tabId\` ` +
661
+ `that changed between two calls means the user switched tabs under you; re-read the state before trusting ` +
662
+ `what you knew. You cannot name a different tab, nor open, close or switch tabs — those are the user's. An ` +
663
+ `empty \`tabId\` means an app older than tabs, which has only the one.\n\n` +
644
664
  `Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
645
665
  `mutates visible app state (it changes what the user's window shows, and a click can act on the live page).`,
646
666
  inputSchema: driveInputShape,
@@ -16,6 +16,7 @@ const IPC = {
16
16
  frame: "obsrv:frame",
17
17
  frameSubscribe: "obsrv:frame-subscribe",
18
18
  urlChanged: "obsrv:url-changed",
19
+ titleChanged: "obsrv:title-changed",
19
20
  loadError: "obsrv:load-error",
20
21
  hostChanged: "obsrv:host-changed",
21
22
  targetLoading: "obsrv:target-loading",
@@ -34,7 +35,12 @@ const IPC = {
34
35
  updateStatus: "obsrv:update-status",
35
36
  getHistory: "obsrv:get-history",
36
37
  clearHistory: "obsrv:clear-history",
37
- historyChanged: "obsrv:history-changed"
38
+ historyChanged: "obsrv:history-changed",
39
+ getTabs: "obsrv:get-tabs",
40
+ addTab: "obsrv:add-tab",
41
+ closeTab: "obsrv:close-tab",
42
+ activateTab: "obsrv:activate-tab",
43
+ tabsChanged: "obsrv:tabs-changed"
38
44
  };
39
45
  function subscribe(channel, cb) {
40
46
  const listener = (_e, v) => cb(v);
@@ -69,24 +75,16 @@ const api = {
69
75
  getSettings: () => electron.ipcRenderer.invoke(IPC.getSettings),
70
76
  setSettings: (s) => electron.ipcRenderer.invoke(IPC.setSettings, s),
71
77
  onFrame: subscribeFrames,
78
+ // Each of these names the tab it describes: main no longer gates them on the
79
+ // tab being in front, so a background tab keeps its own strip entry current
80
+ // without touching the address bar of the tab that is showing.
72
81
  onUrlChanged: (cb) => subscribe(IPC.urlChanged, cb),
82
+ onTitleChanged: (cb) => subscribe(IPC.titleChanged, cb),
73
83
  onLoadError: (cb) => subscribe(IPC.loadError, cb),
74
84
  onHostChanged: (cb) => subscribe(IPC.hostChanged, cb),
75
85
  onTargetLoading: (cb) => subscribe(IPC.targetLoading, cb),
76
- onNativeFocused: (cb) => {
77
- const listener = () => cb();
78
- electron.ipcRenderer.on(IPC.nativeFocused, listener);
79
- return () => {
80
- electron.ipcRenderer.removeListener(IPC.nativeFocused, listener);
81
- };
82
- },
83
- onTargetNavigating: (cb) => {
84
- const listener = () => cb();
85
- electron.ipcRenderer.on(IPC.targetNavigating, listener);
86
- return () => {
87
- electron.ipcRenderer.removeListener(IPC.targetNavigating, listener);
88
- };
89
- },
86
+ onNativeFocused: (cb) => subscribe(IPC.nativeFocused, cb),
87
+ onTargetNavigating: (cb) => subscribe(IPC.targetNavigating, cb),
90
88
  onOpenImage: (cb) => {
91
89
  const listener = () => cb();
92
90
  electron.ipcRenderer.on(IPC.openImage, listener);
@@ -118,6 +116,11 @@ const api = {
118
116
  onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb),
119
117
  getHistory: () => electron.ipcRenderer.invoke(IPC.getHistory),
120
118
  clearHistory: () => electron.ipcRenderer.invoke(IPC.clearHistory),
121
- onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb)
119
+ onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb),
120
+ getTabs: () => electron.ipcRenderer.invoke(IPC.getTabs),
121
+ addTab: () => electron.ipcRenderer.invoke(IPC.addTab),
122
+ closeTab: (id) => electron.ipcRenderer.send(IPC.closeTab, id),
123
+ activateTab: (id) => electron.ipcRenderer.send(IPC.activateTab, id),
124
+ onTabsChanged: (cb) => subscribe(IPC.tabsChanged, cb)
122
125
  };
123
126
  electron.contextBridge.exposeInMainWorld("obsrv", api);
@@ -170,10 +170,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
170
170
  .warn { color: var(--warn); }
171
171
  .chrome .warn { white-space: nowrap; }
172
172
 
173
- /* Two strips. Browsing above; the controls that describe the simulated
174
- screen below, on a darker ground so the rows read as separate registers.
175
- 44 + 38 = 82 must equal TOOLBAR_H in src/main/ipc.ts. Pinned heights also
176
- keep a late reflow from resizing the panes under NativeSlot's observer. */
173
+ /* Three strips. The tab strip on top furthest from the panes, as every
174
+ browser puts it; browsing below it; the controls that describe the simulated
175
+ screen last, on a darker ground so the rows read as separate registers.
176
+ 32 + 44 + 38 = 114 must equal TOOLBAR_H in src/main/ipc.ts. Pinned heights
177
+ also keep a late reflow from resizing the panes under NativeSlot's
178
+ observer. */
177
179
  .chrome { flex: 0 0 auto; }
178
180
  .chrome-row {
179
181
  box-sizing: border-box;
@@ -191,6 +193,107 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
191
193
  .chrome-screen { height: 38px; background: var(--chrome-0); }
192
194
  .chrome-spacer { flex: 1 1 auto; }
193
195
 
196
+ /* The tab strip. Ground is the darkest chrome step so an inactive tab (one
197
+ step up) already reads as a raised object, and the active one has somewhere
198
+ further to go. Neutral throughout: a coloured tab beside the panes would
199
+ shift the perceived hue of the render under test, which is the one thing
200
+ this chrome may never do. */
201
+ .chrome-tabs { height: 32px; gap: 4px; padding: 0 6px; background: var(--chrome-0); }
202
+ /* The tabs scroll rather than shrink past legibility; the new-tab button is
203
+ outside this box so it never scrolls away from the pointer. */
204
+ .tabs { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
205
+ .tabs::-webkit-scrollbar { display: none; }
206
+
207
+ .tab {
208
+ flex: 0 1 auto;
209
+ min-width: 0;
210
+ max-width: 180px;
211
+ height: 24px;
212
+ display: flex;
213
+ align-items: center;
214
+ background: var(--chrome-1);
215
+ border: 1px solid var(--line);
216
+ border-radius: 4px;
217
+ }
218
+ .tab-label {
219
+ flex: 1 1 auto;
220
+ min-width: 0;
221
+ height: 100%;
222
+ padding: 0 2px 0 8px;
223
+ background: none;
224
+ border: 0;
225
+ color: var(--text-1);
226
+ font: inherit;
227
+ text-align: left;
228
+ white-space: nowrap;
229
+ overflow: hidden;
230
+ text-overflow: ellipsis;
231
+ cursor: pointer;
232
+ }
233
+ .tab:hover { background: var(--chrome-2); }
234
+ /* The chrome's one selected idiom: the `--chrome-3` fill step plus weight,
235
+ exactly as `.segmented button[aria-pressed]` and `.menu-row[aria-pressed]`
236
+ mark theirs. Selection is carried by the label button's `aria-selected`, so
237
+ the fill is derived from it rather than duplicated onto the wrapper. */
238
+ .tab:has(> .tab-label[aria-selected='true']) { background: var(--chrome-3); border-color: var(--chrome-3); }
239
+ .tab > .tab-label[aria-selected='true'] { color: var(--text-0); font-weight: 600; }
240
+
241
+ /* The driven tab, while agent control is on. The 2px inset rule `.menu-row`
242
+ and `.surround-control` already use to mark a choice — the same idiom, in
243
+ the same direction, so it reads as "this one" and not as a new language.
244
+ It sits on the leading edge rather than surrounding the tab, so it never
245
+ competes with the selected fill underneath it (the driven tab is always the
246
+ selected one, and two full outlines on one control would be noise).
247
+
248
+ Neutral, and only neutral: --text-1 standing, --text-0 for ~3s after each
249
+ command, exactly the step the AGENT chip makes. No hue and no glow — a blur
250
+ here would be the first shadow in the app, and the rule against it is what
251
+ stops the chrome biasing the render below. */
252
+ .tab.driven { box-shadow: inset 2px 0 0 var(--text-1); }
253
+ .tab.driven.busy { box-shadow: inset 2px 0 0 var(--text-0); }
254
+
255
+ .tab-close {
256
+ flex: 0 0 auto;
257
+ width: 18px;
258
+ height: 18px;
259
+ margin-right: 3px;
260
+ display: inline-flex;
261
+ align-items: center;
262
+ justify-content: center;
263
+ padding: 0;
264
+ background: none;
265
+ border: 0;
266
+ border-radius: 3px;
267
+ color: var(--text-1);
268
+ cursor: pointer;
269
+ }
270
+ /* Only on hover of the tab: sixteen ✕ glyphs at rest is a busier strip than
271
+ the titles they sit beside, and the titles are the content here. */
272
+ .tab-close { opacity: 0; }
273
+ .tab:hover .tab-close,
274
+ .tab:has(> .tab-label[aria-selected='true']) .tab-close,
275
+ .tab-close:focus-visible { opacity: 1; }
276
+ .tab-close:hover { background: var(--chrome-2); color: var(--text-0); }
277
+
278
+ .tab-new {
279
+ flex: 0 0 auto;
280
+ width: 24px;
281
+ height: 24px;
282
+ display: inline-flex;
283
+ align-items: center;
284
+ justify-content: center;
285
+ padding: 0;
286
+ background: var(--chrome-2);
287
+ border: 1px solid var(--line);
288
+ border-radius: 4px;
289
+ color: var(--text-0);
290
+ cursor: pointer;
291
+ }
292
+ .tab-new:hover:not(:disabled) { background: var(--chrome-3); }
293
+ /* At the cap. Dimmed rather than hidden, and the `title` says why and where to
294
+ raise it — a control that vanishes teaches nothing. */
295
+ .tab-new:disabled { color: var(--text-1); background: var(--chrome-1); cursor: default; }
296
+
194
297
  /* No blanket `button` rule here, ever. The old `.toolbar button { width:
195
298
  26px }` was specificity (0,1,1) and silently clipped the update button to
196
299
  "v0."; every control now sizes itself. */