rpc-agent 0.0.0-stage → 0.3.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +302 -2
  5. package/assets/bezels/imac.svg +63 -0
  6. package/assets/bezels/ipad.svg +27 -0
  7. package/assets/bezels/iphone-11-pro.svg +35 -0
  8. package/assets/bezels/iphone-12-mini.svg +26 -0
  9. package/assets/bezels/iphone-12-pro-max.svg +26 -0
  10. package/assets/bezels/iphone-17-pro.svg +172 -0
  11. package/assets/bezels/iphone-se.svg +37 -0
  12. package/assets/bezels/laptop.svg +25 -0
  13. package/assets/bezels/macbook-air.svg +235 -0
  14. package/assets/bezels/macbook-pro-13.svg +63 -0
  15. package/assets/bezels/macbook-pro-14.svg +115 -0
  16. package/assets/bezels/macbook-pro.svg +32 -0
  17. package/assets/bezels/macbook.svg +25 -0
  18. package/assets/bezels/monitor.svg +57 -0
  19. package/assets/bezels/phone-classic.svg +17 -0
  20. package/assets/bezels/phone-home.svg +12 -0
  21. package/assets/bezels/phone-island.svg +130 -0
  22. package/assets/bezels/phone-notch.svg +26 -0
  23. package/assets/bezels/surface-laptop.svg +84 -0
  24. package/assets/bezels/tablet.svg +25 -0
  25. package/assets/bezels/windows-laptop.svg +84 -0
  26. package/dist/args.js +189 -0
  27. package/dist/bezel.js +74 -0
  28. package/dist/browser.js +80 -0
  29. package/dist/capture.js +241 -0
  30. package/dist/cli.js +175 -0
  31. package/dist/composite.js +115 -0
  32. package/dist/devices.js +407 -0
  33. package/dist/errors.js +115 -0
  34. package/dist/help.js +110 -0
  35. package/dist/mcp.js +245 -0
  36. package/dist/options.js +51 -0
  37. package/dist/png.js +183 -0
  38. package/dist/slices.js +59 -0
  39. package/dist/term.js +46 -0
  40. package/dist/ui.js +488 -0
  41. package/package.json +67 -4
