@houwert/conductor 0.33.2 → 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.
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
7
  exports.captureScreen = captureScreen;
8
+ exports.iosHierarchySize = iosHierarchySize;
9
+ exports.cropRect = cropRect;
8
10
  exports.screenshot = screenshot;
9
11
  exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page] [--display <panel>]
10
12
  Take screenshot (--full-page: web only, capture entire scrollable page)
@@ -75,7 +77,8 @@ async function captureScreen(driver, opts, displayOverride) {
75
77
  const primary = displays.find((d) => d.primary);
76
78
  // Nothing to redirect: the driver already captures the primary panel.
77
79
  if (choice.displayId === null || (primary && choice.displayId === primary.displayId)) {
78
- return { buffer: await driver.screenshot(opts), redirected: false };
80
+ const live = choice.displayId === null || primary?.active !== false;
81
+ return { buffer: await driver.screenshot(opts), redirected: false, live };
79
82
  }
80
83
  const file = path_1.default.join(os_1.default.tmpdir(), `conductor-shot-${Date.now()}.png`);
81
84
  try {
@@ -88,7 +91,13 @@ async function captureScreen(driver, opts, displayOverride) {
88
91
  String(choice.displayId),
89
92
  file,
90
93
  ]);
91
- return { buffer: await promises_1.default.readFile(file), redirected: true };
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
+ };
92
101
  }
93
102
  catch (err) {
94
103
  throw new Error(`could not capture display ${choice.displayId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -97,6 +106,38 @@ async function captureScreen(driver, opts, displayOverride) {
97
106
  await promises_1.default.unlink(file).catch(() => { });
98
107
  }
99
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
+ }
100
141
  async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
101
142
  const timestamp = Date.now();
102
143
  const defaultName = `screenshot-${timestamp}.png`;
@@ -127,12 +168,12 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
127
168
  : '';
128
169
  const margin = flags.margin ?? DEFAULT_MARGIN_PX;
129
170
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
130
- const { buffer: buf, redirected } = await captureScreen(driver, { fullPage }, flags.display);
171
+ const { buffer: buf, redirected, scale, live, } = await captureScreen(driver, { fullPage }, flags.display);
131
172
  let out = buf;
132
- if (sel && redirected) {
133
- throw new Error('cropping to an element is not supported on this display yet the panel is ' +
134
- 'rotated relative to the accessibility coordinate space. Re-run with ' +
135
- '--display cover, or fold the device, to crop against the main panel.');
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.');
136
177
  }
137
178
  if (sel) {
138
179
  let el;
@@ -143,10 +184,12 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
143
184
  // around the foreground app + status bars and has frame=.zero, so
144
185
  // reading scale from it would always collapse to 1× and crop the
145
186
  // wrong region on retina/4K screens. Use deviceInfo, which reports
146
- // both points (AX space) and pixels (screenshot space).
147
- const info = await driver.deviceInfo();
148
- hierarchyW = info.widthPoints;
149
- 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;
150
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));
151
194
  }
152
195
  else if (driver instanceof web_js_1.WebDriver) {
@@ -170,21 +213,14 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
170
213
  throw new Error('selector cropping is not supported for this driver');
171
214
  }
172
215
  const { width: pngW, height: pngH } = (0, png_crop_js_1.readPngDimensions)(buf);
173
- const scaleX = hierarchyW > 0 ? pngW / hierarchyW : 1;
174
- const scaleY = hierarchyH > 0 ? pngH / hierarchyH : 1;
175
- // Margin is in the same logical units as the bounds (points on iOS,
176
- // pixels on Android/Web — same units the `inspect` command prints),
177
- // so scale it into screenshot pixels alongside the bounds.
178
- const marginX = margin * scaleX;
179
- const marginY = margin * scaleY;
180
- const rectX = Math.round(el.bounds.x * scaleX - marginX);
181
- const rectY = Math.round(el.bounds.y * scaleY - marginY);
182
- const rectW = Math.round(el.bounds.width * scaleX + marginX * 2);
183
- const rectH = Math.round(el.bounds.height * scaleY + marginY * 2);
184
- if (rectX + rectW <= 0 || rectY + rectH <= 0 || rectX >= pngW || rectY >= pngH) {
185
- 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})`);
186
222
  }
187
- 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);
188
224
  }
189
225
  await promises_1.default.writeFile(resolvedPath, out);
190
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.2",
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,10 +45,10 @@ 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 a screenshot to an element (`take-screenshot <element>`) only works
49
- against the main panel: the inner panel's framebuffer is rotated relative to the
50
- accessibility coordinate space, so conductor refuses the crop there instead of
51
- returning the wrong region. Fold the device or pass `--display cover` to crop.
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
52
 
53
53
  Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
54
54
  0/130/180. Named poses always swap the display; an arbitrary mid-way angle sets