@houwert/conductor 0.18.0 → 0.19.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.
@@ -2,7 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.clearState = clearState;
5
- exports.HELP = ` clear-state [<appId>] Clear app data/state`;
5
+ exports.HELP = ` clear-state [<appId>] DESTRUCTIVE: wipe app data and signed-in state.
6
+ On iOS this uninstall+reinstalls the app, which also drops
7
+ the app's keychain items — the user will be signed out and
8
+ cannot be recovered without their credentials. Do not use to
9
+ reset focus or navigation state.`;
6
10
  const runner_js_1 = require("../runner.js");
7
11
  const session_js_1 = require("../session.js");
8
12
  const output_js_1 = require("../output.js");
@@ -13,6 +17,8 @@ async function clearState(appId, opts = {}, sessionName = 'default') {
13
17
  (0, output_js_1.printError)('clear-state: no appId provided and no active session. Run launch-app first.', opts);
14
18
  return 1;
15
19
  }
20
+ process.stderr.write('warning: clear-state wipes app data AND signed-in state; the user will be signed out ' +
21
+ 'and cannot be recovered without their credentials.\n');
16
22
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
17
23
  await driver.clearAppState(resolvedAppId);
18
24
  }, sessionName);
@@ -3,8 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.launchApp = launchApp;
5
5
  exports.HELP = ` launch-app <appId> Launch app (saves to session)
6
- --clear-state Clear app data/state before launching
7
- --clear-keychain Clear keychain before launching
6
+ --clear-state DESTRUCTIVE: wipe app data AND signed-in state before launching.
7
+ On iOS this uninstall+reinstalls the app, which also drops the
8
+ app's keychain items — the user will be signed out and you
9
+ cannot undo it without their credentials. Do not use to reset
10
+ focus or navigation state; relaunch without this flag instead.
11
+ --clear-keychain DESTRUCTIVE: wipe the device keychain before launching. Signs
12
+ the user out of every app on the simulator. Cannot be undone
13
+ without re-entering credentials.
8
14
  --no-stop-app Do not stop the app before launching (resume instead of restart)
9
15
  --argument key=value Set launch argument (repeatable)`;
10
16
  const runner_js_1 = require("../runner.js");
@@ -19,6 +25,10 @@ async function launchApp(appId, deviceId, opts = {}, sessionName = 'default', fl
19
25
  return 1;
20
26
  }
21
27
  await (0, session_js_1.updateSession)({ appId, ...(deviceId ? { deviceId } : {}) }, sessionName);
28
+ if (flags.clearState || flags.clearKeychain) {
29
+ process.stderr.write('warning: --clear-state / --clear-keychain wipes app data AND signed-in state; ' +
30
+ 'the user will be signed out and cannot be recovered without their credentials.\n');
31
+ }
22
32
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
23
33
  if (flags.clearKeychain)
24
34
  await driver.clearKeychain();
@@ -5,24 +5,108 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
7
  exports.screenshot = screenshot;
