getobsrv 0.11.0 → 0.13.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,8 +14,13 @@ 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
  };
20
+ const DEFAULT_ORIENTATION = "portrait";
21
+ function isOrientation(v) {
22
+ return v === "portrait" || v === "landscape";
23
+ }
17
24
  const SCREEN_PRESETS = [
18
25
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
19
26
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -52,6 +59,9 @@ function findProfile(id) {
52
59
  if (!p) throw new Error(`unknown profile: ${id}`);
53
60
  return p;
54
61
  }
62
+ function screenShape(width, height) {
63
+ return width > height ? "landscape" : "portrait";
64
+ }
55
65
  function maxCssViewport(deviceScaleFactor) {
56
66
  const dsf = Number.isFinite(deviceScaleFactor) && deviceScaleFactor > 1 ? deviceScaleFactor : 1;
57
67
  return Math.max(1, Math.floor(MAX_VIEWPORT / dsf));
@@ -144,6 +154,12 @@ class TargetSource extends node_events.EventEmitter {
144
154
  */
145
155
  internal = false;
146
156
  disposed = false;
157
+ /**
158
+ * What the owner asked for, not what the current window happens to be doing.
159
+ * See `setPainting` — `recreate()` swaps in a fresh webContents that starts
160
+ * painting, so the wish has to outlive the window that was serving it.
161
+ */
162
+ paintingWanted = true;
147
163
  constructor(fps = DEFAULT_FPS, options = {}) {
148
164
  super();
149
165
  this.fps = fps;
@@ -171,6 +187,7 @@ class TargetSource extends node_events.EventEmitter {
171
187
  this.firstNavDone = false;
172
188
  const wc = win.webContents;
173
189
  wc.setFrameRate(this.fps);
190
+ if (!this.paintingWanted) wc.stopPainting();
174
191
  wc.setAudioMuted(true);
175
192
  this.defaultUserAgent ??= wc.getUserAgent();
176
193
  wc.setUserAgent(this.dsf > 1 && this.mobileEmulation ? MOBILE_USER_AGENT : this.defaultUserAgent);
@@ -245,6 +262,16 @@ class TargetSource extends node_events.EventEmitter {
245
262
  if (this.win === win) this.firstNavDone = true;
246
263
  });
247
264
  }
265
+ /**
266
+ * Resolves once this window's own initial `about:blank` has committed.
267
+ * A navigation issued before that lands is undone by it — the commit
268
+ * arrives late and `SyncBus` mirrors it into the native pane — so a caller
269
+ * that builds a source and immediately drives it waits here first. Follows
270
+ * the current window: a dsf change swaps in a fresh one with a fresh gate.
271
+ */
272
+ get ready() {
273
+ return this.firstNavigation;
274
+ }
248
275
  /**
249
276
  * Mobile viewport semantics for dsf > 1 (see class doc). Post-commit only:
250
277
  * enabling emulation before a window's first navigation commits segfaults
@@ -344,6 +371,26 @@ class TargetSource extends node_events.EventEmitter {
344
371
  getDeviceScaleFactor() {
345
372
  return this.dsf;
346
373
  }
374
+ /**
375
+ * Stops or resumes rasterisation without touching the page. Offscreen
376
+ * rendering runs at a fixed frame rate with `backgroundThrottling: false`,
377
+ * so a source nobody is looking at would otherwise paint a full viewport
378
+ * forever for nobody. The page keeps its DOM, timers, network and scroll —
379
+ * only pixel production stops.
380
+ */
381
+ setPainting(painting) {
382
+ if (this.paintingWanted === painting) return;
383
+ this.paintingWanted = painting;
384
+ if (this.win.isDestroyed()) return;
385
+ const wc = this.win.webContents;
386
+ if (wc.isDestroyed()) return;
387
+ if (painting) wc.startPainting();
388
+ else wc.stopPainting();
389
+ }
390
+ /** What was last asked of `setPainting`, not what the window is doing. */
391
+ get painting() {
392
+ return this.paintingWanted;
393
+ }
347
394
  /** Forces a full-frame repaint, e.g. after the renderer loses its texture. */
348
395
  invalidate() {
349
396
  if (!this.win.isDestroyed()) this.win.webContents.invalidate();
@@ -370,8 +417,11 @@ class TargetSource extends node_events.EventEmitter {
370
417
  }
371
418
  }
372
419
  exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
420
+ exports.DEFAULT_ORIENTATION = DEFAULT_ORIENTATION;
373
421
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
374
422
  exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
423
+ exports.MAX_TABS_MAX = MAX_TABS_MAX;
424
+ exports.MAX_TABS_MIN = MAX_TABS_MIN;
375
425
  exports.PANEL_PROFILES = PANEL_PROFILES;
376
426
  exports.SCREEN_PRESETS = SCREEN_PRESETS;
377
427
  exports.SPLIT_MAX = SPLIT_MAX;
@@ -380,6 +430,8 @@ exports.TargetSource = TargetSource;
380
430
  exports.classifyFileNavigation = classifyFileNavigation;
381
431
  exports.findPreset = findPreset;
382
432
  exports.findProfile = findProfile;
433
+ exports.isOrientation = isOrientation;
383
434
  exports.maxCssViewport = maxCssViewport;
384
435
  exports.normalizeUrl = normalizeUrl;
436
+ exports.screenShape = screenShape;
385
437
  exports.urlSchemeError = urlSchemeError;
package/out/mcp/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
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;
3
+ exports.ORIENTATION_NOTE = 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;
@@ -46,6 +46,8 @@ function buildSnapArgs(input, outPath) {
46
46
  const args = ['snap', input.url];
47
47
  if (input.preset !== undefined)
48
48
  args.push('--preset', input.preset);
49
+ if (input.orientation !== undefined)
50
+ args.push('--orientation', input.orientation);
49
51
  if (custom) {
50
52
  args.push('--width', String(input.width), '--height', String(input.height));
51
53
  if (input.deviceScaleFactor !== undefined)
@@ -170,9 +172,20 @@ function extractTrailingJson(stdout) {
170
172
  }
171
173
  return null;
172
174
  }
175
+ /**
176
+ * What `obsrv_presets` says about rotation. Stated once here rather than
177
+ * repeated per entry: it is true of every preset in the table, and a field
178
+ * saying "rotatable: true" fourteen times would carry less than one sentence.
179
+ */
180
+ exports.ORIENTATION_NOTE = 'cssWidth/cssHeight are each preset\'s natural orientation — portrait for every mobile preset, ' +
181
+ 'landscape for the monitor and laptop ones. Every preset rotates: pass orientation: "landscape" ' +
182
+ 'to obsrv_snap or obsrv_drive to swap the two axes a quarter turn. Rotation changes nothing else — ' +
183
+ 'the diagonal, deviceScaleFactor, ppi and physical size are all orientation-independent, so a ' +
184
+ 'rotated screen is the same panel turned sideways rather than a different one.';
173
185
  /** The `obsrv_presets` payload, straight from src/shared/presets.ts — no spawn. */
174
186
  function listCatalog() {
175
187
  return {
188
+ orientation: exports.ORIENTATION_NOTE,
176
189
  presets: presets_1.SCREEN_PRESETS.map(p => ({
177
190
  id: p.id,
178
191
  label: p.label,
package/out/mcp/server.js CHANGED
@@ -97,12 +97,21 @@ const profileField = zod_1.z
97
97
  .enum(PROFILE_IDS)
98
98
  .optional()
99
99
  .describe('Panel simulation (contrast floor, gamut, bit depth, brightness). Default: reference (off).');
100
+ const orientationField = zod_1.z
101
+ .enum(['portrait', 'landscape'])
102
+ .optional()
103
+ .describe('Rotate the screen a quarter turn (default portrait). Presets store their natural orientation — ' +
104
+ 'portrait for every mobile preset, landscape for the monitors and laptops — and this swaps the CSS ' +
105
+ "viewport's two axes on top of that. Nothing else changes: the diagonal, raster density and physical " +
106
+ 'size are orientation-independent, so it is the same panel turned sideways. Use it to check a ' +
107
+ 'landscape phone layout, or a monitor stood on end.');
100
108
  const snapInputShape = {
101
109
  url: urlField,
102
110
  preset: zod_1.z
103
111
  .enum(PRESET_IDS)
104
112
  .optional()
105
113
  .describe('Screen preset id (list them with obsrv_presets). Mutually exclusive with width/height. Default: 1080p-24.'),
114
+ orientation: orientationField,
106
115
  width: zod_1.z.number().int().min(1).optional().describe('Custom CSS viewport width in px. Needs height; mutually exclusive with preset.'),
107
116
  height: zod_1.z.number().int().min(1).optional().describe('Custom CSS viewport height in px. Needs width.'),
108
117
  deviceScaleFactor: zod_1.z
@@ -138,8 +147,24 @@ const snapOutputShape = {
138
147
  .describe('How the snap was produced: a headless render, or a capture of the visible Obsrv app window (live drive).'),
139
148
  out: zod_1.z.string().optional().describe('Headless only: PNG path the CLI wrote (same file as pngPath).'),
140
149
  preset: zod_1.z.string().optional().describe('Headless only: preset id, or "custom" for width/height runs.'),
141
- cssWidth: zod_1.z.number().optional().describe('Headless only: applied CSS viewport width.'),
142
- cssHeight: zod_1.z.number().optional().describe('Headless only: applied CSS viewport height (grown under fullPage).'),
150
+ cssWidth: zod_1.z
151
+ .number()
152
+ .optional()
153
+ .describe('Applied CSS viewport width, already rotated. Headless: grown under fullPage. Live: what the app is rendering.'),
154
+ cssHeight: zod_1.z
155
+ .number()
156
+ .optional()
157
+ .describe('Applied CSS viewport height, already rotated. Headless: grown under fullPage. Live: what the app is rendering.'),
158
+ orientation: zod_1.z
159
+ .string()
160
+ .optional()
161
+ .describe("Live only: the app's rotation flag — 'portrait' (the preset as its table stores it) or 'landscape' " +
162
+ '(rotated a quarter turn). See `screenShape` for the shape that produced. Headless runs report the ' +
163
+ 'applied `cssWidth`/`cssHeight` instead, which say the same thing exactly.'),
164
+ screenShape: zod_1.z.string().optional().describe('Live only. ' + "The shape the screen actually has: 'portrait' or 'landscape'. Derived from the CSS dimensions, not from " +
165
+ "the `orientation` flag beside it — the flag means 'the preset as its table stores it' vs 'rotated a " +
166
+ "quarter turn', so for a landscape-natural monitor preset the two diverge (a fresh 1080p-24 tab is " +
167
+ "orientation 'portrait' on a 1920x1080 landscape screen). Report this word to the user, not the flag."),
143
168
  deviceScaleFactor: zod_1.z.number().optional().describe('Headless only.'),
144
169
  profile: zod_1.z.string().optional().describe('Headless only: applied panel profile id.'),
145
170
  settled: zod_1.z
@@ -161,6 +186,8 @@ const snapOutputShape = {
161
186
  profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
162
187
  viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
163
188
  panes: zod_1.z.string().optional().describe("Live only: 'both' (native pane beside the target) or 'target' (the target render has the whole window)."),
189
+ 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.'),
190
+ tabIndex: zod_1.z.number().optional().describe("Live only: that tab's 0-based position in the strip."),
164
191
  width: zod_1.z
165
192
  .number()
166
193
  .optional()
@@ -216,6 +243,9 @@ const diffOutputShape = {
216
243
  findings: zod_1.z.array(zod_1.z.string()).describe('Humanised per-band findings. Informational — thresholds are the caller\'s job.'),
217
244
  };
218
245
  const presetsOutputShape = {
246
+ orientation: zod_1.z
247
+ .string()
248
+ .describe('How the cssWidth/cssHeight below relate to rotation, and how to ask for the other orientation.'),
219
249
  presets: zod_1.z.array(zod_1.z.object({
220
250
  id: zod_1.z.string(),
221
251
  label: zod_1.z.string(),
@@ -244,6 +274,7 @@ const driveInputShape = {
244
274
  .optional()
245
275
  .describe('Navigate the app (both panes) to this http://, https:// or file:// URL (bare hosts also work).'),
246
276
  preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
277
+ orientation: orientationField,
247
278
  profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
248
279
  viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
249
280
  panes: zod_1.z
@@ -307,9 +338,28 @@ const driveOutputShape = {
307
338
  url: zod_1.z.string().describe('The URL the target pane reports showing.'),
308
339
  presetId: zod_1.z.string(),
309
340
  profileId: zod_1.z.string(),
341
+ orientation: zod_1.z
342
+ .string()
343
+ .describe("The rotation flag: 'portrait' (the preset as its table stores it) or 'landscape' (rotated a quarter " +
344
+ "turn). This is what to pass back to change it — for the shape the screen actually has, read " +
345
+ '`screenShape`. Reported as \'portrait\' by an app older than rotation, which is what such an app shows.'),
346
+ screenShape: zod_1.z.string().describe("The shape the screen actually has: 'portrait' or 'landscape'. Derived from the CSS dimensions, not from " +
347
+ "the `orientation` flag beside it — the flag means 'the preset as its table stores it' vs 'rotated a " +
348
+ "quarter turn', so for a landscape-natural monitor preset the two diverge (a fresh 1080p-24 tab is " +
349
+ "orientation 'portrait' on a 1920x1080 landscape screen). Report this word to the user, not the flag."),
350
+ cssWidth: zod_1.z
351
+ .number()
352
+ .describe('The CSS viewport the target is rendering at, already rotated. 0 from an app that predates the field.'),
353
+ cssHeight: zod_1.z.number().describe('The CSS viewport height, already rotated. 0 from an app that predates the field.'),
310
354
  viewMode: zod_1.z.string(),
311
355
  panes: zod_1.z.string(),
312
356
  mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
357
+ tabId: zod_1.z
358
+ .string()
359
+ .describe('Which of the app\'s tabs this acted on. Every command resolves the active tab as it arrives, so a tabId ' +
360
+ 'that changed between two calls means the user switched tabs under you. Empty string from an app older ' +
361
+ 'than tabs, which has only one.'),
362
+ tabIndex: zod_1.z.number().describe('That tab\'s 0-based position in the strip.'),
313
363
  scrolled: zod_1.z
314
364
  .object({ x: zod_1.z.number(), y: zod_1.z.number() })
315
365
  .nullable()
@@ -434,6 +484,9 @@ async function liveSnap(app, input, notes) {
434
484
  }
435
485
  if (input.preset !== undefined)
436
486
  await (0, control_2.controlCall)(info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
487
+ if (input.orientation !== undefined) {
488
+ await (0, control_2.controlCall)(info, 'setOrientation', { orientation: input.orientation }, LIVE_APPLY_TIMEOUT_MS);
489
+ }
437
490
  if (input.profile !== undefined)
438
491
  await (0, control_2.controlCall)(info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
439
492
  }
@@ -480,8 +533,14 @@ async function liveSnap(app, input, notes) {
480
533
  url: status.url,
481
534
  presetId: status.presetId,
482
535
  profileId: status.profileId,
536
+ orientation: status.orientation,
537
+ screenShape: status.screenShape,
538
+ cssWidth: status.cssWidth,
539
+ cssHeight: status.cssHeight,
483
540
  viewMode: status.viewMode,
484
541
  panes: status.panes,
542
+ tabId: status.tabId,
543
+ tabIndex: status.tabIndex,
485
544
  width,
486
545
  height,
487
546
  settled,
@@ -505,7 +564,8 @@ server.registerTool('obsrv_snap', {
505
564
  `presets, the device's 2x/3x DPR plus mobile UA and viewport semantics for phone presets — optionally ` +
506
565
  `through a cheap-panel simulation, and return the PNG. Use it to judge how a page actually looks on the ` +
507
566
  `screens users own (1366×768 laptops, 1080p desktops, budget Androids) before declaring frontend work done.\n\n` +
508
- `Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both. ` +
567
+ `Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both; either can be ` +
568
+ `rotated with \`orientation: "landscape"\`, which is how you check a phone's landscape layout. ` +
509
569
  `Returns structured metadata (applied viewport, profile, \`settled\`, warnings, and \`pngPath\` — the PNG ` +
510
570
  `kept in a per-call temp dir) plus the PNG as an inline image when it is within the 1.5 MiB cap; larger ` +
511
571
  `captures (typically fullPage) stay on disk with a note.\n\n` +
@@ -520,7 +580,11 @@ server.registerTool('obsrv_snap', {
520
580
  `capture; that visible steering is the point of live mode.\n\n` +
521
581
  `A live snap only navigates when the app is showing a different URL; the result's \`navigated\` says which ` +
522
582
  `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.`,
583
+ `panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.\n\n` +
584
+ `Tabs: the app can hold several sessions open as tabs, and a live snap acts on the one in front — which the ` +
585
+ `user can change at any moment. The result names it (\`tabId\`, \`tabIndex\`); compare across calls if you ` +
586
+ `need to know it did not move. There is no way to name a different tab, and no way to open, close or ` +
587
+ `switch tabs — those are the user's.`,
524
588
  inputSchema: snapInputShape,
525
589
  outputSchema: snapOutputShape,
526
590
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -621,15 +685,16 @@ server.registerTool('obsrv_diff', {
621
685
  });
622
686
  server.registerTool('obsrv_drive', {
623
687
  title: 'Drive the visible Obsrv app',
624
- description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, a panel ` +
625
- `profile, the target pane's 1:1/fit view or pixel-exact toggle — each exactly as clicking the toolbar would ` +
688
+ description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, rotate that ` +
689
+ `screen to landscape or portrait, apply a panel profile, the target pane's 1:1/fit view or pixel-exact ` +
690
+ `toggle — each exactly as clicking the toolbar would ` +
626
691
  `— and steer the session like a guided demo: focus the window, step history (back/forward/reload), scroll ` +
627
692
  `both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
628
693
  `neutral marker, all while the user watches.\n\n` +
629
694
  `Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
630
- `preset → profile → viewMode → panes → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
695
+ `preset → orientation → profile → viewMode → panes → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
631
696
  `capture. ` +
632
- `The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
697
+ `The result is the final status: app version, the URL showing, and the selected preset/orientation/profile/view. A ` +
633
698
  `click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
634
699
  `scroll adds \`scrolled\` (the offset actually reached) and \`scroller\` ('root' or 'element'): compare ` +
635
700
  `\`scrolled\` with what you asked for rather than trusting the call's success, and use \`scroll.scrollSelector\` ` +
@@ -641,6 +706,12 @@ server.registerTool('obsrv_drive', {
641
706
  `pass \`url\`, so this is how you photograph a scrolled or panned state: scroll, then capture, in one call. ` +
642
707
  `obsrv_snap is the other way round — it points the app at a URL first, and pointing it somewhere new is a ` +
643
708
  `fresh load that starts at the top.\n\n` +
709
+ `Tabs: the app can hold several sessions open as tabs, each with its own URL, screen preset and page state. ` +
710
+ `Every command here acts on whichever tab is in front *when that command arrives* — nothing is bound to a ` +
711
+ `tab for the length of the call — and the returned status names it (\`tabId\`, \`tabIndex\`). A \`tabId\` ` +
712
+ `that changed between two calls means the user switched tabs under you; re-read the state before trusting ` +
713
+ `what you knew. You cannot name a different tab, nor open, close or switch tabs — those are the user's. An ` +
714
+ `empty \`tabId\` means an app older than tabs, which has only the one.\n\n` +
644
715
  `Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
645
716
  `mutates visible app state (it changes what the user's window shows, and a click can act on the live page).`,
646
717
  inputSchema: driveInputShape,
@@ -666,6 +737,12 @@ server.registerTool('obsrv_drive', {
666
737
  }
667
738
  if (input.preset !== undefined)
668
739
  await (0, control_2.controlCall)(live.info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
740
+ // After the preset, before everything else: rotation is applied on top of
741
+ // whichever screen is in force, so a call carrying both has to land in
742
+ // that order or the rotation would be spent on the outgoing preset.
743
+ if (input.orientation !== undefined) {
744
+ await (0, control_2.controlCall)(live.info, 'setOrientation', { orientation: input.orientation }, LIVE_APPLY_TIMEOUT_MS);
745
+ }
669
746
  if (input.profile !== undefined)
670
747
  await (0, control_2.controlCall)(live.info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
671
748
  if (input.viewMode !== undefined) {
@@ -757,7 +834,9 @@ server.registerTool('obsrv_presets', {
757
834
  title: 'List screen presets and panel profiles',
758
835
  description: `List every screen preset (id, label, group, CSS dims, deviceScaleFactor, panel diagonal, derived physical ` +
759
836
  `ppi) and panel profile (id, label, simulation params) accepted by obsrv_snap and obsrv_diff. Read straight ` +
760
- `from the app's preset table — nothing is rendered.`,
837
+ `from the app's preset table — nothing is rendered. The dimensions are each preset's natural orientation ` +
838
+ `(portrait for the mobile ones, landscape for the monitors); every preset also rotates — see the ` +
839
+ `\`orientation\` note in the result.`,
761
840
  inputSchema: {},
762
841
  outputSchema: presetsOutputShape,
763
842
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -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);