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.
@@ -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
+ }
@@ -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.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": "./bin/obsrv.js",
8
- "obsrv-mcp": "./bin/obsrv-mcp.js"
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: `npm run build` must have been run in the Obsrv repo (the CLI
19
- runs the built `out/`). If a snap fails with "out/main/cli.js is missing", run
20
- `npm run build` in `/Users/opeyemiajagbe/Documents/Projects/Obsrv` first.
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
- OBSRV=/Users/opeyemiajagbe/Documents/Projects/Obsrv/bin/obsrv.js
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
- node $OBSRV snap http://localhost:5173 --preset laptop-768 --out shots/laptop.png
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
- node $OBSRV snap http://localhost:5173 --matrix laptop-768,android-65,1080p-24 --out shots/
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
- node $OBSRV snap http://localhost:5173 --preset laptop-768 --profile budget-tn --out shots/laptop-tn.png
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
- node $OBSRV snap http://localhost:5173 --preset laptop-768 --full-page --out shots/full.png
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
- node $OBSRV diff http://localhost:5173 --preset laptop-768 --out-dir shots/diff
41
+ $OBSRV diff http://localhost:5173 --preset laptop-768 --out-dir shots/diff
39
42
  ```
40
43
 
41
- `node $OBSRV --help` lists every preset (`1080p-24`, `laptop-768`,
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