@houwert/conductor 0.33.0 → 0.33.2

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,9 +4,12 @@ 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;
7
8
  exports.screenshot = screenshot;
8
- exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page]
9
+ exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page] [--display <panel>]
9
10
  Take screenshot (--full-page: web only, capture entire scrollable page)
11
+ --display <cover|inner|id> Which display to capture (default: whichever panel
12
+ is live). An unknown value lists the device's displays
10
13
  <element> Crop to the element matched by text (positional)
11
14
  --id <id> Crop to the element matched by accessibility id
12
15
  --text <text> Crop to the element matched by text only (not id)
@@ -21,9 +24,14 @@ exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page]
21
24
  --left-of <text> Match element left of the given reference
22
25
  --right-of <text> Match element right of the given reference`;
23
26
  const path_1 = __importDefault(require("path"));
27
+ const os_1 = __importDefault(require("os"));
24
28
  const promises_1 = __importDefault(require("fs/promises"));
29
+ const child_process_1 = require("child_process");
30
+ const util_1 = require("util");
25
31
  const runner_js_1 = require("../runner.js");
26
32
  const output_js_1 = require("../output.js");
33
+ const devicectl_js_1 = require("../drivers/devicectl.js");
34
+ const ios_displays_js_1 = require("../drivers/ios-displays.js");
27
35
  const ios_js_1 = require("../drivers/ios.js");
28
36
  const android_js_1 = require("../drivers/android.js");
29
37
  const web_js_1 = require("../drivers/web.js");
@@ -32,7 +40,63 @@ const roku_js_1 = require("../drivers/roku.js");
32
40
  const wait_js_1 = require("../drivers/wait.js");
33
41
  const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
34
42
  const png_crop_js_1 = require("../png-crop.js");
43
+ const exec = (0, util_1.promisify)(child_process_1.execFile);
35
44
  const DEFAULT_MARGIN_PX = 8;
45
+ /**
46
+ * Grab the screen, pointing at the right panel on a multi-display device.
47
+ *
48
+ * The driver always screenshots `XCUIScreen.main`, which on a foldable is the
49
+ * cover panel — powered off, and so a black image, whenever the device is
50
+ * unfolded. When the device reports more than one integrated panel we capture
51
+ * the live one through simctl instead. Ordinary devices keep the driver path.
52
+ */
53
+ async function captureScreen(driver, opts, displayOverride) {
54
+ if (!(driver instanceof ios_js_1.IOSDriver)) {
55
+ if (displayOverride) {
56
+ throw new Error('--display is iOS-only; other platforms expose a single screen');
57
+ }
58
+ return { buffer: await driver.screenshot(opts), redirected: false };
59
+ }
60
+ const deviceId = driver.deviceId;
61
+ if (!deviceId) {
62
+ if (displayOverride)
63
+ throw new Error('--display needs a device to query for its displays');
64
+ return { buffer: await driver.screenshot(opts), redirected: false };
65
+ }
66
+ const displays = await (0, devicectl_js_1.listDisplays)(deviceId).catch(() => []);
67
+ if (!displays.length) {
68
+ if (displayOverride)
69
+ throw new Error("could not read this device's displays");
70
+ return { buffer: await driver.screenshot(opts), redirected: false };
71
+ }
72
+ const choice = (0, ios_displays_js_1.pickCaptureDisplay)(displays, displayOverride);
73
+ if (choice.error)
74
+ throw new Error(choice.error);
75
+ const primary = displays.find((d) => d.primary);
76
+ // Nothing to redirect: the driver already captures the primary panel.
77
+ if (choice.displayId === null || (primary && choice.displayId === primary.displayId)) {
78
+ return { buffer: await driver.screenshot(opts), redirected: false };
79
+ }
80
+ const file = path_1.default.join(os_1.default.tmpdir(), `conductor-shot-${Date.now()}.png`);
81
+ try {
82
+ await exec('xcrun', [
83
+ 'simctl',
84
+ 'io',
85
+ deviceId,
86
+ 'screenshot',
87
+ '--display',
88
+ String(choice.displayId),
89
+ file,
90
+ ]);
91
+ return { buffer: await promises_1.default.readFile(file), redirected: true };
92
+ }
93
+ catch (err) {
94
+ throw new Error(`could not capture display ${choice.displayId}: ${err instanceof Error ? err.message : String(err)}`);
95
+ }
96
+ finally {
97
+ await promises_1.default.unlink(file).catch(() => { });
98
+ }
99
+ }
36
100
  async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
37
101
  const timestamp = Date.now();
38
102
  const defaultName = `screenshot-${timestamp}.png`;
@@ -63,8 +127,13 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
63
127
  : '';
64
128
  const margin = flags.margin ?? DEFAULT_MARGIN_PX;
65
129
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
66
- const buf = await driver.screenshot({ fullPage });
130
+ const { buffer: buf, redirected } = await captureScreen(driver, { fullPage }, flags.display);
67
131
  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.');
136
+ }
68
137
  if (sel) {
69
138
  let el;
70
139
  let hierarchyW;
@@ -17,6 +17,7 @@ exports.terminateApp = terminateApp;
17
17
  exports.listApps = listApps;
18
18
  exports.getOrientation = getOrientation;
19
19
  exports.setOrientation = setOrientation;
20
+ exports.listDisplays = listDisplays;
20
21
  /**
21
22
  * `xcrun devicectl` wrapper — the physical-device counterpart to `simctl`.
22
23
  *
@@ -269,3 +270,26 @@ async function getOrientation(deviceId) {
269
270
  async function setOrientation(deviceId, orientation) {
270
271
  await devicectl(['device', 'orientation', 'set', '--device', deviceId, orientation]);
271
272
  }
273
+ /**
274
+ * List the device's displays. Foldables report two integrated panels and flag
275
+ * which one is live, which is the only reliable way to know where to point a
276
+ * screenshot: the swap follows the system's own transition logic, not the
277
+ * hinge angle.
278
+ */
279
+ async function listDisplays(deviceId) {
280
+ const parsed = await devicectlJson(['device', 'info', 'displays', '--device', deviceId], 15000);
281
+ return (parsed.result?.displays ?? [])
282
+ .filter((d) => typeof d.displayId === 'number')
283
+ .map((d) => {
284
+ // `type` is a single-key object, e.g. { integrated: {} } or { carPlay: {} }.
285
+ const kind = Object.keys(d.type ?? {})[0] ?? '';
286
+ return {
287
+ displayId: d.displayId,
288
+ name: d.name ?? '',
289
+ active: d.active === true,
290
+ primary: d.primary === true,
291
+ kind,
292
+ integrated: kind === 'integrated',
293
+ };
294
+ });
295
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pickCaptureDisplay = pickCaptureDisplay;
4
+ /**
5
+ * Roles, which mean the same thing on any platform that grows a foldable.
6
+ * Everything else is addressed by display id: a device's own class and panel
7
+ * names (integrated/carPlay, LCD-1) are platform vocabulary that wouldn't carry
8
+ * over to Android, and every display it could name already has an id. Unknown
9
+ * values list the device's displays so the ids are discoverable.
10
+ */
11
+ const ROLE_ALIASES = {
12
+ cover: 'primary',
13
+ main: 'primary',
14
+ primary: 'primary',
15
+ inner: 'secondary',
16
+ secondary: 'secondary',
17
+ };
18
+ /**
19
+ * Pick the display to capture. With no override this is whichever panel is
20
+ * live, which keeps screenshots working across a fold without the caller
21
+ * having to think about it. Returns null for ordinary single-display devices
22
+ * so they keep using the driver path untouched.
23
+ */
24
+ function pickCaptureDisplay(displays, override) {
25
+ const integrated = displays.filter((d) => d.integrated);
26
+ if (override) {
27
+ const key = override.trim().toLowerCase();
28
+ const id = Number(key);
29
+ if (Number.isInteger(id) && displays.some((d) => d.displayId === id)) {
30
+ return { displayId: id };
31
+ }
32
+ const role = ROLE_ALIASES[key];
33
+ if (role) {
34
+ const match = role === 'primary' ? integrated.find((d) => d.primary) : integrated.find((d) => !d.primary);
35
+ if (match)
36
+ return { displayId: match.displayId };
37
+ }
38
+ const known = displays
39
+ .map((d) => `${d.displayId} (${[d.name, d.kind].filter(Boolean).join(', ')})`)
40
+ .join('; ');
41
+ return {
42
+ displayId: null,
43
+ error: `this device has no '${override}' display — it reports: ${known}`,
44
+ };
45
+ }
46
+ // Single-panel devices: nothing to choose, let the driver handle it.
47
+ if (integrated.length < 2)
48
+ return { displayId: null };
49
+ const live = integrated.find((d) => d.active) ?? integrated.find((d) => d.primary);
50
+ return { displayId: live ? live.displayId : null };
51
+ }
@@ -59,6 +59,17 @@ exports.ENUM_PARAMS = [
59
59
  { value: 'faceDown', description: 'iOS only' },
60
60
  ],
61
61
  },
62
+ {
63
+ command: 'take-screenshot',
64
+ param: '--display',
65
+ description: 'Which display to capture (default: whichever panel is live)',
66
+ // Source: ROLE_ALIASES in drivers/ios-displays.ts. Any display id the
67
+ // device reports is also accepted, so the list is not exhaustive.
68
+ values: [
69
+ { value: 'cover', description: 'Foldable outer panel (XCUIScreen.main)' },
70
+ { value: 'inner', description: 'Foldable inner panel, live when unfolded' },
71
+ ],
72
+ },
62
73
  {
63
74
  command: 'set-fold',
64
75
  param: '<state>',
package/dist/index.js CHANGED
@@ -885,6 +885,7 @@ async function main() {
885
885
  above: argv['above'],
886
886
  leftOf: argv['left-of'],
887
887
  rightOf: argv['right-of'],
888
+ display: argv['display'] !== undefined ? String(argv['display']) : undefined,
888
889
  });
889
890
  break;
890
891
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.33.0",
3
+ "version": "0.33.2",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,8 +39,16 @@ conductor list-apps # installed app ids / package names (--json adds ap
39
39
  `set-fold` drives the hinge the same way Device Hub's slider does, so the device
40
40
  really folds: SpringBoard swaps between the cover and inner displays, and
41
41
  `devicectl device motion hinge-angle` reports the new angle. `take-screenshot`
42
- captures whichever display is active; to grab a specific panel directly, use
43
- `xcrun simctl io <device> screenshot --display <1|3>` (1 = cover, 3 = inner).
42
+ follows the fold automatically it captures whichever panel is live, so an
43
+ unfolded device gives you the inner screen rather than the powered-off cover.
44
+ Pass `--display cover` or `--display inner` to pin it to one panel, or a display
45
+ id for anything else the device has attached (CarPlay, an external screen). An
46
+ unknown value lists that device's displays with their ids.
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.
44
52
 
45
53
  Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
46
54
  0/130/180. Named poses always swap the display; an arbitrary mid-way angle sets
@@ -17,7 +17,7 @@ here. Always observe before you act, and confirm after.
17
17
  | `conductor inspect [--dump]` | Print the UI hierarchy (`--dump` = raw driver output) |
18
18
  | `conductor inspect --at <x,y> [--tappable]` | Topmost view at a screen point |
19
19
  | `conductor focused [--poll [ms]]` | Metadata of the focused element. `--poll` watches changes — only with a bounded use, then stop it |
20
- | `conductor take-screenshot [<element>] [--output <path>] [--full-page]` | Screenshot; crop to a matched element; `--full-page` (web) |
20
+ | `conductor take-screenshot [<element>] [--output <path>] [--full-page] [--display <panel>]` | Screenshot; crop to a matched element; `--full-page` (web); `--display cover\|inner\|<id>` picks a display (default: whichever panel is live) |
21
21
 
22
22
  `capture-ui` is the workhorse: it returns the screen as structured data **and**
23
23
  gives each element a ref like `@e3` that `conductor tap-on @e3` taps by cached