getobsrv 0.3.0 → 0.4.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 +30 -8
- package/bin/obsrv.js +8 -0
- package/out/main/cli.js +1 -1
- package/out/main/index.js +292 -7
- package/out/main/{targetSource-w_vWw7zd.js → targetSource-DkXWE0ha.js} +12 -1
- package/out/mcp/control.js +105 -0
- package/out/mcp/lib.js +45 -24
- package/out/mcp/server.js +215 -10
- package/out/preload/app.js +14 -2
- package/out/renderer/assets/{index-VleEBVgL.css → index-FtoKShe_.css} +15 -1
- package/out/renderer/assets/{index-BAEO9_6W.js → index-P496vEhv.js} +49 -1
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +153 -0
- package/out/shared/presets.js +1 -1
- package/out/shared/url.js +54 -0
- package/package.json +8 -5
- package/skills/obsrv-screens/SKILL.md +17 -11
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONTROL_COMMANDS = exports.CONTROL_TOKEN_BYTES = exports.CONTROL_FILE_NAME = void 0;
|
|
4
|
+
exports.isControlCommand = isControlCommand;
|
|
5
|
+
exports.parseControlFile = parseControlFile;
|
|
6
|
+
exports.controlFileModeOk = controlFileModeOk;
|
|
7
|
+
exports.tokenEqual = tokenEqual;
|
|
8
|
+
exports.defaultControlFilePath = defaultControlFilePath;
|
|
9
|
+
exports.presetApplyError = presetApplyError;
|
|
10
|
+
exports.profileApplyError = profileApplyError;
|
|
11
|
+
exports.viewModeApplyError = viewModeApplyError;
|
|
12
|
+
exports.parseControlStatus = parseControlStatus;
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
const presets_1 = require("./presets");
|
|
16
|
+
/**
|
|
17
|
+
* The agent-control protocol shared by the main-process control server
|
|
18
|
+
* (`src/main/controlServer.ts`) and the MCP discovery client
|
|
19
|
+
* (`src/mcp/control.ts`): discovery-file shape, token comparison, command
|
|
20
|
+
* names and payload validation. Pure node — no Electron, no I/O — so
|
|
21
|
+
* everything here runs under plain node and is unit-tested in
|
|
22
|
+
* tests/unit/control.test.ts.
|
|
23
|
+
*
|
|
24
|
+
* Renderer code imports *types* from this module only; the `node:crypto`
|
|
25
|
+
* import never reaches a browser bundle.
|
|
26
|
+
*
|
|
27
|
+
* Security decisions (spec §14 "Live drive"):
|
|
28
|
+
* - The server binds 127.0.0.1 only, on an ephemeral port.
|
|
29
|
+
* - Every command — `status` included — carries the bearer token from the
|
|
30
|
+
* discovery file. The file is mode 0600 in the app's own userData dir, so
|
|
31
|
+
* possession already proves "same user"; a token-free status would only
|
|
32
|
+
* leak app state to other local users for no benefit.
|
|
33
|
+
* - No command accepts file paths, JavaScript, or IPC channel names; every
|
|
34
|
+
* payload is validated against the same tables the app itself uses.
|
|
35
|
+
*/
|
|
36
|
+
/** Discovery file the app writes to `app.getPath('userData')` while agent control is on. */
|
|
37
|
+
exports.CONTROL_FILE_NAME = 'control.json';
|
|
38
|
+
/** Bearer-token entropy; hex-encoded in the discovery file (64 chars). */
|
|
39
|
+
exports.CONTROL_TOKEN_BYTES = 32;
|
|
40
|
+
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
41
|
+
exports.CONTROL_COMMANDS = [
|
|
42
|
+
'status',
|
|
43
|
+
'navigate',
|
|
44
|
+
'setPreset',
|
|
45
|
+
'setProfile',
|
|
46
|
+
'setViewMode',
|
|
47
|
+
'captureVisible',
|
|
48
|
+
];
|
|
49
|
+
function isControlCommand(v) {
|
|
50
|
+
return typeof v === 'string' && exports.CONTROL_COMMANDS.includes(v);
|
|
51
|
+
}
|
|
52
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
53
|
+
/**
|
|
54
|
+
* Parses a discovery file's contents. Strict: a malformed file (bad JSON,
|
|
55
|
+
* out-of-range port, a token that is not 64 hex chars) yields null — the
|
|
56
|
+
* client must treat the app as not reachable rather than send credentials
|
|
57
|
+
* derived from a file something else may have written.
|
|
58
|
+
*/
|
|
59
|
+
function parseControlFile(raw) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(parsed))
|
|
68
|
+
return null;
|
|
69
|
+
const { port, token } = parsed;
|
|
70
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
71
|
+
return null;
|
|
72
|
+
if (typeof token !== 'string' || !TOKEN_RE.test(token))
|
|
73
|
+
return null;
|
|
74
|
+
return { port, token };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a discovery file's permission bits are acceptable: no group or
|
|
78
|
+
* other access on POSIX (the app writes it 0600). Windows has no POSIX mode
|
|
79
|
+
* bits worth reading, so everything passes there.
|
|
80
|
+
*/
|
|
81
|
+
function controlFileModeOk(mode, platform) {
|
|
82
|
+
if (platform === 'win32')
|
|
83
|
+
return true;
|
|
84
|
+
return (mode & 0o077) === 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Constant-time bearer-token comparison. Both sides are hashed first so
|
|
88
|
+
* `timingSafeEqual` always gets equal-length inputs — a length mismatch must
|
|
89
|
+
* not throw or short-circuit into a timing signal.
|
|
90
|
+
*/
|
|
91
|
+
function tokenEqual(expected, provided) {
|
|
92
|
+
if (typeof provided !== 'string')
|
|
93
|
+
return false;
|
|
94
|
+
const a = (0, node_crypto_1.createHash)('sha256').update(expected).digest();
|
|
95
|
+
const b = (0, node_crypto_1.createHash)('sha256').update(provided).digest();
|
|
96
|
+
return (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Where the app's discovery file lives for a given platform, derived the way
|
|
100
|
+
* Electron derives `app.getPath('userData')` for productName "Obsrv" — the
|
|
101
|
+
* MCP server runs under plain node and cannot ask Electron.
|
|
102
|
+
*/
|
|
103
|
+
function defaultControlFilePath(platform, env, home) {
|
|
104
|
+
const appDir = platform === 'darwin'
|
|
105
|
+
? (0, node_path_1.join)(home, 'Library', 'Application Support', 'Obsrv')
|
|
106
|
+
: platform === 'win32'
|
|
107
|
+
? (0, node_path_1.join)(env['APPDATA'] ?? (0, node_path_1.join)(home, 'AppData', 'Roaming'), 'Obsrv')
|
|
108
|
+
: (0, node_path_1.join)(env['XDG_CONFIG_HOME'] ?? (0, node_path_1.join)(home, '.config'), 'Obsrv');
|
|
109
|
+
return (0, node_path_1.join)(appDir, exports.CONTROL_FILE_NAME);
|
|
110
|
+
}
|
|
111
|
+
const idList = (ids) => ids.join(', ');
|
|
112
|
+
/**
|
|
113
|
+
* Validates a `setPreset` payload id. The custom preset is refused: it is
|
|
114
|
+
* defined by the renderer's own width/height/diagonal fields, so "apply
|
|
115
|
+
* custom" from outside would apply whatever happened to be typed there.
|
|
116
|
+
*/
|
|
117
|
+
function presetApplyError(id) {
|
|
118
|
+
if (typeof id !== 'string')
|
|
119
|
+
return 'setPreset payload must be { id: string }';
|
|
120
|
+
if (id === 'custom') {
|
|
121
|
+
return 'the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id';
|
|
122
|
+
}
|
|
123
|
+
if (!presets_1.SCREEN_PRESETS.some(p => p.id === id)) {
|
|
124
|
+
return `unknown preset "${id}" — valid ids: ${idList(presets_1.SCREEN_PRESETS.map(p => p.id))}`;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function profileApplyError(id) {
|
|
129
|
+
if (typeof id !== 'string')
|
|
130
|
+
return 'setProfile payload must be { id: string }';
|
|
131
|
+
if (!presets_1.PANEL_PROFILES.some(p => p.id === id)) {
|
|
132
|
+
return `unknown profile "${id}" — valid ids: ${idList(presets_1.PANEL_PROFILES.map(p => p.id))}`;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function viewModeApplyError(v) {
|
|
137
|
+
return v === '1:1' || v === 'fit' ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
|
|
138
|
+
}
|
|
139
|
+
/** Validates a control server `status` response on the client side. */
|
|
140
|
+
function parseControlStatus(raw) {
|
|
141
|
+
if (!isRecord(raw))
|
|
142
|
+
return null;
|
|
143
|
+
const { version, url, presetId, profileId, viewMode, mode } = raw;
|
|
144
|
+
if (typeof version !== 'string' || typeof url !== 'string')
|
|
145
|
+
return null;
|
|
146
|
+
if (typeof presetId !== 'string' || typeof profileId !== 'string')
|
|
147
|
+
return null;
|
|
148
|
+
if (viewMode !== '1:1' && viewMode !== 'fit')
|
|
149
|
+
return null;
|
|
150
|
+
if (mode !== 'url' && mode !== 'image')
|
|
151
|
+
return null;
|
|
152
|
+
return { version, url, presetId, profileId, viewMode, mode };
|
|
153
|
+
}
|
package/out/shared/presets.js
CHANGED
|
@@ -4,7 +4,7 @@ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exp
|
|
|
4
4
|
exports.findPreset = findPreset;
|
|
5
5
|
exports.findProfile = findProfile;
|
|
6
6
|
exports.MAX_VIEWPORT = 4096;
|
|
7
|
-
exports.DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
|
|
7
|
+
exports.DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
|
|
8
8
|
exports.SCREEN_PRESETS = [
|
|
9
9
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
10
10
|
{ id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ALLOWED_URL_SCHEMES = void 0;
|
|
4
|
+
exports.normalizeUrl = normalizeUrl;
|
|
5
|
+
exports.urlSchemeError = urlSchemeError;
|
|
6
|
+
/** `scheme:` prefix, e.g. `https:`, `about:`, `file:`. */
|
|
7
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
8
|
+
/** Loopback host with optional port, e.g. `localhost:5173`, `127.0.0.1/a`. */
|
|
9
|
+
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i;
|
|
10
|
+
/**
|
|
11
|
+
* Turn URL-bar input into an absolute, loadable URL.
|
|
12
|
+
* Loopback is checked before the scheme test because `localhost:5173`
|
|
13
|
+
* parses as scheme `localhost` otherwise.
|
|
14
|
+
*/
|
|
15
|
+
function normalizeUrl(input) {
|
|
16
|
+
const s = input.trim();
|
|
17
|
+
if (s === '')
|
|
18
|
+
throw new Error('empty url');
|
|
19
|
+
if (/\s/.test(s))
|
|
20
|
+
throw new Error('invalid URL');
|
|
21
|
+
if (s.startsWith('/'))
|
|
22
|
+
return `file://${s}`;
|
|
23
|
+
if (LOOPBACK.test(s))
|
|
24
|
+
return `http://${s}`;
|
|
25
|
+
if (SCHEME.test(s))
|
|
26
|
+
return s;
|
|
27
|
+
return `https://${s}`;
|
|
28
|
+
}
|
|
29
|
+
/** Schemes an agent-facing entry point (MCP tool, control server) may load. */
|
|
30
|
+
exports.ALLOWED_URL_SCHEMES = ['http:', 'https:', 'file:'];
|
|
31
|
+
/**
|
|
32
|
+
* Rejects URLs whose explicit scheme is outside the allowlist (javascript:,
|
|
33
|
+
* data:, chrome:, …) with an actionable message, or returns null when the URL
|
|
34
|
+
* may proceed. Scheme-relative (`//host`), bare-host (`example.com/page`) and
|
|
35
|
+
* host:port (`localhost:5173`) forms pass — they normalise to http(s)
|
|
36
|
+
* downstream. Shared by the MCP tools and the agent-control server, so the
|
|
37
|
+
* app's URL bar stays the only surface that can reach another scheme.
|
|
38
|
+
*/
|
|
39
|
+
function urlSchemeError(url) {
|
|
40
|
+
const trimmed = url.trim();
|
|
41
|
+
const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
|
|
42
|
+
if (!match)
|
|
43
|
+
return null; // bare host or scheme-relative
|
|
44
|
+
const scheme = `${match[1].toLowerCase()}:`;
|
|
45
|
+
if (exports.ALLOWED_URL_SCHEMES.includes(scheme))
|
|
46
|
+
return null;
|
|
47
|
+
// `localhost:5173`-style host:port, not a scheme: the "scheme" is followed
|
|
48
|
+
// by a bare port number.
|
|
49
|
+
if (/^[a-z0-9.-]+:\d+(\/|$)/i.test(trimmed))
|
|
50
|
+
return null;
|
|
51
|
+
return (`unsupported URL scheme "${scheme}" — obsrv renders ` +
|
|
52
|
+
`${exports.ALLOWED_URL_SCHEMES.map(s => `${s}//`).join(', ')} URLs only ` +
|
|
53
|
+
`(bare hosts like example.com also work; they normalise to http(s)).`);
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getobsrv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "See your site the way 1x screens see it",
|
|
5
5
|
"main": "./out/main/index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"obsrv": "
|
|
8
|
-
"
|
|
7
|
+
"obsrv": "bin/obsrv.js",
|
|
8
|
+
"getobsrv": "bin/obsrv.js",
|
|
9
|
+
"obsrv-mcp": "bin/obsrv-mcp.js"
|
|
9
10
|
},
|
|
10
11
|
"author": "Opeyemi Ajagbe <auto-report@hydrogenpay.com>",
|
|
11
12
|
"homepage": "https://github.com/vibesyemmy/obsrv",
|
|
@@ -16,9 +17,11 @@
|
|
|
16
17
|
"test": "vitest run --project unit",
|
|
17
18
|
"test:browser": "vitest run --project browser",
|
|
18
19
|
"test:e2e": "npm run build && playwright test",
|
|
19
|
-
"dist": "npm run build && electron-builder --mac",
|
|
20
|
+
"dist": "npm run build && electron-builder --mac --publish never",
|
|
20
21
|
"build:mcp": "tsc -p tsconfig.mcp.json",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"prepack": "node scripts/electron-dep.js to-prod",
|
|
24
|
+
"postpack": "node scripts/electron-dep.js to-dev"
|
|
22
25
|
},
|
|
23
26
|
"dependencies": {
|
|
24
27
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
|
@@ -15,37 +15,43 @@ where thin fonts, 0.5px hairlines, and low-contrast grey text actually break.
|
|
|
15
15
|
|
|
16
16
|
## Commands
|
|
17
17
|
|
|
18
|
-
Prerequisite:
|
|
19
|
-
|
|
20
|
-
`npm run build`
|
|
18
|
+
Prerequisite: none when using `npx -y getobsrv` (npm downloads everything,
|
|
19
|
+
including Electron, on first run). In a local Obsrv checkout, run
|
|
20
|
+
`npm install && npm run build` there first.
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
|
|
23
|
+
# Installed anywhere via npm (first run downloads Electron):
|
|
24
|
+
OBSRV="npx -y getobsrv"
|
|
25
|
+
# Or, in a local Obsrv checkout (faster, no download):
|
|
26
|
+
# OBSRV="node /path/to/Obsrv/bin/obsrv.js"
|
|
24
27
|
|
|
25
28
|
# One screen, one PNG (+ JSON metadata on stdout, humans on stderr):
|
|
26
|
-
|
|
29
|
+
$OBSRV snap http://localhost:5173 --preset laptop-768 --out shots/laptop.png
|
|
27
30
|
|
|
28
31
|
# The recommended matrix — small laptop, budget phone, 1080p desktop:
|
|
29
|
-
|
|
32
|
+
$OBSRV snap http://localhost:5173 --matrix laptop-768,android-65,1080p-24 --out shots/
|
|
30
33
|
|
|
31
34
|
# Worst realistic panel (cheap TN) on the small laptop:
|
|
32
|
-
|
|
35
|
+
$OBSRV snap http://localhost:5173 --preset laptop-768 --profile budget-tn --out shots/laptop-tn.png
|
|
33
36
|
|
|
34
37
|
# Whole page, not just the first viewport (device px cap 4096, warns if clamped):
|
|
35
|
-
|
|
38
|
+
$OBSRV snap http://localhost:5173 --preset laptop-768 --full-page --out shots/full.png
|
|
36
39
|
|
|
37
40
|
# Numbers instead of eyeballs: 1x target vs a 2x-reference downsample, JSON to stdout:
|
|
38
|
-
|
|
41
|
+
$OBSRV diff http://localhost:5173 --preset laptop-768 --out-dir shots/diff
|
|
39
42
|
```
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
`$OBSRV --help` lists every preset (`1080p-24`, `laptop-768`,
|
|
42
45
|
`android-65`, `iphone-61`, …), profile (`reference`, `office-ips`,
|
|
43
46
|
`budget-tn`, `old-laptop`), and flag (`--width/--height/--dsf`, `--wait`,
|
|
44
47
|
`--timeout`).
|
|
45
48
|
|
|
46
49
|
If the obsrv MCP tools are connected (`obsrv_snap` / `obsrv_diff` /
|
|
47
50
|
`obsrv_presets`), prefer them over shelling out — same pipeline, and the PNG
|
|
48
|
-
comes back inline.
|
|
51
|
+
comes back inline. If the Obsrv desktop app is open with "Agent control" on
|
|
52
|
+
(toolbar toggle), snaps drive the visible window — the user watches — and
|
|
53
|
+
`obsrv_drive` flips its URL/preset/profile directly; no app means the usual
|
|
54
|
+
headless render.
|
|
49
55
|
|
|
50
56
|
## The loop that catches real regressions
|
|
51
57
|
|