@houwert/conductor 0.33.1 → 0.33.3

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.
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
+ exports.captureScreen = captureScreen;
8
+ exports.iosHierarchySize = iosHierarchySize;
9
+ exports.cropRect = cropRect;
7
10
  exports.screenshot = screenshot;
8
11
  exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page] [--display <panel>]
9
12
  Take screenshot (--full-page: web only, capture entire scrollable page)
@@ -39,8 +42,8 @@ const roku_js_1 = require("../drivers/roku.js");
39
42
  const wait_js_1 = require("../drivers/wait.js");
40
43
  const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
41
44
  const png_crop_js_1 = require("../png-crop.js");
42
- const DEFAULT_MARGIN_PX = 8;
43
45
  const exec = (0, util_1.promisify)(child_process_1.execFile);
46
+ const DEFAULT_MARGIN_PX = 8;
44
47
  /**
45
48
  * Grab the screen, pointing at the right panel on a multi-display device.
46
49
  *
@@ -50,15 +53,23 @@ const exec = (0, util_1.promisify)(child_process_1.execFile);
50
53
  * the live one through simctl instead. Ordinary devices keep the driver path.
51
54
  */
52
55
  async function captureScreen(driver, opts, displayOverride) {
53
- const deviceId = driver instanceof ios_js_1.IOSDriver ? driver.deviceId : undefined;
54
- if (!deviceId || (!displayOverride && !(driver instanceof ios_js_1.IOSDriver))) {
55
- return await driver.screenshot(opts);
56
+ if (!(driver instanceof ios_js_1.IOSDriver)) {
57
+ if (displayOverride) {
58
+ throw new Error('--display is iOS-only; other platforms expose a single screen');
59
+ }
60
+ return { buffer: await driver.screenshot(opts), redirected: false };
61
+ }
62
+ const deviceId = driver.deviceId;
63
+ if (!deviceId) {
64
+ if (displayOverride)
65
+ throw new Error('--display needs a device to query for its displays');
66
+ return { buffer: await driver.screenshot(opts), redirected: false };
56
67
  }
57
68
  const displays = await (0, devicectl_js_1.listDisplays)(deviceId).catch(() => []);
58
69
  if (!displays.length) {
59
70
  if (displayOverride)
60
71
  throw new Error("could not read this device's displays");
61
- return await driver.screenshot(opts);
72
+ return { buffer: await driver.screenshot(opts), redirected: false };
62
73
  }
63
74
  const choice = (0, ios_displays_js_1.pickCaptureDisplay)(displays, displayOverride);
64
75
  if (choice.error)
@@ -66,7 +77,8 @@ async function captureScreen(driver, opts, displayOverride) {
66
77
  const primary = displays.find((d) => d.primary);
67
78
  // Nothing to redirect: the driver already captures the primary panel.
68
79
  if (choice.displayId === null || (primary && choice.displayId === primary.displayId)) {
69
- return await driver.screenshot(opts);
80
+ const live = choice.displayId === null || primary?.active !== false;
81
+ return { buffer: await driver.screenshot(opts), redirected: false, live };
70
82
  }
71
83
  const file = path_1.default.join(os_1.default.tmpdir(), `conductor-shot-${Date.now()}.png`);
72
84
  try {
@@ -79,12 +91,53 @@ async function captureScreen(driver, opts, displayOverride) {
79
91
  String(choice.displayId),
80
92
  file,
81
93
  ]);
82
- return await promises_1.default.readFile(file);
94
+ const panel = displays.find((d) => d.displayId === choice.displayId);
95
+ return {
96
+ buffer: await promises_1.default.readFile(file),
97
+ redirected: true,
98
+ scale: panel?.pointScale,
99
+ live: panel?.active,
100
+ };
101
+ }
102
+ catch (err) {
103
+ throw new Error(`could not capture display ${choice.displayId}: ${err instanceof Error ? err.message : String(err)}`);
83
104
  }
84
105
  finally {
85
106
  await promises_1.default.unlink(file).catch(() => { });
86
107
  }
87
108
  }
109
+ /**
110
+ * The size of the coordinate space the view hierarchy reports, in its own
111
+ * units, for an iOS capture.
112
+ *
113
+ * `deviceInfo()` describes `XCUIScreen.main`, which is the wrong panel for a
114
+ * redirected capture: on an unfolded foldable the hierarchy is in the inner
115
+ * panel's points while deviceInfo still reports the cover's. Fall back to the
116
+ * captured image divided by that panel's own scale, which is exact.
117
+ */
118
+ function iosHierarchySize(capture, png, deviceInfo) {
119
+ if (capture.redirected && capture.scale) {
120
+ return { width: png.width / capture.scale, height: png.height / capture.scale };
121
+ }
122
+ return { width: deviceInfo.widthPoints, height: deviceInfo.heightPoints };
123
+ }
124
+ /**
125
+ * Map element bounds onto screenshot pixels. The margin is in the same logical
126
+ * units as the bounds (points on iOS, pixels on Android/Web — what `inspect`
127
+ * prints), so it scales alongside them.
128
+ */
129
+ function cropRect(bounds, hierarchy, png, margin) {
130
+ const scaleX = hierarchy.width > 0 ? png.width / hierarchy.width : 1;
131
+ const scaleY = hierarchy.height > 0 ? png.height / hierarchy.height : 1;
132
+ const marginX = margin * scaleX;
133
+ const marginY = margin * scaleY;
134
+ return {
135
+ x: Math.round(bounds.x * scaleX - marginX),
136
+ y: Math.round(bounds.y * scaleY - marginY),
137
+ width: Math.round(bounds.width * scaleX + marginX * 2),
138
+ height: Math.round(bounds.height * scaleY + marginY * 2),
139
+ };
140
+ }
88
141
  async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
89
142
  const timestamp = Date.now();
90
143
  const defaultName = `screenshot-${timestamp}.png`;
@@ -115,8 +168,13 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
115
168
  : '';
116
169
  const margin = flags.margin ?? DEFAULT_MARGIN_PX;
117
170
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
118
- const buf = await captureScreen(driver, { fullPage }, flags.display);
171
+ const { buffer: buf, redirected, scale, live, } = await captureScreen(driver, { fullPage }, flags.display);
119
172
  let out = buf;
173
+ if (sel && live === false) {
174
+ throw new Error(`cannot crop to ${label} on this display: it is powered off, and the view ` +
175
+ 'hierarchy describes whichever panel is live. Drop --display to capture ' +
176
+ 'the live panel, or fold the device so this one takes over.');
177
+ }
120
178
  if (sel) {
121
179
  let el;
122
180
  let hierarchyW;
@@ -126,10 +184,12 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
126
184
  // around the foreground app + status bars and has frame=.zero, so
127
185
  // reading scale from it would always collapse to 1× and crop the
128
186
  // wrong region on retina/4K screens. Use deviceInfo, which reports
129
- // both points (AX space) and pixels (screenshot space).
130
- const info = await driver.deviceInfo();
131
- hierarchyW = info.widthPoints;
132
- hierarchyH = info.heightPoints;
187
+ // both points (AX space) and pixels (screenshot space) — except for a
188
+ // redirected capture, where deviceInfo describes XCUIScreen.main and
189
+ // not the panel we captured, so derive the points from its own scale.
190
+ const size = iosHierarchySize({ redirected, scale }, (0, png_crop_js_1.readPngDimensions)(buf), await driver.deviceInfo());
191
+ hierarchyW = size.width;
192
+ hierarchyH = size.height;
133
193
  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));
