@houwert/conductor 0.33.0 → 0.33.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.
@@ -5,8 +5,10 @@ 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 [<element>] [--output <path>] [--full-page]
8
+ exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page] [--display <panel>]
9
9
  Take screenshot (--full-page: web only, capture entire scrollable page)
10
+ --display <cover|inner|id> Which display to capture (default: whichever panel
11
+ is live). An unknown value lists the device's displays
10
12
  <element> Crop to the element matched by text (positional)
11
13
  --id <id> Crop to the element matched by accessibility id
12
14
  --text <text> Crop to the element matched by text only (not id)
@@ -21,9 +23,14 @@ exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page]
21
23
  --left-of <text> Match element left of the given reference
22
24
  --right-of <text> Match element right of the given reference`;
23
25
  const path_1 = __importDefault(require("path"));
26
+ const os_1 = __importDefault(require("os"));
24
27
  const promises_1 = __importDefault(require("fs/promises"));
28
+ const child_process_1 = require("child_process");
29
+ const util_1 = require("util");
25
30
  const runner_js_1 = require("../runner.js");
26
31
  const output_js_1 = require("../output.js");
32
+ const devicectl_js_1 = require("../drivers/devicectl.js");
33
+ const ios_displays_js_1 = require("../drivers/ios-displays.js");
27
34
  const ios_js_1 = require("../drivers/ios.js");
28
35
  const android_js_1 = require("../drivers/android.js");
29
36
  const web_js_1 = require("../drivers/web.js");
@@ -33,6 +40,51 @@ const wait_js_1 = require("../drivers/wait.js");
33
40
  const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
34
41
  const png_crop_js_1 = require("../png-crop.js");
35
42
  const DEFAULT_MARGIN_PX = 8;
43
+ const exec = (0, util_1.promisify)(child_process_1.execFile);
44
+ /**
45
+ * Grab the screen, pointing at the right panel on a multi-display device.
46
+ *
47
+ * The driver always screenshots `XCUIScreen.main`, which on a foldable is the
48
+ * cover panel — powered off, and so a black image, whenever the device is
49
+ * unfolded. When the device reports more than one integrated panel we capture
50
+ * the live one through simctl instead. Ordinary devices keep the driver path.
51
+ */
52
+ 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
+ }
57
+ const displays = await (0, devicectl_js_1.listDisplays)(deviceId).catch(() => []);
58
+ if (!displays.length) {
59
+ if (displayOverride)
60
+ throw new Error("could not read this device's displays");
61
+ return await driver.screenshot(opts);
62
+ }
63
+ const choice = (0, ios_displays_js_1.pickCaptureDisplay)(displays, displayOverride);
64
+ if (choice.error)
65
+ throw new Error(choice.error);
66
+ const primary = displays.find((d) => d.primary);
67
+ // Nothing to redirect: the driver already captures the primary panel.
68
+ if (choice.displayId === null || (primary && choice.displayId === primary.displayId)) {
69
+ return await driver.screenshot(opts);
70
+ }
71
+ const file = path_1.default.join(os_1.default.tmpdir(), `conductor-shot-${Date.now()}.png`);
72
+ try {
73
+ await exec('xcrun', [
74
+ 'simctl',
75
+ 'io',
76
+ deviceId,
77
+ 'screenshot',
78
+ '--display',
79
+ String(choice.displayId),
80
+ file,
81
+ ]);
82
+ return await promises_1.default.readFile(file);
83
+ }
84
+ finally {
85
+ await promises_1.default.unlink(file).catch(() => { });
86
+ }
87
+ }
36
88
  async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
37
89
  const timestamp = Date.now();
38
90
  const defaultName = `screenshot-${timestamp}.png`;
@@ -63,7 +115,7 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
63
115
  : '';
64
116
  const margin = flags.margin ?? DEFAULT_MARGIN_PX;
65
117
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
66
- const buf = await driver.screenshot({ fullPage });
118
+ const buf = await captureScreen(driver, { fullPage }, flags.display);
67
119
  let out = buf;
68
120
  if (sel) {
69
121
  let el;
@@ -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.1",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,8 +39,11 @@ 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.
44
47
 
45
48
  Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
46
49
  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