@houwert/conductor 0.32.1 → 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.
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.getFold = getFold;
5
+ exports.setFold = setFold;
6
+ exports.HELP = ` get-fold Print the hinge angle of a foldable device (iPhone Duo)
7
+ set-fold <closed|book|open|0-180> Fold, half-open, unfold, or set an exact hinge angle`;
8
+ const runner_js_1 = require("../runner.js");
9
+ const session_js_1 = require("../session.js");
10
+ const output_js_1 = require("../output.js");
11
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
12
+ const ios_fold_js_1 = require("../drivers/ios-fold.js");
13
+ async function resolveDeviceId(sessionName) {
14
+ if (sessionName !== 'default')
15
+ return sessionName;
16
+ const session = await (0, session_js_1.getSession)(sessionName);
17
+ return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
18
+ }
19
+ /** Fold control talks to the device directly, so no test driver is needed. */
20
+ async function foldableDeviceId(sessionName, opts) {
21
+ const deviceId = await resolveDeviceId(sessionName);
22
+ if (!deviceId) {
23
+ (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
24
+ return null;
25
+ }
26
+ const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
27
+ if (platform !== 'ios') {
28
+ (0, output_js_1.printError)(`fold control is iOS-only — foldable simulators such as the iPhone Duo`, opts);
29
+ return null;
30
+ }
31
+ return deviceId;
32
+ }
33
+ async function getFold(opts = {}, sessionName = 'default') {
34
+ const deviceId = await foldableDeviceId(sessionName, opts);
35
+ if (!deviceId)
36
+ return 1;
37
+ try {
38
+ const state = await (0, ios_fold_js_1.getFoldState)(deviceId);
39
+ if (opts.json)
40
+ (0, output_js_1.printData)(state, opts);
41
+ else
42
+ (0, output_js_1.printSuccess)(`fold: ${state.pose} (${state.angle}°)`, opts);
43
+ return 0;
44
+ }
45
+ catch (err) {
46
+ (0, output_js_1.printError)(`get-fold — failed\n${err instanceof Error ? err.message : String(err)}`, opts);
47
+ return 1;
48
+ }
49
+ }
50
+ async function setFold(value, opts = {}, sessionName = 'default') {
51
+ const key = value.trim().toLowerCase();
52
+ const angle = (0, ios_fold_js_1.resolveFoldAngle)(value);
53
+ if (angle === null) {
54
+ (0, output_js_1.printError)(`set-fold must be one of: ${Object.keys(ios_fold_js_1.FOLD_POSES).join(', ')}, or an angle from 0 to 180`, opts);
55
+ return 1;
56
+ }
57
+ const deviceId = await foldableDeviceId(sessionName, opts);
58
+ if (!deviceId)
59
+ return 1;
60
+ try {
61
+ await (0, ios_fold_js_1.setFoldAngle)(deviceId, angle);
62
+ (0, output_js_1.printSuccess)(`set-fold ${key in ios_fold_js_1.FOLD_POSES ? key : angle} (${angle}°) — done`, opts);
63
+ return 0;
64
+ }
65
+ catch (err) {
66
+ (0, output_js_1.printError)(`set-fold ${value} — failed\n${err instanceof Error ? err.message : String(err)}`, opts);
67
+ return 1;
68
+ }
69
+ }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.getOrientation = getOrientation;
5
+ exports.HELP = ` get-orientation Print the device's current orientation`;
6
+ const runner_js_1 = require("../runner.js");
7
+ const session_js_1 = require("../session.js");
8
+ const output_js_1 = require("../output.js");
9
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
10
+ const devicectl_js_1 = require("../drivers/devicectl.js");
11
+ async function resolveDeviceId(sessionName) {
12
+ if (sessionName !== 'default')
13
+ return sessionName;
14
+ const session = await (0, session_js_1.getSession)(sessionName);
15
+ return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
16
+ }
17
+ async function getOrientation(opts = {}, sessionName = 'default') {
18
+ const deviceId = await resolveDeviceId(sessionName);
19
+ if (!deviceId) {
20
+ (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
21
+ return 1;
22
+ }
23
+ const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
24
+ if (platform !== 'ios') {
25
+ (0, output_js_1.printError)('get-orientation is iOS-only (simulators and physical devices)', opts);
26
+ return 1;
27
+ }
28
+ try {
29
+ const orientation = await (0, devicectl_js_1.getOrientation)(deviceId);
30
+ if (opts.json)
31
+ (0, output_js_1.printData)({ orientation }, opts);
32
+ else
33
+ (0, output_js_1.printSuccess)(`orientation: ${orientation}`, opts);
34
+ return 0;
35
+ }
36
+ catch (err) {
37
+ (0, output_js_1.printError)(`get-orientation — failed\n${err instanceof Error ? err.message : String(err)}`, opts);
38
+ return 1;
39
+ }
40
+ }
@@ -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;
@@ -2,24 +2,73 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.setOrientation = setOrientation;
5
- exports.HELP = ` set-orientation <portrait|landscape> Set device orientation`;
5
+ exports.HELP = ` set-orientation <orientation> Set device orientation
6
+ portrait | landscape (all platforms)
7
+ portraitUpsideDown | landscapeLeft | landscapeRight |
8
+ faceUp | faceDown (iOS only)`;
6
9
  const runner_js_1 = require("../runner.js");
10
+ const session_js_1 = require("../session.js");
7
11
  const output_js_1 = require("../output.js");
8
- const VALID = ['portrait', 'landscape'];
12
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
13
+ const devicectl_js_1 = require("../drivers/devicectl.js");
14
+ const ios_fold_js_1 = require("../drivers/ios-fold.js");
15
+ /** Accepted everywhere; other platforms handle these through their driver. */
16
+ const BASIC = {
17
+ portrait: 'portrait',
18
+ landscape: 'landscapeLeft',
19
+ };
20
+ /** iOS-only orientations, which the test driver has no concept of. */
21
+ const IOS_ONLY = {
22
+ portraitupsidedown: 'portraitUpsideDown',
23
+ landscapeleft: 'landscapeLeft',
24
+ landscaperight: 'landscapeRight',
25
+ faceup: 'faceUp',
26
+ facedown: 'faceDown',
27
+ };
9
28
  async function setOrientation(orientation, opts = {}, sessionName = 'default') {
10
- if (!VALID.includes(orientation.toLowerCase())) {
11
- (0, output_js_1.printError)(`set-orientation must be one of: ${VALID.join(', ')}`, opts);
29
+ const key = orientation.toLowerCase();
30
+ const iosValue = BASIC[key] ?? IOS_ONLY[key];
31
+ if (!iosValue) {
32
+ (0, output_js_1.printError)(`set-orientation must be one of: ${[...Object.keys(BASIC), ...Object.values(IOS_ONLY)].join(', ')}`, opts);
33
+ return 1;
34
+ }
35
+ const deviceId = sessionName !== 'default'
36
+ ? sessionName
37
+ : ((await (0, session_js_1.getSession)(sessionName)).deviceId ?? (await (0, runner_js_1.detectFirstDevice)()));
38
+ if (!deviceId) {
39
+ (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
40
+ return 1;
41
+ }
42
+ const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
43
+ // On iOS everything goes through devicectl: it covers the full set and needs
44
+ // no test driver running. Other platforms keep using their driver.
45
+ if (platform === 'ios') {
46
+ try {
47
+ await (0, devicectl_js_1.setOrientation)(deviceId, iosValue);
48
+ // Foldables accept the call and ignore it — their pose machine owns
49
+ // orientation — so confirm, and rotate through the relay if it didn't take.
50
+ if ((await (0, devicectl_js_1.getOrientation)(deviceId).catch(() => null)) !== iosValue) {
51
+ await (0, ios_fold_js_1.setOrientationViaRelay)(deviceId, iosValue);
52
+ }
53
+ (0, output_js_1.printSuccess)(`set-orientation ${iosValue} — done`, opts);
54
+ return 0;
55
+ }
56
+ catch (err) {
57
+ (0, output_js_1.printError)(`set-orientation ${orientation} — failed\n${err instanceof Error ? err.message : String(err)}`, opts);
58
+ return 1;
59
+ }
60
+ }
61
+ if (!BASIC[key]) {
62
+ (0, output_js_1.printError)(`${iosValue} is iOS-only — use portrait or landscape on this platform`, opts);
12
63
  return 1;
13
64
  }
14
65
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
15
- await driver.setOrientation(orientation.toLowerCase());
66
+ await driver.setOrientation(key);
16
67
  }, sessionName);
17
68
  if (result.success) {
18
- (0, output_js_1.printSuccess)(`set-orientation ${orientation} — done`, opts);
69
+ (0, output_js_1.printSuccess)(`set-orientation ${key} — done`, opts);
19
70
  return 0;
20
71
  }
21
- else {
22
- (0, output_js_1.printError)(`set-orientation ${orientation} — failed\n${result.stderr}`, opts);
23
- return 1;
24
- }
72
+ (0, output_js_1.printError)(`set-orientation ${orientation} — failed\n${result.stderr}`, opts);
73
+ return 1;
25
74
  }
@@ -11,6 +11,7 @@ exports.getInputPort = getInputPort;
11
11
  exports.getStreamPort = getStreamPort;
12
12
  exports.getInprocDylibPath = getInprocDylibPath;
13
13
  exports.getHidBinaryPath = getHidBinaryPath;
14
+ exports.getFoldDylibPath = getFoldDylibPath;
14
15
  exports.getCaptureBinaryPath = getCaptureBinaryPath;
15
16
  exports.installDriver = installDriver;
16
17
  exports.isSimulatorBooted = isSimulatorBooted;
@@ -363,6 +364,18 @@ async function getHidBinaryPath() {
363
364
  const p = path_1.default.join(dir, 'ios-hid', 'conductor-hid');
364
365
  return fs_1.default.existsSync(p) ? p : null;
365
366
  }
367
+ /**
368
+ * Absolute path to the fold controller dylib (`ios-fold/conductor-fold.dylib`),
369
+ * built by `packages/ios-fold/tools/build-fold.sh`. Injected into a foldable
370
+ * simulator's locationd to drive the hinge. Returns null if it hasn't been built.
371
+ */
372
+ async function getFoldDylibPath() {
373
+ const dir = await getDriversDir().catch(() => null);
374
+ if (!dir)
375
+ return null;
376
+ const p = path_1.default.join(dir, 'ios-fold', 'conductor-fold.dylib');
377
+ return fs_1.default.existsSync(p) ? p : null;
378
+ }
366
379
  /**
367
380
  * Absolute path to the host-side Simulator video capture binary
368
381
  * (`ios-capture/conductor-capture`), built by
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEVICECTL_ORIENTATIONS = void 0;
6
7
  exports.parseDevicectlDevices = parseDevicectlDevices;
7
8
  exports.listPhysicalDevices = listPhysicalDevices;
8
9
  exports.findPhysicalDevice = findPhysicalDevice;
@@ -14,6 +15,9 @@ exports.uninstallApp = uninstallApp;
14
15
  exports.launchApp = launchApp;
15
16
  exports.terminateApp = terminateApp;
16
17
  exports.listApps = listApps;
18
+ exports.getOrientation = getOrientation;
19
+ exports.setOrientation = setOrientation;
20
+ exports.listDisplays = listDisplays;
17
21
  /**
18
22
  * `xcrun devicectl` wrapper — the physical-device counterpart to `simctl`.
19
23
  *
@@ -241,3 +245,51 @@ async function listApps(deviceId) {
241
245
  name: a.name ?? a.bundleIdentifier,
242
246
  }));
243
247
  }
248
+ /** Orientations devicectl accepts; a superset of the driver's portrait/landscape. */
249
+ exports.DEVICECTL_ORIENTATIONS = [
250
+ 'portrait',
251
+ 'portraitUpsideDown',
252
+ 'landscapeLeft',
253
+ 'landscapeRight',
254
+ 'faceUp',
255
+ 'faceDown',
256
+ ];
257
+ /**
258
+ * Read the device's current orientation. Works for simulators as well as
259
+ * physical devices — CoreDevice covers both — and needs no running test driver.
260
+ */
261
+ async function getOrientation(deviceId) {
262
+ const out = await devicectl(['device', 'orientation', 'get', '--device', deviceId]);
263
+ // Devices that track flat poses report "Non-flat Orientation" instead.
264
+ const match = out.match(/Current Device (?:Non-flat )?Orientation:\s*(\w+)/);
265
+ if (!match)
266
+ throw new Error(`could not parse orientation from devicectl: ${out.trim()}`);
267
+ return match[1];
268
+ }
269
+ /** Set the device's orientation. */
270
+ async function setOrientation(deviceId, orientation) {
271
+ await devicectl(['device', 'orientation', 'set', '--device', deviceId, orientation]);
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
+ }
@@ -0,0 +1,209 @@
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.RELAY_ORIENTATIONS = exports.FOLD_POSES = void 0;
7
+ exports.resolveFoldAngle = resolveFoldAngle;
8
+ exports.poseForAngle = poseForAngle;
9
+ exports.ensureFoldControllerInjected = ensureFoldControllerInjected;
10
+ exports.setFoldAngle = setFoldAngle;
11
+ exports.setOrientationViaRelay = setOrientationViaRelay;
12
+ exports.getFoldState = getFoldState;
13
+ /**
14
+ * Fold (hinge) control for foldable simulators, e.g. the iPhone Duo.
15
+ *
16
+ * Apple exposes no setter: `simctl` has nothing, and `devicectl` only *reads*
17
+ * the angle. The fold is driven by an AVP event that travels Device Hub ->
18
+ * CoreDevice remote HID -> the guest's dtuhidd -> locationd, whose CoreMotion
19
+ * relay synthesises the hinge HID event SpringBoard folds on. Conductor's own
20
+ * Indigo channel cannot carry it (backboardd rejects hinge events), so we
21
+ * inject `drivers/ios-fold/conductor-fold.dylib` into the simulator's locationd
22
+ * and feed that relay directly — see packages/ios-fold for the full rationale.
23
+ *
24
+ * Injection lasts until locationd restarts, so every call re-checks the pid the
25
+ * dylib recorded and re-injects when it no longer matches.
26
+ */
27
+ const fs_1 = __importDefault(require("fs"));
28
+ const child_process_1 = require("child_process");
29
+ const util_1 = require("util");
30
+ const bootstrap_js_1 = require("./bootstrap.js");
31
+ const exec = (0, util_1.promisify)(child_process_1.execFile);
32
+ /** Named poses, mirroring Device Hub's three hinge buttons. */
33
+ exports.FOLD_POSES = {
34
+ closed: 0,
35
+ book: 130,
36
+ open: 180,
37
+ };
38
+ /** A simulator process's /tmp is the host's /tmp, so scope paths by device. */
39
+ const controlPath = (udid) => `/tmp/conductor-fold-${udid}`;
40
+ const orientationPath = (udid) => `/tmp/conductor-orientation-${udid}`;
41
+ /**
42
+ * The relay's own orientation vocabulary, which is not devicectl's spelling.
43
+ * Foldables ignore `devicectl device orientation set`, so they need this path.
44
+ */
45
+ exports.RELAY_ORIENTATIONS = {
46
+ portrait: 'portrait',
47
+ portraitUpsideDown: 'pud',
48
+ landscapeLeft: 'landscape-left',
49
+ landscapeRight: 'landscape-right',
50
+ faceUp: 'faceup',
51
+ faceDown: 'facedown',
52
+ };
53
+ /**
54
+ * Resolve a CLI value — a pose name or an angle in degrees — to an angle.
55
+ * Returns null for anything else, including an empty value: `Number('')` is 0,
56
+ * which would otherwise turn a bare `set-fold` into "fold the device shut".
57
+ */
58
+ function resolveFoldAngle(value) {
59
+ const key = value.trim().toLowerCase();
60
+ if (!key)
61
+ return null;
62
+ if (key in exports.FOLD_POSES)
63
+ return exports.FOLD_POSES[key];
64
+ const angle = Number(key);
65
+ if (!Number.isFinite(angle) || angle < 0 || angle > 180)
66
+ return null;
67
+ return angle;
68
+ }
69
+ /**
70
+ * Name the pose when the angle is close to one, else call it partial. Which
71
+ * panel is lit is deliberately not derived from the angle: the system swaps
72
+ * displays on its own transition logic, so a hinge parked mid-way can still be
73
+ * driving the cover display.
74
+ */
75
+ function poseForAngle(angle) {
76
+ for (const [name, value] of Object.entries(exports.FOLD_POSES)) {
77
+ if (Math.abs(value - angle) <= 10)
78
+ return name;
79
+ }
80
+ return 'partial';
81
+ }
82
+ async function locationdPid(udid) {
83
+ try {
84
+ const { stdout } = await exec('xcrun', [
85
+ 'simctl',
86
+ 'spawn',
87
+ udid,
88
+ 'launchctl',
89
+ 'print',
90
+ 'system/com.apple.locationd',
91
+ ]);
92
+ return stdout.match(/^\s*pid = (\d+)/m)?.[1] ?? null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function recordedPid(udid) {
99
+ try {
100
+ return fs_1.default.readFileSync(`${controlPath(udid)}.pid`, 'utf8').trim() || null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Ensure the fold controller is live inside locationd. DYLD_INSERT_LIBRARIES is
108
+ * set on the simulator's launchd only for the moment it takes to restart
109
+ * locationd — anything else launched in that window loads the dylib too, which
110
+ * is harmless (it no-ops outside locationd).
111
+ */
112
+ async function ensureFoldControllerInjected(udid) {
113
+ const pid = await locationdPid(udid);
114
+ if (pid && pid === recordedPid(udid))
115
+ return;
116
+ // Restarting locationd resets CoreMotion's relay to a 0° hinge, which folds
117
+ // the device. Remember where the hinge was so it can be put back.
118
+ const previousAngle = await readHingeAngleViaDevicectl(udid).catch(() => null);
119
+ const dylib = await (0, bootstrap_js_1.getFoldDylibPath)();
120
+ if (!dylib) {
121
+ throw new Error('fold control requires drivers/ios-fold/conductor-fold.dylib — build it with packages/ios-fold/tools/build-fold.sh');
122
+ }
123
+ const spawn = (args) => exec('xcrun', ['simctl', 'spawn', udid, ...args]);
124
+ await spawn(['launchctl', 'setenv', 'DYLD_INSERT_LIBRARIES', dylib]);
125
+ try {
126
+ await spawn(['launchctl', 'kickstart', '-k', 'system/com.apple.locationd']);
127
+ }
128
+ finally {
129
+ await spawn(['launchctl', 'unsetenv', 'DYLD_INSERT_LIBRARIES']).catch(() => { });
130
+ }
131
+ const deadline = Date.now() + 15000;
132
+ while (Date.now() < deadline) {
133
+ const current = await locationdPid(udid);
134
+ if (current && current === recordedPid(udid)) {
135
+ if (previousAngle !== null) {
136
+ fs_1.default.writeFileSync(controlPath(udid), String(previousAngle));
137
+ }
138
+ return;
139
+ }
140
+ await new Promise((r) => setTimeout(r, 300));
141
+ }
142
+ throw new Error('fold controller did not come up inside locationd (is this a foldable device?)');
143
+ }
144
+ /** Apply a hinge angle in degrees (0 = closed, 180 = open flat). */
145
+ async function setFoldAngle(udid, angle) {
146
+ if (!Number.isFinite(angle) || angle < 0 || angle > 180) {
147
+ throw new Error(`fold angle must be between 0 and 180 (got ${angle})`);
148
+ }
149
+ await ensureFoldControllerInjected(udid);
150
+ fs_1.default.writeFileSync(controlPath(udid), String(angle));
151
+ }
152
+ /**
153
+ * Rotate through the injected relay, the way Device Hub's orientation picker
154
+ * does. Only needed where devicectl's setter is ignored.
155
+ */
156
+ async function setOrientationViaRelay(udid, orientation) {
157
+ const token = exports.RELAY_ORIENTATIONS[orientation];
158
+ if (!token)
159
+ throw new Error(`no relay mapping for orientation '${orientation}'`);
160
+ await ensureFoldControllerInjected(udid);
161
+ fs_1.default.writeFileSync(orientationPath(udid), token);
162
+ }
163
+ /**
164
+ * Read the current fold state. devicectl is the authority: the hinge can be
165
+ * moved from Device Hub as well as by us, so anything we cached locally would
166
+ * only cover the folds this process happened to see.
167
+ */
168
+ async function getFoldState(udid) {
169
+ const angle = await readHingeAngleViaDevicectl(udid);
170
+ return { angle, pose: poseForAngle(angle) };
171
+ }
172
+ /**
173
+ * `devicectl device motion hinge-angle` is a monitor: it prints the current
174
+ * angle immediately but then streams until its session expires, so take the
175
+ * first sample and stop it rather than waiting around.
176
+ */
177
+ async function readHingeAngleViaDevicectl(udid) {
178
+ return new Promise((resolve, reject) => {
179
+ const proc = (0, child_process_1.spawn)('xcrun', [
180
+ 'devicectl',
181
+ 'device',
182
+ 'motion',
183
+ 'hinge-angle',
184
+ '--device',
185
+ udid,
186
+ '--session-timeout',
187
+ '10',
188
+ ]);
189
+ let out = '';
190
+ let settled = false;
191
+ const finish = (fn) => {
192
+ if (settled)
193
+ return;
194
+ settled = true;
195
+ clearTimeout(timer);
196
+ proc.kill('SIGTERM');
197
+ fn();
198
+ };
199
+ const timer = setTimeout(() => finish(() => reject(new Error('timed out reading hinge angle'))), 15000);
200
+ proc.stdout.on('data', (chunk) => {
201
+ out += chunk.toString();
202
+ const match = out.match(/Angle:\s*([0-9.]+)/);
203
+ if (match)
204
+ finish(() => resolve(Number(match[1])));
205
+ });
206
+ proc.on('error', (err) => finish(() => reject(err)));
207
+ proc.on('close', () => finish(() => reject(new Error('could not read hinge angle — is this a foldable device?'))));
208
+ });
209
+ }
@@ -48,8 +48,38 @@ exports.ENUM_PARAMS = [
48
48
  command: 'set-orientation',
49
49
  param: '<orientation>',
50
50
  description: 'Device orientation',
51
- // Source: VALID in commands/set-orientation.ts
52
- values: [{ value: 'portrait' }, { value: 'landscape' }],
51
+ // Source: BASIC + IOS_ONLY in commands/set-orientation.ts
52
+ values: [
53
+ { value: 'portrait' },
54
+ { value: 'landscape' },
55
+ { value: 'portraitUpsideDown', description: 'iOS only' },
56
+ { value: 'landscapeLeft', description: 'iOS only' },
57
+ { value: 'landscapeRight', description: 'iOS only' },
58
+ { value: 'faceUp', description: 'iOS only' },
59
+ { value: 'faceDown', description: 'iOS only' },
60
+ ],
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
+ },
73
+ {
74
+ command: 'set-fold',
75
+ param: '<state>',
76
+ description: 'Hinge state of a foldable device, or an angle from 0 to 180',
77
+ // Source: FOLD_POSES in drivers/ios-fold.ts
78
+ values: [
79
+ { value: 'closed', description: '0\u00b0 — folded shut, cover display active' },
80
+ { value: 'book', description: '130\u00b0 — half open' },
81
+ { value: 'open', description: '180\u00b0 — open flat' },
82
+ ],
53
83
  },
54
84
  {
55
85
  command: 'start-device',
package/dist/index.js CHANGED
@@ -58,6 +58,8 @@ const hide_keyboard_js_1 = require("./commands/hide-keyboard.js");
58
58
  const scroll_until_visible_js_1 = require("./commands/scroll-until-visible.js");
59
59
  const set_location_js_1 = require("./commands/set-location.js");
60
60
  const set_orientation_js_1 = require("./commands/set-orientation.js");
61
+ const fold_js_1 = require("./commands/fold.js");
62
+ const get_orientation_js_1 = require("./commands/get-orientation.js");
61
63
  const set_viewport_js_1 = require("./commands/set-viewport.js");
62
64
  const start_device_js_1 = require("./commands/start-device.js");
63
65
  const stop_device_js_1 = require("./commands/stop-device.js");
@@ -140,6 +142,9 @@ const COMMAND_HELP = {
140
142
  travel: travel_js_1.HELP,
141
143
  'record-video': record_video_js_1.HELP,
142
144
  'set-orientation': set_orientation_js_1.HELP,
145
+ 'get-fold': fold_js_1.HELP,
146
+ 'set-fold': fold_js_1.HELP,
147
+ 'get-orientation': get_orientation_js_1.HELP,
143
148
  'set-viewport': set_viewport_js_1.HELP,
144
149
  'take-screenshot': screenshot_js_1.HELP,
145
150
  'capture-ui': capture_ui_js_1.HELP,
@@ -823,6 +828,19 @@ async function main() {
823
828
  });
824
829
  break;
825
830
  }
831
+ case 'get-fold': {
832
+ exitCode = await (0, fold_js_1.getFold)(opts, sessionName);
833
+ break;
834
+ }
835
+ case 'set-fold': {
836
+ const value = String(rest[0] ?? argv['fold'] ?? '');
837
+ exitCode = await (0, fold_js_1.setFold)(value, opts, sessionName);
838
+ break;
839
+ }
840
+ case 'get-orientation': {
841
+ exitCode = await (0, get_orientation_js_1.getOrientation)(opts, sessionName);
842
+ break;
843
+ }
826
844
  case 'set-orientation': {
827
845
  const orientation = (rest[0] ??
828
846
  argv['orientation'] ??
@@ -867,6 +885,7 @@ async function main() {
867
885
  above: argv['above'],
868
886
  leftOf: argv['left-of'],
869
887
  rightOf: argv['right-of'],
888
+ display: argv['display'] !== undefined ? String(argv['display']) : undefined,
870
889
  });
871
890
  break;
872
891
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.32.1",
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": {
@@ -27,10 +27,39 @@ conductor list-apps # installed app ids / package names (--json adds ap
27
27
  | `conductor stop-device [<name-or-id>] [--all]` | Shut down device(s) |
28
28
  | `conductor delete-device <name-or-id> [--all]` | Delete simulator(s)/AVD(s)/web session(s) |
29
29
  | `conductor set-location --lat <n> --lng <n>` | Set GPS coordinates |
30
- | `conductor set-orientation <portrait\|landscape>` | Set orientation |
30
+ | `conductor set-orientation <portrait\|landscape\|portraitUpsideDown\|landscapeLeft\|landscapeRight\|faceUp\|faceDown>` | Set orientation (the last five are iOS only) |
31
+ | `conductor get-orientation` | Print the current orientation (iOS only) |
32
+ | `conductor set-fold <closed\|book\|open\|0-180>` | Fold/unfold a foldable device, or set an exact hinge angle (iPhone Duo) |
33
+ | `conductor get-fold` | Print the current hinge angle and pose |
31
34
  | `conductor set-viewport [<w> <h>] [--preset mobile\|tablet\|desktop]` | Resize web viewport (web only) |
32
35
  | `conductor install-web [--check] [browser]` | Install a Playwright browser (chromium/firefox/webkit); `--check` = status |
33
36
 
37
+ ### Foldable devices (iPhone Duo)
38
+
39
+ `set-fold` drives the hinge the same way Device Hub's slider does, so the device
40
+ really folds: SpringBoard swaps between the cover and inner displays, and
41
+ `devicectl device motion hinge-angle` reports the new angle. `take-screenshot`
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
+ Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
49
+ 0/130/180. Named poses always swap the display; an arbitrary mid-way angle sets
50
+ the hinge but may leave the cover display active, because the system decides
51
+ when to swap on its own transition logic.
52
+
53
+ The first `set-fold` after a device boots takes a few seconds: it injects a
54
+ controller into the simulator's locationd (restarting it), because Apple ships
55
+ no fold setter in `simctl` or `devicectl`. The current angle is restored
56
+ afterwards, so injecting doesn't change the pose. Subsequent calls are instant. This is
57
+ iOS-simulator only.
58
+
59
+ `set-orientation` works on foldables too, but by the same route: they accept
60
+ `devicectl`'s setter and ignore it, so the command verifies the result and
61
+ rotates through the injected controller when needed.
62
+
34
63
  ### Attach to an existing browser (CDP)
35
64
 
36
65
  Instead of launching its own browser, the web driver can attach to one that's
@@ -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