134
194
  }
135
195
  else if (driver instanceof web_js_1.WebDriver) {
@@ -153,21 +213,14 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
153
213
  throw new Error('selector cropping is not supported for this driver');
154
214
  }
155
215
  const { width: pngW, height: pngH } = (0, png_crop_js_1.readPngDimensions)(buf);
156
- const scaleX = hierarchyW > 0 ? pngW / hierarchyW : 1;
157
- const scaleY = hierarchyH > 0 ? pngH / hierarchyH : 1;
158
- // Margin is in the same logical units as the bounds (points on iOS,
159
- // pixels on Android/Web — same units the `inspect` command prints),
160
- // so scale it into screenshot pixels alongside the bounds.
161
- const marginX = margin * scaleX;
162
- const marginY = margin * scaleY;
163
- const rectX = Math.round(el.bounds.x * scaleX - marginX);
164
- const rectY = Math.round(el.bounds.y * scaleY - marginY);
165
- const rectW = Math.round(el.bounds.width * scaleX + marginX * 2);
166
- const rectH = Math.round(el.bounds.height * scaleY + marginY * 2);
167
- if (rectX + rectW <= 0 || rectY + rectH <= 0 || rectX >= pngW || rectY >= pngH) {
168
- throw new Error(`element ${label} bounds [${rectX},${rectY} ${rectW}x${rectH}] are outside the screenshot (${pngW}x${pngH})`);
216
+ const rect = cropRect(el.bounds, { width: hierarchyW, height: hierarchyH }, { width: pngW, height: pngH }, margin);
217
+ if (rect.x + rect.width <= 0 ||
218
+ rect.y + rect.height <= 0 ||
219
+ rect.x >= pngW ||
220
+ rect.y >= pngH) {
221
+ throw new Error(`element ${label} bounds [${rect.x},${rect.y} ${rect.width}x${rect.height}] are outside the screenshot (${pngW}x${pngH})`);
169
222
  }
170
- out = (0, png_crop_js_1.cropPng)(buf, { x: rectX, y: rectY, width: rectW, height: rectH });
223
+ out = (0, png_crop_js_1.cropPng)(buf, rect);
171
224
  }
172
225
  await promises_1.default.writeFile(resolvedPath, out);
173
226
  }, sessionName);
@@ -290,6 +290,7 @@ async function listDisplays(deviceId) {
290
290
  primary: d.primary === true,
291
291
  kind,
292
292
  integrated: kind === 'integrated',
293
+ pointScale: typeof d.pointScale === 'number' && d.pointScale > 0 ? d.pointScale : 1,
293
294
  };
294
295
  });
295
296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.33.1",
3
+ "version": "0.33.3",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,6 +45,11 @@ Pass `--display cover` or `--display inner` to pin it to one panel, or a display
45
45
  id for anything else the device has attached (CarPlay, an external screen). An
46
46
  unknown value lists that device's displays with their ids.
47
47
 
48
+ Cropping to an element (`take-screenshot <element>`) works on either panel. The
49
+ one thing it cannot do is crop against a panel that is powered off — the view
50
+ hierarchy always describes the live one — so combining a selector with a
51
+ `--display` that points at the dark panel is an error rather than a wrong crop.
52
+
48
53
  Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
49
54
  0/130/180. Named poses always swap the display; an arbitrary mid-way angle sets
50
55
  the hinge but may leave the cover display active, because the system decides