package/dist/cli.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { relative } from 'node:path';
4
+ import { ArgError, parseCli, USAGE } from './args.js';
5
+ import { doctor, installChromium } from './browser.js';
6
+ import { capture } from './capture.js';
7
+ import { CATEGORY_LABELS, CATEGORY_ORDER, DEVICE_ALIASES, DEVICES } from './devices.js';
8
+ import { currentMode, openPath } from './term.js';
9
+ const NO_PAINT = { ok: s => s, bad: s => s, warn: s => s };
10
+ let paint = NO_PAINT;
11
+ async function plainPaint() {
12
+ if (!currentMode().color)
13
+ return NO_PAINT;
14
+ const { default: pc } = await import('picocolors');
15
+ const c = pc.createColors(true);
16
+ return { ok: c.green, bad: c.red, warn: c.yellow };
17
+ }
18
+ // Loaded only for rich (interactive terminal) runs; `rpc mcp` and --json
19
+ // never import clack.
20
+ async function loadUi() {
21
+ const ui = await import('./ui.js');
22
+ return { ui, c: ui.colors(currentMode().color) };
23
+ }
24
+ async function main(argv) {
25
+ const cmd = parseCli(argv);
26
+ if (cmd.command === 'mcp') {
27
+ const { runMcpServer } = await import('./mcp.js');
28
+ await runMcpServer();
29
+ return -1; // keep running; stdio transport owns the process
30
+ }
31
+ const json = 'json' in cmd && cmd.json;
32
+ const mode = currentMode(json);
33
+ if (!mode.rich && !json)
34
+ paint = await plainPaint();
35
+ switch (cmd.command) {
36
+ case 'help': {
37
+ if (argv.length === 0 && mode.rich) {
38
+ const { ui, c } = await loadUi();
39
+ return ui.runInteractive(c);
40
+ }
41
+ if (!cmd.topic && !mode.rich) {
42
+ process.stdout.write(USAGE);
43
+ return 0;
44
+ }
45
+ const { commandHelp, richHelp } = await import('./help.js');
46
+ const { default: pc } = await import('picocolors');
47
+ const c = pc.createColors(mode.color);
48
+ process.stdout.write(cmd.topic ? commandHelp(cmd.topic, c) : richHelp(c));
49
+ return 0;
50
+ }
51
+ case 'version': {
52
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
53
+ console.log(pkg.version);
54
+ return 0;
55
+ }
56
+ case 'devices':
57
+ if (cmd.json) {
58
+ console.log(JSON.stringify({ devices: DEVICES, aliases: DEVICE_ALIASES }, null, 2));
59
+ }
60
+ else if (mode.rich) {
61
+ const { ui, c } = await loadUi();
62
+ ui.printDevices(c);
63
+ }
64
+ else {
65
+ process.stdout.write(formatDeviceTable());
66
+ }
67
+ return 0;
68
+ case 'doctor': {
69
+ if (mode.rich) {
70
+ const { ui, c } = await loadUi();
71
+ return ui.runDoctor(doctor, c);
72
+ }
73
+ const report = await doctor();
74
+ if (cmd.json)
75
+ console.log(JSON.stringify(report, null, 2));
76
+ else
77
+ printDoctor(report);
78
+ return report.ok ? 0 : 1;
79
+ }
80
+ case 'install': {
81
+ const code = await installChromium();
82
+ if (code !== 0)
83
+ return code;
84
+ if (mode.rich) {
85
+ const { ui, c } = await loadUi();
86
+ return ui.runDoctor(doctor, c);
87
+ }
88
+ const report = await doctor();
89
+ printDoctor(report);
90
+ return report.ok ? 0 : 1;
91
+ }
92
+ case 'shot': {
93
+ if (mode.rich) {
94
+ const { ui, c } = await loadUi();
95
+ return ui.runShot(cmd.options, { open: cmd.open }, c);
96
+ }
97
+ const result = await capture(cmd.options, cmd.json ? {} : { onDevice: printShot });
98
+ if (cmd.json)
99
+ console.log(JSON.stringify(result, null, 2));
100
+ if (cmd.open && result.shots.some(s => !s.error))
101
+ openPath(result.outDir);
102
+ return result.shots.some(s => s.error) ? 1 : 0;
103
+ }
104
+ }
105
+ }
106
+ function rel(p) {
107
+ const r = relative(process.cwd(), p);
108
+ return r.startsWith('..') ? p : r;
109
+ }
110
+ function printDoctor(r) {
111
+ console.log(`node ${r.node}`);
112
+ console.log(`playwright ${r.playwright}`);
113
+ console.log(`browsers ${r.browsersPath}`);
114
+ if (r.ok) {
115
+ console.log(`chromium ${paint.ok('ok')} (${r.version})`);
116
+ return;
117
+ }
118
+ console.log(`chromium ${paint.bad(r.chromium)}`);
119
+ console.error(`rpc: ${r.error}`);
120
+ if (r.chromium === 'missing')
121
+ console.error('Run `rpc install` to download it now.');
122
+ }
123
+ function printShot(s) {
124
+ const label = `${s.device} (${s.viewport.width}x${s.viewport.height} @${s.dpr}x)`;
125
+ if (s.error) {
126
+ console.error(`${paint.bad('x')} ${label}: ${s.error}`);
127
+ if (s.hint)
128
+ console.error(` ${paint.warn('hint:')} ${s.hint}`);
129
+ return;
130
+ }
131
+ const lines = [`${paint.ok('ok')} ${label}${s.status && s.status >= 400 ? ` HTTP ${s.status}` : ''}`];
132
+ if (s.shot) {
133
+ const extra = [
134
+ s.segments && s.segments > 1 ? `${s.segments} segments stitched` : '',
135
+ s.clipped ? `clipped at ${s.capturedHeight} of ${s.scrollHeight} css px` : '',
136
+ ].filter(Boolean);
137
+ lines.push(` ${rel(s.shot.path)} ${s.shot.width}x${s.shot.height}${extra.length ? ` (${extra.join(', ')})` : ''}`);
138
+ }
139
+ if (s.framed)
140
+ lines.push(` ${rel(s.framed.path)} ${s.framed.width}x${s.framed.height}`);
141
+ if (s.slices?.saved && s.slices.items[0]?.path) {
142
+ const first = rel(s.slices.items[0].path);
143
+ lines.push(` ${first.replace(/-slice-\d+\.jpg$/, '-slice-NN.jpg')} ${s.slices.total} slice(s), ${s.slices.items[0].width}px wide`);
144
+ }
145
+ if (s.consoleErrors?.length)
146
+ lines.push(` ${s.consoleErrors.length} console error(s), first: ${s.consoleErrors[0]}`);
147
+ console.log(lines.join('\n'));
148
+ }
149
+ export function formatDeviceTable() {
150
+ const out = [];
151
+ for (const cat of CATEGORY_ORDER) {
152
+ out.push(CATEGORY_LABELS[cat]);
153
+ for (const d of DEVICES.filter(x => x.category === cat)) {
154
+ out.push(` ${d.id.padEnd(20)} ${`${d.width}x${d.height}`.padEnd(10)} @${String(d.dpr).padEnd(6)} ${d.name}`);
155
+ }
156
+ out.push('');
157
+ }
158
+ out.push('Aliases');
159
+ for (const [alias, id] of Object.entries(DEVICE_ALIASES))
160
+ out.push(` ${alias.padEnd(20)} -> ${id}`);
161
+ return out.join('\n') + '\n';
162
+ }
163
+ main(process.argv.slice(2)).then(code => {
164
+ if (code >= 0)
165
+ process.exitCode = code;
166
+ }, err => {
167
+ if (err instanceof ArgError) {
168
+ console.error(`rpc: ${err.message}`);
169
+ process.exitCode = 2;
170
+ }
171
+ else {
172
+ console.error(`rpc: ${err.message}`);
173
+ process.exitCode = 1;
174
+ }
175
+ });
@@ -0,0 +1,115 @@
1
+ import { bezelLayoutFor, loadBezelSvg } from './bezel.js';
2
+ /** Any origin works; requests to it never leave the process (page.route). */
3
+ const ORIGIN = 'http://rpc-compositor.invalid';
4
+ /** Long-side cap for framed output so 3x phones and 2x 27" displays stay sane. */
5
+ export const MAX_FRAMED_PX = 4096;
6
+ /**
7
+ * Serve `html` at ORIGIN/ and `images` at ORIGIN/shot.png (the first) and
8
+ * ORIGIN/img/<n>.png from a throwaway page, then run `fn`. Routing avoids
9
+ * stuffing multi-MB data: URLs into setContent.
10
+ */
11
+ async function withStage(browser, viewport, deviceScaleFactor, html, images, fn) {
12
+ const list = Array.isArray(images) ? images : [images];
13
+ const context = await browser.newContext({ viewport, deviceScaleFactor });
14
+ try {
15
+ const page = await context.newPage();
16
+ await page.route(`${ORIGIN}/**`, route => {
17
+ const path = new URL(route.request().url()).pathname;
18
+ const m = /^\/img\/(\d+)\.png$/.exec(path);
19
+ const img = path === '/shot.png' ? list[0] : m ? list[Number(m[1])] : undefined;
20
+ if (img)
21
+ return route.fulfill({ status: 200, contentType: 'image/png', body: img });
22
+ if (path !== '/')
23
+ return route.fulfill({ status: 404 });
24
+ return route.fulfill({ status: 200, contentType: 'text/html', body: html });
25
+ });
26
+ await page.goto(`${ORIGIN}/`);
27
+ // String predicate: see the note on in-page scripts in capture.ts.
28
+ await page.waitForFunction(`Array.from(document.images).every(i => i.complete)`);
29
+ return await fn(page);
30
+ }
31
+ finally {
32
+ await context.close();
33
+ }
34
+ }
35
+ /** Strip the root <svg>'s fixed width/height so CSS can size it, as RPC does. */
36
+ function prepareSvg(svg) {
37
+ return svg.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height)="[^"]*"/g, '').replace('<svg', '<svg style="position:absolute;left:0;top:0;width:100%;height:100%"'));
38
+ }
39
+ /**
40
+ * Composite a viewport screenshot into the device bezel. Mirrors RPC's
41
+ * frame.ts: SVG fills the outer box underneath, the page sits on top at the
42
+ * screen rect with the screen's corner radius. Output has a transparent
43
+ * background and the device's DPR (capped by MAX_FRAMED_PX).
44
+ */
45
+ export async function renderFramed(browser, device, viewportPng) {
46
+ const layout = bezelLayoutFor(device);
47
+ const { screen } = layout;
48
+ const scale = Math.min(device.dpr, MAX_FRAMED_PX / Math.max(layout.outerWidth, layout.outerHeight));
49
+ const html = `<!doctype html><html><head><style>
50
+ html,body{margin:0;background:transparent}
51
+ #frame{position:relative;width:${layout.outerWidth}px;height:${layout.outerHeight}px}
52
+ #bezel{position:absolute;inset:0;z-index:1}
53
+ #screen{position:absolute;z-index:2;display:block;left:${screen.x}px;top:${screen.y}px;
54
+ width:${screen.width}px;height:${screen.height}px;border-radius:${screen.radius}px;object-fit:fill}
55
+ </style></head><body><div id="frame"><div id="bezel">${prepareSvg(loadBezelSvg(layout.src))}</div>
56
+ <img id="screen" src="/shot.png" alt=""></div></body></html>`;
57
+ return withStage(browser, { width: layout.outerWidth, height: layout.outerHeight }, scale, html, viewportPng, page => page.locator('#frame').screenshot({ omitBackground: true, type: 'png' }));
58
+ }
59
+ /**
60
+ * Small JPEG for returning inline over MCP. Tall full-page shots are cropped
61
+ * to the top `maxAspect` region first so text is still legible after scaling.
62
+ */
63
+ export async function renderPreview(browser, png, size, { maxDim = 1024, maxAspect = 3, quality = 70 } = {}) {
64
+ const srcH = Math.min(size.height, Math.round(size.width * maxAspect));
65
+ const cropped = srcH < size.height;
66
+ const scale = Math.min(1, maxDim / Math.max(size.width, srcH));
67
+ const width = Math.max(1, Math.round(size.width * scale));
68
+ const height = Math.max(1, Math.round(srcH * scale));
69
+ const html = `<!doctype html><html><head><style>
70
+ html,body{margin:0;background:#fff}
71
+ #c{width:${width}px;height:${height}px;overflow:hidden}
72
+ img{display:block;width:${width}px;height:auto}
73
+ </style></head><body><div id="c"><img src="/shot.png" alt=""></div></body></html>`;
74
+ const data = await withStage(browser, { width, height }, 1, html, png, page => page.locator('#c').screenshot({ type: 'jpeg', quality }));
75
+ return { data, width, height, cropped };
76
+ }
77
+ /**
78
+ * Render review slices (JPEG) from full-page capture segments. The segments
79
+ * are stacked at preview scale in one compositor page and each slice is a
80
+ * clipped view of that stack, so neither the page nor any slice ever needs a
81
+ * bitmap taller than a single segment.
82
+ *
83
+ * `cssWidth` is the captured width in CSS px; `scale` is output px per CSS px.
84
+ */
85
+ export async function renderSlices(browser, segments, slices, cssWidth, scale, quality = 70) {
86
+ if (slices.length === 0)
87
+ return [];
88
+ const width = Math.max(1, Math.round(cssWidth * scale));
89
+ const maxH = Math.max(...slices.map(s => Math.round(s.height * scale)));
90
+ const imgs = segments
91
+ .map((seg, i) =>
92
+ // Whole-pixel edges so adjacent segments meet without a blended seam row.
93
+ `<img src="/img/${i}.png" alt="" style="position:absolute;left:0;top:${Math.round(seg.y * scale)}px;` +
94
+ `width:${width}px;height:${Math.round((seg.y + seg.height) * scale) - Math.round(seg.y * scale)}px">`)
95
+ .join('');
96
+ const html = `<!doctype html><html><head><style>
97
+ html,body{margin:0;background:#fff}
98
+ #c{position:relative;width:${width}px;height:${maxH}px;overflow:hidden}
99
+ #s{position:absolute;left:0;top:0;width:${width}px}
100
+ img{display:block}
101
+ </style></head><body><div id="c"><div id="s">${imgs}</div></div></body></html>`;
102
+ return withStage(browser, { width, height: maxH }, 1, html, segments.map(s => s.png), async (page) => {
103
+ const out = [];
104
+ for (const slice of slices) {
105
+ const pxHeight = Math.max(1, Math.round(slice.height * scale));
106
+ await page.evaluate(`(() => {
107
+ document.getElementById('c').style.height = '${pxHeight}px';
108
+ document.getElementById('s').style.transform = 'translateY(${-Math.round(slice.y * scale)}px)';
109
+ })()`);
110
+ const data = await page.locator('#c').screenshot({ type: 'jpeg', quality });
111
+ out.push({ ...slice, data, width, pxHeight });
112
+ }
113
+ return out;
114
+ });
115
+ }