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
package/README.md
CHANGED
|
@@ -90,6 +90,24 @@ sit in half a window, and for agent captures. The native pane stays loaded while
|
|
|
90
90
|
hidden, so the URL bar, back/forward and link clicks keep working exactly as
|
|
91
91
|
they do side by side.
|
|
92
92
|
|
|
93
|
+
### Tabs
|
|
94
|
+
|
|
95
|
+
The strip above the URL bar holds several independent sessions at once. A tab is
|
|
96
|
+
its own URL, its own screen preset and panel profile, its own view mode and pan,
|
|
97
|
+
and its own page state — so the same page on a 1366×768 laptop and on a budget
|
|
98
|
+
Android sit in two tabs and you flip between them without either one reloading.
|
|
99
|
+
|
|
100
|
+
`Cmd+T` opens a tab, `Cmd+W` closes one, `Cmd+1`–`Cmd+8` select by position and
|
|
101
|
+
`Cmd+9` selects the last. Closing the last tab leaves a fresh blank one rather
|
|
102
|
+
than closing the window. Tabs come back on relaunch — their URLs, their screens
|
|
103
|
+
and which one was in front.
|
|
104
|
+
|
|
105
|
+
Every tab is two Chromium renderers (a native pane and an offscreen 1x surface),
|
|
106
|
+
so the count is capped: 12 by default, settable from 2 to 32 in Settings. At the
|
|
107
|
+
cap the new-tab button dims and says why. Background tabs stay loaded and keep
|
|
108
|
+
their scroll position, but stop rasterising until you return to them — the cost
|
|
109
|
+
of a background tab is memory, not GPU.
|
|
110
|
+
|
|
93
111
|
## Agent & CI use
|
|
94
112
|
|
|
95
113
|
The same rendering pipeline runs headless — no window, JSON on stdout, humans
|
|
@@ -144,6 +162,15 @@ navigates unless you pass `url`, so the scroll survives the shutter. A live
|
|
|
144
162
|
`navigated` field says which happened — and navigating is a fresh load, which
|
|
145
163
|
starts at the top of the page.
|
|
146
164
|
|
|
165
|
+
**Tabs and the agent.** `obsrv_drive` and a live `obsrv_snap` act on the tab
|
|
166
|
+
that is in front, resolved per command rather than fixed when the drive starts —
|
|
167
|
+
a command that quietly succeeded on a tab you could not see would not surface
|
|
168
|
+
until the drive ended. Both report `tabId` and `tabIndex`, so an agent that
|
|
169
|
+
cares can compare them across calls and notice you switched tabs under it. While
|
|
170
|
+
Agent control is on, the driven tab carries a neutral rule on its leading edge
|
|
171
|
+
that brightens for ~3 s on each command, so it is visible which session is being
|
|
172
|
+
driven. An agent cannot open, close or switch tabs; that stays yours.
|
|
173
|
+
|
|
147
174
|
A headless `snap` returns `settled: true` when the page went paint-quiet and
|
|
148
175
|
every pixel painted. `settled: false` is still a usable capture, not a
|
|
149
176
|
failure — a page that kept animating, or one whose repaint never completed,
|
|
@@ -220,3 +247,10 @@ belongs to an unrelated package.
|
|
|
220
247
|
hides its scroller in a shadow root has no escape hatch.
|
|
221
248
|
- Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
|
|
222
249
|
with dirty rects it has not been needed.
|
|
250
|
+
- Tabs are a first cut. They cannot be reordered, dragged out into another window, or
|
|
251
|
+
reopened after closing (no `Cmd+Shift+T`), and there is no tab overflow menu — a strip
|
|
252
|
+
longer than the window scrolls. An agent can only reach the tab in front: there is no
|
|
253
|
+
way to name another tab in `obsrv_drive`, and no way for an agent to open, close or
|
|
254
|
+
switch tabs at all. The URL bar's visited-URL suggestions are one window-wide list
|
|
255
|
+
rather than one per tab (back/forward *are* per tab), and a restored tab comes back at
|
|
256
|
+
the top of its page — the scroll position is not persisted.
|
package/out/cli/args.js
CHANGED
|
@@ -30,6 +30,18 @@ Shared flags:
|
|
|
30
30
|
${presets}
|
|
31
31
|
--width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
|
|
32
32
|
Custom CSS viewport instead of --preset (dsf defaults to 1).
|
|
33
|
+
--orientation <o> portrait | landscape (default ${presets_1.DEFAULT_ORIENTATION}). This names the
|
|
34
|
+
preset's *stored* orientation, not the shape you get:
|
|
35
|
+
portrait = the preset exactly as the table above lists it
|
|
36
|
+
landscape = that rotated a quarter turn (width and height swap)
|
|
37
|
+
Every mobile preset is stored portrait, so for those the two
|
|
38
|
+
readings agree. The laptop and desktop presets are stored
|
|
39
|
+
landscape-natural, so --orientation landscape turns them into a
|
|
40
|
+
portrait screen — which is how you render a 1080p monitor stood on
|
|
41
|
+
end (1080p-24 becomes 1080x1920). Applies to custom --width/--height
|
|
42
|
+
dims too. The diagonal, raster density and physical size never
|
|
43
|
+
change: it is the same panel turned sideways. Each render's JSON
|
|
44
|
+
and log line name the resulting shape.
|
|
33
45
|
--profile <id> Panel profile: ${profiles} (default reference).
|
|
34
46
|
--wait <ms> Extra settle time after load (default 0).
|
|
35
47
|
--timeout <ms> Per-render budget for load + paint quiescence (default ${exports.DEFAULT_TIMEOUT_MS}).
|
|
@@ -55,7 +67,7 @@ warning naming what was missing. Only a render that painted nothing errors.`;
|
|
|
55
67
|
/** Flags that take no value. */
|
|
56
68
|
const BOOLEAN_FLAGS = new Set(['full-page', 'json']);
|
|
57
69
|
/** Flags that consume the next token. */
|
|
58
|
-
const VALUE_FLAGS = new Set(['preset', 'profile', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
|
|
70
|
+
const VALUE_FLAGS = new Set(['preset', 'profile', 'orientation', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
|
|
59
71
|
const SNAP_ONLY = new Set(['out', 'full-page', 'matrix']);
|
|
60
72
|
const DIFF_ONLY = new Set(['out-dir', 'json']);
|
|
61
73
|
function collect(command, argv) {
|
|
@@ -131,9 +143,30 @@ function presetSpec(id) {
|
|
|
131
143
|
cssHeight: preset.height,
|
|
132
144
|
deviceScaleFactor: preset.deviceScaleFactor,
|
|
133
145
|
diagonalInches: preset.diagonalInches,
|
|
146
|
+
orientation: presets_1.DEFAULT_ORIENTATION,
|
|
134
147
|
};
|
|
135
148
|
}
|
|
149
|
+
function resolveOrientation(flags) {
|
|
150
|
+
const raw = flags.get('orientation');
|
|
151
|
+
if (raw === undefined)
|
|
152
|
+
return presets_1.DEFAULT_ORIENTATION;
|
|
153
|
+
if (!(0, presets_1.isOrientation)(raw))
|
|
154
|
+
throw new ArgError(`--orientation: expected portrait or landscape, got "${String(raw)}"`);
|
|
155
|
+
return raw;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Rotation swaps the CSS axes and nothing else — the diagonal and the raster
|
|
159
|
+
* density are orientation-independent, so the render is the same screen turned
|
|
160
|
+
* sideways rather than a different one. Applied here, before the diff bounds
|
|
161
|
+
* are checked, so those check the viewport that will actually be rendered.
|
|
162
|
+
*/
|
|
163
|
+
function orientSpec(spec, orientation) {
|
|
164
|
+
if (orientation !== 'landscape')
|
|
165
|
+
return { ...spec, orientation };
|
|
166
|
+
return { ...spec, orientation, cssWidth: spec.cssHeight, cssHeight: spec.cssWidth };
|
|
167
|
+
}
|
|
136
168
|
function resolveSpecs(flags) {
|
|
169
|
+
const orientation = resolveOrientation(flags);
|
|
137
170
|
const custom = ['width', 'height', 'dsf', 'diagonal'].some(f => flags.has(f));
|
|
138
171
|
if (custom && flags.has('preset'))
|
|
139
172
|
throw new ArgError('--preset and --width/--height are mutually exclusive');
|
|
@@ -153,17 +186,25 @@ function resolveSpecs(flags) {
|
|
|
153
186
|
throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
|
|
154
187
|
}
|
|
155
188
|
const diagonal = flags.has('diagonal') ? float(flags, 'diagonal', 0, 0.1) : null;
|
|
156
|
-
|
|
189
|
+
const spec = {
|
|
190
|
+
presetId: 'custom',
|
|
191
|
+
cssWidth,
|
|
192
|
+
cssHeight,
|
|
193
|
+
deviceScaleFactor,
|
|
194
|
+
diagonalInches: diagonal,
|
|
195
|
+
orientation: presets_1.DEFAULT_ORIENTATION,
|
|
196
|
+
};
|
|
197
|
+
return { specs: [orientSpec(spec, orientation)], matrix: false };
|
|
157
198
|
}
|
|
158
199
|
const matrixRaw = flags.get('matrix');
|
|
159
200
|
if (typeof matrixRaw === 'string') {
|
|
160
201
|
const ids = matrixRaw.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
161
202
|
if (ids.length === 0)
|
|
162
203
|
throw new ArgError('--matrix: expected a comma-separated list of preset ids');
|
|
163
|
-
return { specs: ids.map(presetSpec), matrix: true };
|
|
204
|
+
return { specs: ids.map(id => orientSpec(presetSpec(id), orientation)), matrix: true };
|
|
164
205
|
}
|
|
165
206
|
const id = typeof flags.get('preset') === 'string' ? flags.get('preset') : exports.DEFAULT_PRESET;
|
|
166
|
-
return { specs: [presetSpec(id)], matrix: false };
|
|
207
|
+
return { specs: [orientSpec(presetSpec(id), orientation)], matrix: false };
|
|
167
208
|
}
|
|
168
209
|
function resolveProfile(flags) {
|
|
169
210
|
const raw = flags.get('profile');
|
|
@@ -201,8 +242,14 @@ function parseArgs(argv) {
|
|
|
201
242
|
}
|
|
202
243
|
const referenceMax = (0, calibration_1.maxCssViewport)(2);
|
|
203
244
|
if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
|
|
245
|
+
// Named as rendered, not as stored: the dims here are post-rotation, and
|
|
246
|
+
// attributing them to the bare preset id would print "1440p-27 is
|
|
247
|
+
// 1440×2560" — a shape that id never has. The bound itself is per-axis
|
|
248
|
+
// symmetric, so rotation can never sneak a too-large viewport past it;
|
|
249
|
+
// this is the message telling the truth about which one it measured.
|
|
250
|
+
const as = spec.orientation === 'landscape' ? ' rotated a quarter turn' : '';
|
|
204
251
|
throw new ArgError(`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis ` +
|
|
205
|
-
`(4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. ` +
|
|
252
|
+
`(4096 device px at 2x) — "${spec.presetId}"${as} is ${spec.cssWidth}×${spec.cssHeight}. ` +
|
|
206
253
|
`Use \`obsrv snap\` for this preset instead.`);
|
|
207
254
|
}
|
|
208
255
|
const outDir = typeof flags.get('out-dir') === 'string' ? flags.get('out-dir') : null;
|
package/out/main/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ const electron = require("electron");
|
|
|
3
3
|
const node_fs = require("node:fs");
|
|
4
4
|
const node_os = require("node:os");
|
|
5
5
|
const node_path = require("node:path");
|
|
6
|
-
const targetSource = require("./targetSource-
|
|
6
|
+
const targetSource = require("./targetSource-DuRYHUo8.js");
|
|
7
7
|
function boxDownsample(src, factor) {
|
|
8
8
|
if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
|
|
9
9
|
const width = Math.floor(src.width / factor);
|
|
@@ -61,6 +61,18 @@ Shared flags:
|
|
|
61
61
|
${presets}
|
|
62
62
|
--width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
|
|
63
63
|
Custom CSS viewport instead of --preset (dsf defaults to 1).
|
|
64
|
+
--orientation <o> portrait | landscape (default ${targetSource.DEFAULT_ORIENTATION}). This names the
|
|
65
|
+
preset's *stored* orientation, not the shape you get:
|
|
66
|
+
portrait = the preset exactly as the table above lists it
|
|
67
|
+
landscape = that rotated a quarter turn (width and height swap)
|
|
68
|
+
Every mobile preset is stored portrait, so for those the two
|
|
69
|
+
readings agree. The laptop and desktop presets are stored
|
|
70
|
+
landscape-natural, so --orientation landscape turns them into a
|
|
71
|
+
portrait screen — which is how you render a 1080p monitor stood on
|
|
72
|
+
end (1080p-24 becomes 1080x1920). Applies to custom --width/--height
|
|
73
|
+
dims too. The diagonal, raster density and physical size never
|
|
74
|
+
change: it is the same panel turned sideways. Each render's JSON
|
|
75
|
+
and log line name the resulting shape.
|
|
64
76
|
--profile <id> Panel profile: ${profiles} (default reference).
|
|
65
77
|
--wait <ms> Extra settle time after load (default 0).
|
|
66
78
|
--timeout <ms> Per-render budget for load + paint quiescence (default ${DEFAULT_TIMEOUT_MS}).
|
|
@@ -84,7 +96,7 @@ painted. False is a rescued capture, not a failure: a page that kept animating
|
|
|
84
96
|
warning naming what was missing. Only a render that painted nothing errors.`;
|
|
85
97
|
}
|
|
86
98
|
const BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["full-page", "json"]);
|
|
87
|
-
const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
|
|
99
|
+
const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "orientation", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
|
|
88
100
|
const SNAP_ONLY = /* @__PURE__ */ new Set(["out", "full-page", "matrix"]);
|
|
89
101
|
const DIFF_ONLY = /* @__PURE__ */ new Set(["out-dir", "json"]);
|
|
90
102
|
function collect(command, argv) {
|
|
@@ -148,10 +160,22 @@ function presetSpec(id) {
|
|
|
148
160
|
cssWidth: preset.width,
|
|
149
161
|
cssHeight: preset.height,
|
|
150
162
|
deviceScaleFactor: preset.deviceScaleFactor,
|
|
151
|
-
diagonalInches: preset.diagonalInches
|
|
163
|
+
diagonalInches: preset.diagonalInches,
|
|
164
|
+
orientation: targetSource.DEFAULT_ORIENTATION
|
|
152
165
|
};
|
|
153
166
|
}
|
|
167
|
+
function resolveOrientation(flags) {
|
|
168
|
+
const raw = flags.get("orientation");
|
|
169
|
+
if (raw === void 0) return targetSource.DEFAULT_ORIENTATION;
|
|
170
|
+
if (!targetSource.isOrientation(raw)) throw new ArgError(`--orientation: expected portrait or landscape, got "${String(raw)}"`);
|
|
171
|
+
return raw;
|
|
172
|
+
}
|
|
173
|
+
function orientSpec(spec, orientation) {
|
|
174
|
+
if (orientation !== "landscape") return { ...spec, orientation };
|
|
175
|
+
return { ...spec, orientation, cssWidth: spec.cssHeight, cssHeight: spec.cssWidth };
|
|
176
|
+
}
|
|
154
177
|
function resolveSpecs(flags) {
|
|
178
|
+
const orientation = resolveOrientation(flags);
|
|
155
179
|
const custom = ["width", "height", "dsf", "diagonal"].some((f) => flags.has(f));
|
|
156
180
|
if (custom && flags.has("preset")) throw new ArgError("--preset and --width/--height are mutually exclusive");
|
|
157
181
|
if (custom && flags.has("matrix")) throw new ArgError("--matrix lists presets; it cannot be combined with custom --width/--height dims");
|
|
@@ -168,16 +192,24 @@ function resolveSpecs(flags) {
|
|
|
168
192
|
throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
|
|
169
193
|
}
|
|
170
194
|
const diagonal = flags.has("diagonal") ? float(flags, "diagonal", 0, 0.1) : null;
|
|
171
|
-
|
|
195
|
+
const spec = {
|
|
196
|
+
presetId: "custom",
|
|
197
|
+
cssWidth,
|
|
198
|
+
cssHeight,
|
|
199
|
+
deviceScaleFactor,
|
|
200
|
+
diagonalInches: diagonal,
|
|
201
|
+
orientation: targetSource.DEFAULT_ORIENTATION
|
|
202
|
+
};
|
|
203
|
+
return { specs: [orientSpec(spec, orientation)], matrix: false };
|
|
172
204
|
}
|
|
173
205
|
const matrixRaw = flags.get("matrix");
|
|
174
206
|
if (typeof matrixRaw === "string") {
|
|
175
207
|
const ids = matrixRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
176
208
|
if (ids.length === 0) throw new ArgError("--matrix: expected a comma-separated list of preset ids");
|
|
177
|
-
return { specs: ids.map(presetSpec), matrix: true };
|
|
209
|
+
return { specs: ids.map((id2) => orientSpec(presetSpec(id2), orientation)), matrix: true };
|
|
178
210
|
}
|
|
179
211
|
const id = typeof flags.get("preset") === "string" ? flags.get("preset") : DEFAULT_PRESET;
|
|
180
|
-
return { specs: [presetSpec(id)], matrix: false };
|
|
212
|
+
return { specs: [orientSpec(presetSpec(id), orientation)], matrix: false };
|
|
181
213
|
}
|
|
182
214
|
function resolveProfile(flags) {
|
|
183
215
|
const raw = flags.get("profile");
|
|
@@ -215,8 +247,9 @@ ${usage()}`);
|
|
|
215
247
|
}
|
|
216
248
|
const referenceMax = targetSource.maxCssViewport(2);
|
|
217
249
|
if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
|
|
250
|
+
const as = spec.orientation === "landscape" ? " rotated a quarter turn" : "";
|
|
218
251
|
throw new ArgError(
|
|
219
|
-
`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
|
|
252
|
+
`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}"${as} is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
|
|
220
253
|
);
|
|
221
254
|
}
|
|
222
255
|
const outDir = typeof flags.get("out-dir") === "string" ? flags.get("out-dir") : null;
|
|
@@ -559,8 +592,9 @@ async function runSnap(cmd) {
|
|
|
559
592
|
const img = applyPanelProfile(bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height), profile);
|
|
560
593
|
node_fs.mkdirSync(node_path.dirname(out), { recursive: true });
|
|
561
594
|
node_fs.writeFileSync(out, encodePng(img));
|
|
595
|
+
const shape = targetSource.screenShape(r.cssWidth, r.cssHeight);
|
|
562
596
|
human(
|
|
563
|
-
`snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, preset ${spec.presetId}, profile ${profile.id})`
|
|
597
|
+
`snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, ${r.cssWidth}×${r.cssHeight} CSS ${shape}, preset ${spec.presetId}, profile ${profile.id})`
|
|
564
598
|
);
|
|
565
599
|
results.push({
|
|
566
600
|
out,
|
|
@@ -607,7 +641,7 @@ async function runDiff(cmd) {
|
|
|
607
641
|
}
|
|
608
642
|
const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
|
|
609
643
|
human(
|
|
610
|
-
`diff ${cmd.url} @ ${cmd.spec.presetId} (profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
|
|
644
|
+
`diff ${cmd.url} @ ${cmd.spec.presetId} (${cmd.spec.cssWidth}×${cmd.spec.cssHeight} CSS ${targetSource.screenShape(cmd.spec.cssWidth, cmd.spec.cssHeight)}, profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
|
|
611
645
|
);
|
|
612
646
|
await machine({
|
|
613
647
|
url: cmd.url,
|