8
- exports.HELP = ` take-screenshot [--output <path>] [--full-page]
9
- Take screenshot (--full-page: web only, capture entire scrollable page)`;
8
+ exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page]
9
+ Take screenshot (--full-page: web only, capture entire scrollable page)
10
+ <element> Crop to the element matched by text (positional)
11
+ --id <id> Crop to the element matched by accessibility id
12
+ --text <text> Crop to the element matched by text only (not id)
13
+ --index <n> Pick the nth match (0-based)
14
+ --margin <px> Extra pixels around the crop (default 8) to capture shadows
15
+ --focused Match only focused elements
16
+ --enabled / --no-enabled Match by enabled state
17
+ --checked / --no-checked Match by checked state
18
+ --selected / --no-selected Match by selected state
19
+ --below <text> Match element below the given reference
20
+ --above <text> Match element above the given reference
21
+ --left-of <text> Match element left of the given reference
22
+ --right-of <text> Match element right of the given reference`;
10
23
  const path_1 = __importDefault(require("path"));
11
24
  const promises_1 = __importDefault(require("fs/promises"));
12
25
  const runner_js_1 = require("../runner.js");
13
26
  const output_js_1 = require("../output.js");
14
- async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false) {
27
+ const ios_js_1 = require("../drivers/ios.js");
28
+ const android_js_1 = require("../drivers/android.js");
29
+ const web_js_1 = require("../drivers/web.js");
30
+ const wait_js_1 = require("../drivers/wait.js");
31
+ const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
32
+ const png_crop_js_1 = require("../png-crop.js");
33
+ const DEFAULT_MARGIN_PX = 8;
34
+ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
15
35
  const timestamp = Date.now();
16
36
  const defaultName = `screenshot-${timestamp}.png`;
17
37
  const resolvedPath = outputPath
18
38
  ? path_1.default.resolve(outputPath)
19
39
  : path_1.default.resolve(process.cwd(), defaultName);
40
+ const hasSelector = !!(query || flags.id || flags.text);
41
+ const sel = hasSelector
42
+ ? {
43
+ ...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query }),
44
+ ...(flags.index !== undefined && { index: flags.index }),
45
+ ...(flags.focused !== undefined && { focused: flags.focused }),
46
+ ...(flags.enabled !== undefined && { enabled: flags.enabled }),
47
+ ...(flags.checked !== undefined && { checked: flags.checked }),
48
+ ...(flags.selected !== undefined && { selected: flags.selected }),
49
+ ...(flags.below && { below: { query: flags.below } }),
50
+ ...(flags.above && { above: { query: flags.above } }),
51
+ ...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
52
+ ...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
53
+ }
54
+ : null;
55
+ const label = flags.text
56
+ ? `text="${flags.text}"`
57
+ : flags.id
58
+ ? `id="${flags.id}"`
59
+ : query
60
+ ? `"${query}"`
61
+ : '';
62
+ const margin = flags.margin ?? DEFAULT_MARGIN_PX;
20
63
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
21
64
  const buf = await driver.screenshot({ fullPage });
22
- await promises_1.default.writeFile(resolvedPath, buf);
65
+ let out = buf;
66
+ if (sel) {
67
+ let el;
68
+ let hierarchyW;
69
+ let hierarchyH;
70
+ if (driver instanceof ios_js_1.IOSDriver) {
71
+ const h = await driver.viewHierarchy(false, [], { cache: false });
72
+ hierarchyW = h.axElement.frame.Width;
73
+ hierarchyH = h.axElement.frame.Height;
74
+ el = await (0, wait_js_1.waitForIOSElement)((o) => driver.viewHierarchy(false, [], { cache: o?.cached }).then((x) => x.axElement), sel, undefined, undefined, (0, direct_ios_selector_js_1.makeIOSDirectResolver)(driver, sel));
75
+ }
76
+ else if (driver instanceof web_js_1.WebDriver) {
77
+ const info = await driver.deviceInfo();
78
+ hierarchyW = info.widthPixels;
79
+ hierarchyH = info.heightPixels;
80
+ el = await (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), sel);
81
+ }
82
+ else if (driver instanceof android_js_1.AndroidDriver) {
83
+ const xml = await driver.viewHierarchy();
84
+ // Android XML root bounds: derive from the first parseable <node bounds="[0,0][W,H]">
85
+ const m = xml.match(/<node[^>]*bounds="\[0,0\]\[(\d+),(\d+)\]"/);
86
+ hierarchyW = m ? +m[1] : 0;
87
+ hierarchyH = m ? +m[2] : 0;
88
+ el = await (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel);
89
+ }
90
+ else {
91
+ throw new Error('selector cropping is not supported for this driver');
92
+ }
93
+ const { width: pngW, height: pngH } = (0, png_crop_js_1.readPngDimensions)(buf);
94
+ const scaleX = hierarchyW > 0 ? pngW / hierarchyW : 1;
95
+ const scaleY = hierarchyH > 0 ? pngH / hierarchyH : 1;
96
+ const rectX = Math.round(el.bounds.x * scaleX - margin);
97
+ const rectY = Math.round(el.bounds.y * scaleY - margin);
98
+ const rectW = Math.round(el.bounds.width * scaleX + margin * 2);
99
+ const rectH = Math.round(el.bounds.height * scaleY + margin * 2);
100
+ if (rectX + rectW <= 0 || rectY + rectH <= 0 || rectX >= pngW || rectY >= pngH) {
101
+ throw new Error(`element ${label} bounds [${rectX},${rectY} ${rectW}x${rectH}] are outside the screenshot (${pngW}x${pngH})`);
102
+ }
103
+ out = (0, png_crop_js_1.cropPng)(buf, { x: rectX, y: rectY, width: rectW, height: rectH });
104
+ }
105
+ await promises_1.default.writeFile(resolvedPath, out);
23
106
  }, sessionName);
24
107
  if (result.success) {
25
- (0, output_js_1.printSuccess)(`screenshot saved to ${resolvedPath}`, opts);
108
+ const suffix = sel ? ` (${label})` : '';
109
+ (0, output_js_1.printSuccess)(`screenshot saved to ${resolvedPath}${suffix}`, opts);
26
110
  return 0;
27
111
  }
28
112
  else {
@@ -110,7 +110,7 @@ async function ensureDriverRunning() {
110
110
  }
111
111
  else if (driverPlatform === 'tvos') {
112
112
  // Health-check restart — don't dismiss, to avoid disrupting user's app
113
- await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
113
+ await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* restoreFocusAfterLaunch */ false);
114
114
  }
115
115
  else if (driverPlatform === 'web') {
116
116
  await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
@@ -357,8 +357,9 @@ async function main() {
357
357
  await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
358
358
  }
359
359
  else if (platform === 'tvos') {
360
- // First install — dismiss the runner app to return to homescreen
361
- await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
360
+ // First install — the runner takes foreground; ask it to hand
361
+ // focus back to whatever app the user had open.
362
+ await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* restoreFocusAfterLaunch */ true);
362
363
  }
363
364
  else if (platform === 'web') {
364
365
  await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
@@ -547,11 +547,16 @@ async function setupTvOSDriverCache() {
547
547
  * Start the tvOS XCTest driver via `xcodebuild test-without-building`.
548
548
  * Mirrors startIOSDriver but targets the tvOS xctestrun.
549
549
  *
550
- * On first launch the runner app appears in the foreground, so we press the
551
- * home button to dismiss it. On subsequent restarts (e.g. health-check recovery)
552
- * we skip the dismiss to avoid disrupting the user's navigation state.
550
+ * On first launch the runner app appears in the foreground, displacing whatever
551
+ * app the user had open. When `restoreFocusAfterLaunch` is set we ask the driver
552
+ * to re-activate that app once the server is up; otherwise the runner stays in
553
+ * front and commands like `inspect` run against the wrong target. The driver
554
+ * falls back to pressing home when no candidate app is found.
555
+ *
556
+ * Subsequent restarts (e.g. health-check recovery) skip the restore to avoid
557
+ * disrupting whatever the user is doing.
553
558
  */
554
- async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, dismissAfterLaunch = false) {
559
+ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, restoreFocusAfterLaunch = false) {
555
560
  if (await isPortOpen(port)) {
556
561
  (0, verbose_js_1.log)(`tvOS driver already running on port ${port}`);
557
562
  return;
@@ -579,13 +584,18 @@ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, dismissAfterLaun
579
584
  await (0, utils_js_1.sleep)(TVOS_STARTUP_POLL_MS);
580
585
  if (await isPortOpen(port)) {
581
586
  (0, verbose_js_1.log)(`tvOS driver ready on port ${port}`);
582
- if (dismissAfterLaunch) {
587
+ if (restoreFocusAfterLaunch) {
583
588
  try {
584
- await pressButtonViaDriver(port, 'home');
585
- (0, verbose_js_1.log)('Dismissed tvOS driver app');
589
+ const restored = await restoreFocusViaDriver(port);
590
+ if (restored) {
591
+ (0, verbose_js_1.log)(`Restored tvOS focus to ${restored}`);
592
+ }
593
+ else {
594
+ (0, verbose_js_1.log)('Dismissed tvOS driver app (no previous app to restore)');
595
+ }
586
596
  }
587
597
  catch {
588
- (0, verbose_js_1.log)('Could not dismiss tvOS driver app (non-fatal)');
598
+ (0, verbose_js_1.log)('Could not restore tvOS focus (non-fatal)');
589
599
  }
590
600
  }
591
601
  return;
@@ -827,25 +837,39 @@ async function uninstallDriver(deviceId, platform) {
827
837
  }
828
838
  }
829
839
  // ── Helpers ───────────────────────────────────────────────────────────────────
830
- /** Send a pressButton command directly to the driver HTTP server. */
831
- function pressButtonViaDriver(port, button) {
840
+ /**
841
+ * Ask the driver to restore foreground focus to whatever app was active before
842
+ * the runner launched. Returns the bundle ID that was activated, or null when
843
+ * the driver fell back to pressing home because no previous app was found.
844
+ */
845
+ function restoreFocusViaDriver(port) {
832
846
  return new Promise((resolve, reject) => {
833
- const body = JSON.stringify({ button });
834
847
  const options = {
835
848
  hostname: '127.0.0.1',
836
849
  port,
837
- path: '/pressButton',
850
+ path: '/restoreFocus',
838
851
  method: 'POST',
839
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
852
+ headers: { 'Content-Type': 'application/json', 'Content-Length': 0 },
840
853
  };
841
854
  const req = http_1.default.request(options, (res) => {
842
- res.resume();
843
- res.on('end', () => res.statusCode && res.statusCode < 300
844
- ? resolve()
845
- : reject(new Error(`HTTP ${res.statusCode}`)));
855
+ const chunks = [];
856
+ res.on('data', (c) => chunks.push(c));
857
+ res.on('end', () => {
858
+ if (!res.statusCode || res.statusCode >= 300) {
859
+ reject(new Error(`HTTP ${res.statusCode}`));
860
+ return;
861
+ }
862
+ try {
863
+ const body = Buffer.concat(chunks).toString('utf-8');
864
+ const parsed = JSON.parse(body);
865
+ resolve(parsed.restoredBundleId ? parsed.restoredBundleId : null);
866
+ }
867
+ catch (err) {
868
+ reject(err);
869
+ }
870
+ });
846
871
  });
847
872
  req.on('error', reject);
848
- req.write(body);
849
873
  req.end();
850
874
  });
851
875
  }
@@ -63,6 +63,7 @@ function makeIOSDirectResolver(driver, sel, appIds = []) {
63
63
  return {
64
64
  centerX: X + Width / 2,
65
65
  centerY: Y + Height / 2,
66
+ bounds: { x: X, y: Y, width: Width, height: Height },
66
67
  text: n.label || n.title || n.value || n.placeholderValue || undefined,
67
68
  id: n.identifier || undefined,
68
69
  };
@@ -204,6 +204,7 @@ function findIOSElement(root, sel) {
204
204
  return {
205
205
  centerX: X + Width / 2,
206
206
  centerY: Y + Height / 2,
207
+ bounds: { x: X, y: Y, width: Width, height: Height },
207
208
  text: iosTextOf(node) || undefined,
208
209
  id: node.identifier || undefined,
209
210
  };
@@ -353,6 +354,7 @@ function findAndroidElement(xml, sel) {
353
354
  return {
354
355
  centerX: (x1 + x2) / 2,
355
356
  centerY: (y1 + y2) / 2,
357
+ bounds: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 },
356
358
  text: androidTextOf(node) || undefined,
357
359
  id: node.resourceId || undefined,
358
360
  };
@@ -558,6 +560,7 @@ function findWebElement(hierarchy, sel) {
558
560
  return {
559
561
  centerX: b.x + b.width / 2,
560
562
  centerY: b.y + b.height / 2,
563
+ bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
561
564
  text: node.name || undefined,
562
565
  id: node.ref || undefined,
563
566
  };
package/dist/index.js CHANGED
@@ -532,7 +532,21 @@ async function main() {
532
532
  case 'take-screenshot': {
533
533
  const outPath = argv['output'];
534
534
  const fullPage = Boolean(argv['full-page']);
535
- exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName, fullPage);
535
+ const element = rest.join(' ');
536
+ exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName, fullPage, element, {
537
+ id: argv['id'],
538
+ text: argv['text'],
539
+ index: argv['index'] !== undefined ? Number(argv['index']) : undefined,
540
+ margin: argv['margin'] !== undefined ? Number(argv['margin']) : undefined,
541
+ focused: argv['focused'] !== undefined ? argv['focused'] : undefined,
542
+ enabled: argv['enabled'] !== undefined ? argv['enabled'] : undefined,
543
+ checked: argv['checked'] !== undefined ? argv['checked'] : undefined,
544
+ selected: argv['selected'] !== undefined ? argv['selected'] : undefined,
545
+ below: argv['below'],
546
+ above: argv['above'],
547
+ leftOf: argv['left-of'],
548
+ rightOf: argv['right-of'],
549
+ });
536
550
  break;
537
551
  }
538
552
  case 'capture-ui': {
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.readPngDimensions = readPngDimensions;
7
+ exports.cropPng = cropPng;
8
+ /**
9
+ * Minimal PNG cropper — decodes 8-bit RGB / RGBA / grayscale / grayscale+alpha
10
+ * PNGs, crops to a rect, re-encodes with filter type 0 (None).
11
+ *
12
+ * Used by `take-screenshot --id/--text/<query>` to return only the pixels
13
+ * inside a resolved element's bounds.
14
+ */
15
+ const zlib_1 = __importDefault(require("zlib"));
16
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
17
+ const CRC_TABLE = (() => {
18
+ const table = new Uint32Array(256);
19
+ for (let n = 0; n < 256; n++) {
20
+ let c = n;
21
+ for (let k = 0; k < 8; k++)
22
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
23
+ table[n] = c >>> 0;
24
+ }
25
+ return table;
26
+ })();
27
+ function crc32(buf) {
28
+ let c = 0xffffffff;
29
+ for (let i = 0; i < buf.length; i++)
30
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
31
+ return (c ^ 0xffffffff) >>> 0;
32
+ }
33
+ /** Read width/height from a PNG IHDR chunk. Throws if `buf` is not a PNG. */
34
+ function readPngDimensions(buf) {
35
+ if (buf.length < 24 || !SIGNATURE.equals(buf.subarray(0, 8))) {
36
+ throw new Error('not a PNG');
37
+ }
38
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
39
+ }
40
+ function bytesPerPixel(colorType) {
41
+ switch (colorType) {
42
+ case 0:
43
+ return 1; // grayscale
44
+ case 2:
45
+ return 3; // RGB
46
+ case 3:
47
+ return 1; // palette (unsupported below)
48
+ case 4:
49
+ return 2; // grayscale + alpha
50
+ case 6:
51
+ return 4; // RGBA
52
+ default:
53
+ throw new Error(`unsupported PNG color type ${colorType}`);
54
+ }
55
+ }
56
+ function paeth(a, b, c) {
57
+ const p = a + b - c;
58
+ const pa = Math.abs(p - a);
59
+ const pb = Math.abs(p - b);
60
+ const pc = Math.abs(p - c);
61
+ if (pa <= pb && pa <= pc)
62
+ return a;
63
+ if (pb <= pc)
64
+ return b;
65
+ return c;
66
+ }
67
+ function unfilter(filtered, w, h, bpp) {
68
+ const stride = w * bpp;
69
+ const out = Buffer.alloc(stride * h);
70
+ let inOff = 0;
71
+ for (let y = 0; y < h; y++) {
72
+ const filter = filtered[inOff++];
73
+ const rowOff = y * stride;
74
+ for (let x = 0; x < stride; x++) {
75
+ const raw = filtered[inOff++];
76
+ const left = x >= bpp ? out[rowOff + x - bpp] : 0;
77
+ const up = y > 0 ? out[rowOff - stride + x] : 0;
78
+ const upLeft = x >= bpp && y > 0 ? out[rowOff - stride + x - bpp] : 0;
79
+ let v;
80
+ switch (filter) {
81
+ case 0:
82
+ v = raw;
83
+ break;
84
+ case 1:
85
+ v = raw + left;
86
+ break;
87
+ case 2:
88
+ v = raw + up;
89
+ break;
90
+ case 3:
91
+ v = raw + ((left + up) >> 1);
92
+ break;
93
+ case 4:
94
+ v = raw + paeth(left, up, upLeft);
95
+ break;
96
+ default:
97
+ throw new Error(`unsupported PNG filter ${filter}`);
98
+ }
99
+ out[rowOff + x] = v & 0xff;
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+ function writeChunk(out, type, data) {
105
+ const len = Buffer.alloc(4);
106
+ len.writeUInt32BE(data.length, 0);
107
+ const typeBuf = Buffer.from(type, 'ascii');
108
+ const crcInput = Buffer.concat([typeBuf, data]);
109
+ const crc = Buffer.alloc(4);
110
+ crc.writeUInt32BE(crc32(crcInput), 0);
111
+ out.push(len, typeBuf, data, crc);
112
+ }
113
+ /**
114
+ * Crop a PNG buffer to the given rect. Coordinates are in PNG pixel space;
115
+ * `x`/`y`/`width`/`height` are clamped to the PNG canvas before cropping.
116
+ * Throws if the rect lies fully outside the canvas.
117
+ */
118
+ function cropPng(buf, rect) {
119
+ if (buf.length < 8 || !SIGNATURE.equals(buf.subarray(0, 8))) {
120
+ throw new Error('not a PNG');
121
+ }
122
+ let off = 8;
123
+ let ihdr = null;
124
+ const idatParts = [];
125
+ while (off < buf.length) {
126
+ const len = buf.readUInt32BE(off);
127
+ off += 4;
128
+ const type = buf.subarray(off, off + 4).toString('ascii');
129
+ off += 4;
130
+ const data = buf.subarray(off, off + len);
131
+ off += len;
132
+ off += 4; // CRC
133
+ if (type === 'IHDR') {
134
+ ihdr = {
135
+ width: data.readUInt32BE(0),
136
+ height: data.readUInt32BE(4),
137
+ bitDepth: data.readUInt8(8),
138
+ colorType: data.readUInt8(9),
139
+ };
140
+ }
141
+ else if (type === 'IDAT') {
142
+ idatParts.push(data);
143
+ }
144
+ else if (type === 'IEND') {
145
+ break;
146
+ }
147
+ }
148
+ if (!ihdr)
149
+ throw new Error('PNG missing IHDR');
150
+ if (ihdr.bitDepth !== 8) {
151
+ throw new Error(`unsupported PNG bit depth ${ihdr.bitDepth} (only 8 supported)`);
152
+ }
153
+ if (ihdr.colorType === 3) {
154
+ throw new Error('palette PNGs are not supported for cropping');
155
+ }
156
+ const bpp = bytesPerPixel(ihdr.colorType);
157
+ // Clamp crop rect to canvas bounds
158
+ const cx = Math.max(0, Math.min(Math.floor(rect.x), ihdr.width));
159
+ const cy = Math.max(0, Math.min(Math.floor(rect.y), ihdr.height));
160
+ const cx2 = Math.max(0, Math.min(Math.floor(rect.x + rect.width), ihdr.width));
161
+ const cy2 = Math.max(0, Math.min(Math.floor(rect.y + rect.height), ihdr.height));
162
+ const cw = cx2 - cx;
163
+ const ch = cy2 - cy;
164
+ if (cw <= 0 || ch <= 0) {
165
+ throw new Error('crop rect is outside the screenshot canvas');
166
+ }
167
+ const filtered = zlib_1.default.inflateSync(Buffer.concat(idatParts));
168
+ const raw = unfilter(filtered, ihdr.width, ihdr.height, bpp);
169
+ const srcStride = ihdr.width * bpp;
170
+ const dstStride = cw * bpp;
171
+ // New filtered scanlines with filter byte 0 (None) prefix.
172
+ const filteredOut = Buffer.alloc(ch * (dstStride + 1));
173
+ for (let y = 0; y < ch; y++) {
174
+ filteredOut[y * (dstStride + 1)] = 0;
175
+ raw.copy(filteredOut, y * (dstStride + 1) + 1, (cy + y) * srcStride + cx * bpp, (cy + y) * srcStride + cx * bpp + dstStride);
176
+ }
177
+ const idat = zlib_1.default.deflateSync(filteredOut);
178
+ const chunks = [SIGNATURE];
179
+ const ihdrData = Buffer.alloc(13);
180
+ ihdrData.writeUInt32BE(cw, 0);
181
+ ihdrData.writeUInt32BE(ch, 4);
182
+ ihdrData.writeUInt8(ihdr.bitDepth, 8);
183
+ ihdrData.writeUInt8(ihdr.colorType, 9);
184
+ ihdrData.writeUInt8(0, 10); // compression
185
+ ihdrData.writeUInt8(0, 11); // filter method
186
+ ihdrData.writeUInt8(0, 12); // interlace
187
+ writeChunk(chunks, 'IHDR', ihdrData);
188
+ writeChunk(chunks, 'IDAT', idat);
189
+ writeChunk(chunks, 'IEND', Buffer.alloc(0));
190
+ return Buffer.concat(chunks);
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {