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.
- package/README.md +34 -0
- package/out/cli/args.js +52 -5
- package/out/main/cli.js +43 -9
- package/out/main/index.js +792 -202
- package/out/main/{targetSource-nbfc-Fe6.js → targetSource-DuRYHUo8.js} +53 -1
- package/out/mcp/lib.js +14 -1
- package/out/mcp/server.js +88 -9
- package/out/preload/app.js +19 -16
- package/out/renderer/assets/{index-BlbBg6lm.js → index-BHt9Vm0J.js} +470 -120
- package/out/renderer/assets/{index-B61fbgqt.css → index-BRkFfzcI.css} +142 -4
- package/out/renderer/index.html +2 -2
- package/out/shared/calibration.js +35 -0
- package/out/shared/control.js +71 -1
- package/out/shared/ipcPayloads.js +44 -1
- package/out/shared/presets.js +16 -1
- package/out/shared/tabList.js +46 -0
- package/package.json +1 -1
- package/skills/obsrv-screens/SKILL.md +7 -0
|
@@ -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
|
-
/*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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. */
|
|
@@ -536,6 +639,41 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
536
639
|
}
|
|
537
640
|
.pane-footer .role { color: var(--text-0); letter-spacing: 0.08em; }
|
|
538
641
|
|
|
642
|
+
/* The rotate control. Same 30px border-box and the same `--chrome-3` pressed
|
|
643
|
+
fill as `.segmented`, so the screen row keeps one hit-target rhythm and one
|
|
644
|
+
way of marking a choice. Two outlines of the screen you get, rather than the
|
|
645
|
+
words: "Portrait"/"Landscape" would take 150px in a row that already holds
|
|
646
|
+
two selects and three groups, and the shape reads faster than either word.
|
|
647
|
+
The buttons are 28px like `.surround-control`'s, for the same reason — the
|
|
648
|
+
glyph is the affordance and the button is only the target.
|
|
649
|
+
|
|
650
|
+
No hue anywhere, pressed included: this control sits a few pixels from the
|
|
651
|
+
pane whose greys the user came to judge. */
|
|
652
|
+
.orientation-control {
|
|
653
|
+
box-sizing: border-box;
|
|
654
|
+
flex: 0 0 auto;
|
|
655
|
+
display: inline-flex;
|
|
656
|
+
height: 30px;
|
|
657
|
+
border: 1px solid var(--line);
|
|
658
|
+
border-radius: 5px;
|
|
659
|
+
overflow: hidden;
|
|
660
|
+
}
|
|
661
|
+
.orientation-control button {
|
|
662
|
+
width: 28px;
|
|
663
|
+
height: 100%;
|
|
664
|
+
display: inline-flex;
|
|
665
|
+
align-items: center;
|
|
666
|
+
justify-content: center;
|
|
667
|
+
border: 0;
|
|
668
|
+
border-right: 1px solid var(--line);
|
|
669
|
+
border-radius: 0;
|
|
670
|
+
background: var(--chrome-2);
|
|
671
|
+
color: var(--text-1);
|
|
672
|
+
cursor: pointer;
|
|
673
|
+
}
|
|
674
|
+
.orientation-control button:last-child { border-right: 0; }
|
|
675
|
+
.orientation-control button[aria-pressed='true'] { background: var(--chrome-3); color: var(--text-0); }
|
|
676
|
+
|
|
539
677
|
/* Segmented surround control: the same 30px border-box as `.segmented` and
|
|
540
678
|
`.select-shell`, so the screen row has one hit-target rhythm. `box-sizing`
|
|
541
679
|
for the same reason `.segmented` needs it — a `div` gets no border-box from
|
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-BHt9Vm0J.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BRkFfzcI.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyOrientation = applyOrientation;
|
|
4
|
+
exports.screenShape = screenShape;
|
|
3
5
|
exports.ppi = ppi;
|
|
4
6
|
exports.computeScale = computeScale;
|
|
5
7
|
exports.maxCssViewport = maxCssViewport;
|
|
6
8
|
exports.clampViewport = clampViewport;
|
|
7
9
|
const presets_1 = require("./presets");
|
|
10
|
+
/**
|
|
11
|
+
* The screen as it is actually being held. The **one** place the axes are
|
|
12
|
+
* swapped: everything downstream — the viewport handed to `TargetSource`, the
|
|
13
|
+
* clamp, the magnification, the footer — reads the rotated screen and needs no
|
|
14
|
+
* orientation of its own.
|
|
15
|
+
*
|
|
16
|
+
* Rotation must not change the *magnitude* of the physical scale, and does not:
|
|
17
|
+
* `ppi` is `hypot(w, h) / diagonalInches` and `hypot` is symmetric, so the
|
|
18
|
+
* diagonal, the pixel count and the density are all orientation-independent.
|
|
19
|
+
* A phone does not get physically larger by being turned sideways. The unit
|
|
20
|
+
* test asserts it, because it is exactly the kind of thing that drifts.
|
|
21
|
+
*
|
|
22
|
+
* `'portrait'` is the preset as the table stores it; `'landscape'` is that
|
|
23
|
+
* rotated a quarter turn. Every mobile preset — the case this feature exists
|
|
24
|
+
* for — is stored portrait, so for those the names are literal. A monitor
|
|
25
|
+
* preset is stored landscape-natural, so there the pair reads as
|
|
26
|
+
* unrotated/rotated instead; the UI never repeats the flag back at the user,
|
|
27
|
+
* it names the shape the dimensions actually have (`screenShape`), so nothing
|
|
28
|
+
* on screen can contradict the pixels beside it.
|
|
29
|
+
*/
|
|
30
|
+
function applyOrientation(screen, orientation) {
|
|
31
|
+
if (orientation !== 'landscape')
|
|
32
|
+
return screen;
|
|
33
|
+
return { ...screen, width: screen.height, height: screen.width };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The shape a pair of dimensions actually has, for anything the user reads. A
|
|
37
|
+
* square screen counts as portrait — it has no landscape reading, and one of
|
|
38
|
+
* the two words has to win.
|
|
39
|
+
*/
|
|
40
|
+
function screenShape(width, height) {
|
|
41
|
+
return width > height ? 'landscape' : 'portrait';
|
|
42
|
+
}
|
|
8
43
|
function ppi(width, height, diagonalInches) {
|
|
9
44
|
if (!(diagonalInches > 0))
|
|
10
45
|
throw new RangeError('diagonalInches must be > 0');
|
package/out/shared/control.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.defaultControlFilePath = defaultControlFilePath;
|
|
|
9
9
|
exports.presetApplyError = presetApplyError;
|
|
10
10
|
exports.profileApplyError = profileApplyError;
|
|
11
11
|
exports.viewModeApplyError = viewModeApplyError;
|
|
12
|
+
exports.orientationApplyError = orientationApplyError;
|
|
12
13
|
exports.panesApplyError = panesApplyError;
|
|
13
14
|
exports.pixelExactApplyError = pixelExactApplyError;
|
|
14
15
|
exports.parseClick = parseClick;
|
|
@@ -17,6 +18,7 @@ exports.parseControlStatus = parseControlStatus;
|
|
|
17
18
|
const node_crypto_1 = require("node:crypto");
|
|
18
19
|
const node_path_1 = require("node:path");
|
|
19
20
|
const ipcPayloads_1 = require("./ipcPayloads");
|
|
21
|
+
const calibration_1 = require("./calibration");
|
|
20
22
|
const presets_1 = require("./presets");
|
|
21
23
|
/**
|
|
22
24
|
* The agent-control protocol shared by the main-process control server
|
|
@@ -50,6 +52,7 @@ exports.CONTROL_COMMANDS = [
|
|
|
50
52
|
'setProfile',
|
|
51
53
|
'setViewMode',
|
|
52
54
|
'setPanes',
|
|
55
|
+
'setOrientation',
|
|
53
56
|
'captureVisible',
|
|
54
57
|
// v0.5 drive controls (spec §14 "Drive controls").
|
|
55
58
|
'scroll',
|
|
@@ -153,6 +156,13 @@ function profileApplyError(id) {
|
|
|
153
156
|
function viewModeApplyError(v) {
|
|
154
157
|
return v === '1:1' || v === 'fit' ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
|
|
155
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Validates a `setOrientation` payload. Unlike a preset id there is no table to
|
|
161
|
+
* miss, so the message just names the two words rather than listing anything.
|
|
162
|
+
*/
|
|
163
|
+
function orientationApplyError(v) {
|
|
164
|
+
return (0, presets_1.isOrientation)(v) ? null : `setOrientation payload must be { orientation: 'portrait' | 'landscape' }`;
|
|
165
|
+
}
|
|
156
166
|
function panesApplyError(v) {
|
|
157
167
|
return v === 'both' || v === 'target' ? null : `setPanes payload must be { panes: 'both' | 'target' }`;
|
|
158
168
|
}
|
|
@@ -214,6 +224,19 @@ function parseHighlight(raw) {
|
|
|
214
224
|
durationMs: Math.min(Math.max(Math.round(d), exports.HIGHLIGHT_DURATION_MIN_MS), exports.HIGHLIGHT_DURATION_MAX_MS),
|
|
215
225
|
};
|
|
216
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* The shape to report when the app did not name one. Dimensions settle it
|
|
229
|
+
* outright when they are there. Without them the app predates rotation, which
|
|
230
|
+
* means it is showing the preset unrotated — so the preset's own natural shape
|
|
231
|
+
* is exact rather than a guess. Only a custom screen on such an app falls all
|
|
232
|
+
* the way through to the flag.
|
|
233
|
+
*/
|
|
234
|
+
function inferScreenShape(cssWidth, cssHeight, presetId, orientation) {
|
|
235
|
+
if (cssWidth > 0 && cssHeight > 0)
|
|
236
|
+
return (0, calibration_1.screenShape)(cssWidth, cssHeight);
|
|
237
|
+
const preset = presets_1.SCREEN_PRESETS.find(p => p.id === presetId);
|
|
238
|
+
return preset ? (0, calibration_1.screenShape)(preset.width, preset.height) : orientation;
|
|
239
|
+
}
|
|
217
240
|
/** Validates a control server `status` response on the client side. */
|
|
218
241
|
function parseControlStatus(raw) {
|
|
219
242
|
if (!isRecord(raw))
|
|
@@ -233,5 +256,52 @@ function parseControlStatus(raw) {
|
|
|
233
256
|
const panes = raw.panes ?? 'both';
|
|
234
257
|
if (panes !== 'both' && panes !== 'target')
|
|
235
258
|
return null;
|
|
236
|
-
|
|
259
|
+
// The same skew, one release later. An app that predates tabs has exactly
|
|
260
|
+
// one session — it is the tab at index 0, and it has no id to name — so the
|
|
261
|
+
// defaults describe that app truthfully rather than papering over it. An
|
|
262
|
+
// agent polling one sees an unchanging `tabId`, which is correct: there is
|
|
263
|
+
// no other tab for the user to switch to. Returning null instead would take
|
|
264
|
+
// out drive and live snap wholesale against every app older than tabs.
|
|
265
|
+
const tabId = raw.tabId ?? '';
|
|
266
|
+
if (typeof tabId !== 'string')
|
|
267
|
+
return null;
|
|
268
|
+
const tabIndex = raw.tabIndex ?? 0;
|
|
269
|
+
if (typeof tabIndex !== 'number' || !Number.isInteger(tabIndex) || tabIndex < 0)
|
|
270
|
+
return null;
|
|
271
|
+
// And the same skew again, one release later still. The npm MCP server
|
|
272
|
+
// routinely talks to a DMG app older than itself, and an app that predates
|
|
273
|
+
// rotation shows every screen unrotated — which is exactly what the default
|
|
274
|
+
// says. Returning null instead would take out drive and live snap wholesale
|
|
275
|
+
// against every app older than this feature, which is the bug this repo has
|
|
276
|
+
// already been bitten by twice.
|
|
277
|
+
const orientation = raw.orientation ?? presets_1.DEFAULT_ORIENTATION;
|
|
278
|
+
if (!(0, presets_1.isOrientation)(orientation))
|
|
279
|
+
return null;
|
|
280
|
+
// And once more for the dimensions and the derived shape. `0` means "the app
|
|
281
|
+
// did not say", which is the truthful answer for one that predates them —
|
|
282
|
+
// inventing a size would be worse than admitting the gap.
|
|
283
|
+
const cssWidth = raw.cssWidth ?? 0;
|
|
284
|
+
if (typeof cssWidth !== 'number' || !Number.isFinite(cssWidth) || cssWidth < 0)
|
|
285
|
+
return null;
|
|
286
|
+
const cssHeight = raw.cssHeight ?? 0;
|
|
287
|
+
if (typeof cssHeight !== 'number' || !Number.isFinite(cssHeight) || cssHeight < 0)
|
|
288
|
+
return null;
|
|
289
|
+
const reported = raw.screenShape;
|
|
290
|
+
if (reported !== undefined && !(0, presets_1.isOrientation)(reported))
|
|
291
|
+
return null;
|
|
292
|
+
return {
|
|
293
|
+
version,
|
|
294
|
+
url,
|
|
295
|
+
presetId,
|
|
296
|
+
profileId,
|
|
297
|
+
viewMode,
|
|
298
|
+
panes,
|
|
299
|
+
orientation,
|
|
300
|
+
mode,
|
|
301
|
+
tabId,
|
|
302
|
+
tabIndex,
|
|
303
|
+
cssWidth,
|
|
304
|
+
cssHeight,
|
|
305
|
+
screenShape: reported ?? inferScreenShape(cssWidth, cssHeight, presetId, orientation),
|
|
306
|
+
};
|
|
237
307
|
}
|
|
@@ -5,7 +5,9 @@ exports.parseRect = parseRect;
|
|
|
5
5
|
exports.parseInputEvent = parseInputEvent;
|
|
6
6
|
exports.parseDeviceScaleFactor = parseDeviceScaleFactor;
|
|
7
7
|
exports.parseSettings = parseSettings;
|
|
8
|
+
exports.parseOrientation = parseOrientation;
|
|
8
9
|
exports.parseMode = parseMode;
|
|
10
|
+
exports.parseTabId = parseTabId;
|
|
9
11
|
exports.parseUiState = parseUiState;
|
|
10
12
|
exports.parseScrollPos = parseScrollPos;
|
|
11
13
|
exports.parseScrollRequest = parseScrollRequest;
|
|
@@ -132,13 +134,42 @@ function parseSettings(raw) {
|
|
|
132
134
|
const split = raw.split ?? presets_1.DEFAULT_SETTINGS.split;
|
|
133
135
|
if (!isFiniteNumber(split) || split < presets_1.SPLIT_MIN || split > presets_1.SPLIT_MAX)
|
|
134
136
|
return null;
|
|
135
|
-
|
|
137
|
+
// Same shape for the tab cap: absent is the pre-tabs wire shape and means
|
|
138
|
+
// the default, and an out-of-band or fractional count is refused rather
|
|
139
|
+
// than clamped — the Settings input is a bounded integer field, so one
|
|
140
|
+
// arriving here is a bug rather than a hand-edited file.
|
|
141
|
+
const maxTabs = raw.maxTabs ?? presets_1.DEFAULT_SETTINGS.maxTabs;
|
|
142
|
+
if (!isFiniteNumber(maxTabs) || !Number.isInteger(maxTabs) || maxTabs < presets_1.MAX_TABS_MIN || maxTabs > presets_1.MAX_TABS_MAX)
|
|
143
|
+
return null;
|
|
144
|
+
return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split, maxTabs };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* An orientation off the wire. Refused rather than defaulted, unlike the
|
|
148
|
+
* absent-field handling inside `parseUiState`: a caller that names an
|
|
149
|
+
* orientation and gets a different one back is worse served than one told its
|
|
150
|
+
* value was not a word this app knows.
|
|
151
|
+
*/
|
|
152
|
+
function parseOrientation(raw) {
|
|
153
|
+
return (0, presets_1.isOrientation)(raw) ? raw : null;
|
|
136
154
|
}
|
|
137
155
|
function parseMode(raw) {
|
|
138
156
|
return raw === 'url' || raw === 'image' ? raw : null;
|
|
139
157
|
}
|
|
140
158
|
/** Longest preset/profile id the UI-state mirror will store. */
|
|
141
159
|
const MAX_UI_ID = 64;
|
|
160
|
+
/**
|
|
161
|
+
* Longest tab id main will act on. Ids are minted main-side (`tab-N`), so a
|
|
162
|
+
* renderer message naming one is only ever echoing what it was told; the bound
|
|
163
|
+
* is there because the renderer is still the one sending it. An id no session
|
|
164
|
+
* carries resolves to nothing in the manager and the command is dropped, so
|
|
165
|
+
* shape is all this has to check.
|
|
166
|
+
*/
|
|
167
|
+
const MAX_TAB_ID = 64;
|
|
168
|
+
function parseTabId(raw) {
|
|
169
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_TAB_ID)
|
|
170
|
+
return null;
|
|
171
|
+
return raw;
|
|
172
|
+
}
|
|
142
173
|
/**
|
|
143
174
|
* The renderer's UI-state report (`IPC.uiState`), mirrored main-side so the
|
|
144
175
|
* agent-control server can answer `status` without a renderer round-trip.
|
|
@@ -156,6 +187,12 @@ function parseUiState(raw) {
|
|
|
156
187
|
if (!isRecord(raw))
|
|
157
188
|
return null;
|
|
158
189
|
const { presetId, profileId, viewMode, mode } = raw;
|
|
190
|
+
// Required, unlike `panes` below: there is no sane default for "which tab
|
|
191
|
+
// this describes", and guessing would reintroduce exactly the misattribution
|
|
192
|
+
// the field exists to stop.
|
|
193
|
+
const tabId = parseTabId(raw.tabId);
|
|
194
|
+
if (tabId === null)
|
|
195
|
+
return null;
|
|
159
196
|
if (typeof presetId !== 'string' || presetId.length === 0 || presetId.length > MAX_UI_ID)
|
|
160
197
|
return null;
|
|
161
198
|
if (typeof profileId !== 'string' || profileId.length === 0 || profileId.length > MAX_UI_ID)
|
|
@@ -170,11 +207,17 @@ function parseUiState(raw) {
|
|
|
170
207
|
const panes = raw.panes ?? 'both';
|
|
171
208
|
if (panes !== 'both' && panes !== 'target')
|
|
172
209
|
return null;
|
|
210
|
+
// Same shape again for the orientation, and for the same reason.
|
|
211
|
+
const orientation = raw.orientation ?? presets_1.DEFAULT_ORIENTATION;
|
|
212
|
+
if (!(0, presets_1.isOrientation)(orientation))
|
|
213
|
+
return null;
|
|
173
214
|
return {
|
|
215
|
+
tabId,
|
|
174
216
|
presetId,
|
|
175
217
|
profileId,
|
|
176
218
|
viewMode,
|
|
177
219
|
panes,
|
|
220
|
+
orientation,
|
|
178
221
|
mode,
|
|
179
222
|
targetBounds: parseRect(raw.targetBounds),
|
|
180
223
|
canvasBounds: parseRect(raw.canvasBounds),
|
package/out/shared/presets.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
|
|
3
|
+
exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_ORIENTATION = exports.DEFAULT_SETTINGS = exports.MAX_TABS_MAX = exports.MAX_TABS_MIN = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
|
|
4
|
+
exports.isOrientation = isOrientation;
|
|
4
5
|
exports.findPreset = findPreset;
|
|
5
6
|
exports.findProfile = findProfile;
|
|
6
7
|
exports.MAX_VIEWPORT = 4096;
|
|
@@ -8,6 +9,10 @@ exports.MAX_VIEWPORT = 4096;
|
|
|
8
9
|
* drag's 240px-a-side floor, which only the renderer knows the pixels for. */
|
|
9
10
|
exports.SPLIT_MIN = 0.1;
|
|
10
11
|
exports.SPLIT_MAX = 0.9;
|
|
12
|
+
/** The band `Settings.maxTabs` is held to. Below two there is nothing to tab
|
|
13
|
+
* between; above thirty-two the process count is a problem on any machine. */
|
|
14
|
+
exports.MAX_TABS_MIN = 2;
|
|
15
|
+
exports.MAX_TABS_MAX = 32;
|
|
11
16
|
exports.DEFAULT_SETTINGS = {
|
|
12
17
|
hostDiagonalInches: 27,
|
|
13
18
|
hostNits: 500,
|
|
@@ -16,7 +21,17 @@ exports.DEFAULT_SETTINGS = {
|
|
|
16
21
|
lastUpdateCheck: 0,
|
|
17
22
|
recordHistory: true,
|
|
18
23
|
split: 0.5,
|
|
24
|
+
maxTabs: 12,
|
|
19
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Every tab opens unrotated. `'portrait'` means "the preset as stored", so this
|
|
28
|
+
* is a no-op against the table below rather than a shape anyone has to honour.
|
|
29
|
+
*/
|
|
30
|
+
exports.DEFAULT_ORIENTATION = 'portrait';
|
|
31
|
+
/** Type guard for anything off the wire, off disk, or off an agent's payload. */
|
|
32
|
+
function isOrientation(v) {
|
|
33
|
+
return v === 'portrait' || v === 'landscape';
|
|
34
|
+
}
|
|
20
35
|
exports.SCREEN_PRESETS = [
|
|
21
36
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
22
37
|
{ id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.closeTab = closeTab;
|
|
4
|
+
exports.canAddTab = canAddTab;
|
|
5
|
+
exports.tabTitle = tabTitle;
|
|
6
|
+
/**
|
|
7
|
+
* Removes a tab and says which one should take focus. Closing the active tab
|
|
8
|
+
* moves right, because the tab that took its screen position is the one the
|
|
9
|
+
* eye is already on; at the end of the strip there is nothing to the right, so
|
|
10
|
+
* it moves left.
|
|
11
|
+
*/
|
|
12
|
+
function closeTab(tabs, closeId, activeId) {
|
|
13
|
+
const index = tabs.findIndex(t => t.id === closeId);
|
|
14
|
+
if (index === -1)
|
|
15
|
+
return { tabs, activeId };
|
|
16
|
+
const next = tabs.filter(t => t.id !== closeId);
|
|
17
|
+
if (next.length === 0)
|
|
18
|
+
return { tabs: next, activeId: null };
|
|
19
|
+
if (closeId !== activeId)
|
|
20
|
+
return { tabs: next, activeId };
|
|
21
|
+
const neighbour = next[Math.min(index, next.length - 1)];
|
|
22
|
+
return { tabs: next, activeId: neighbour.id };
|
|
23
|
+
}
|
|
24
|
+
function canAddTab(count, max) {
|
|
25
|
+
return count < max;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* What the strip shows. The port is kept deliberately: two local dev servers
|
|
29
|
+
* differ only by it, and a host-only label would render them identically.
|
|
30
|
+
*/
|
|
31
|
+
function tabTitle(url, pageTitle) {
|
|
32
|
+
if (pageTitle.trim() !== '')
|
|
33
|
+
return pageTitle;
|
|
34
|
+
// `about:blank` is where every session starts and what an unused tab still
|
|
35
|
+
// holds, so it is the empty tab spelled in Chromium's words rather than an
|
|
36
|
+
// address anyone typed. It has no host, and falling through to the raw
|
|
37
|
+
// string labels a fresh tab `about:blank` — which the screenshot caught.
|
|
38
|
+
if (url.trim() === '' || url.trim() === 'about:blank')
|
|
39
|
+
return 'New tab';
|
|
40
|
+
try {
|
|
41
|
+
return new URL(url).host || url;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -61,6 +61,13 @@ when the PNG is taken. Reaching for `obsrv_snap` after a scroll works only
|
|
|
61
61
|
when the app is already on that exact URL (it answers `navigated: false`);
|
|
62
62
|
snapping a different URL is a fresh load and lands back at the top.
|
|
63
63
|
|
|
64
|
+
The app can hold several sessions open as tabs, and both tools act on the one
|
|
65
|
+
in **front** — resolved per command, so the user can move it under you. Each
|
|
66
|
+
result names the tab (`tabId`, `tabIndex`); if you drive over several calls and
|
|
67
|
+
the state has to hold, check that `tabId` did not change rather than assuming
|
|
68
|
+
it. You cannot name another tab, and you cannot open, close or switch tabs —
|
|
69
|
+
ask the user. An empty `tabId` is an app older than tabs, which has only one.
|
|
70
|
+
|
|
64
71
|
## The loop that catches real regressions
|
|
65
72
|
|
|
66
73
|
1. Snap the dev URL across `--matrix laptop-768,android-65,1080p-24`, plus a
|