@houwert/conductor 0.32.1 → 0.33.0

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
+ }
@@ -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,8 @@ 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;
17
20
  /**
18
21
  * `xcrun devicectl` wrapper — the physical-device counterpart to `simctl`.
19
22
  *
@@ -241,3 +244,28 @@ async function listApps(deviceId) {
241
244
  name: a.name ?? a.bundleIdentifier,
242
245
  }));
243
246
  }
247
+ /** Orientations devicectl accepts; a superset of the driver's portrait/landscape. */
248
+ exports.DEVICECTL_ORIENTATIONS = [
249
+ 'portrait',
250
+ 'portraitUpsideDown',
251
+ 'landscapeLeft',
252
+ 'landscapeRight',
253
+ 'faceUp',
254
+ 'faceDown',
255
+ ];
256
+ /**
257
+ * Read the device's current orientation. Works for simulators as well as
258
+ * physical devices — CoreDevice covers both — and needs no running test driver.
259
+ */
260
+ async function getOrientation(deviceId) {
261
+ const out = await devicectl(['device', 'orientation', 'get', '--device', deviceId]);
262
+ // Devices that track flat poses report "Non-flat Orientation" instead.
263
+ const match = out.match(/Current Device (?:Non-flat )?Orientation:\s*(\w+)/);
264
+ if (!match)
265
+ throw new Error(`could not parse orientation from devicectl: ${out.trim()}`);
266
+ return match[1];
267
+ }
268
+ /** Set the device's orientation. */
269
+ async function setOrientation(deviceId, orientation) {
270
+ await devicectl(['device', 'orientation', 'set', '--device', deviceId, orientation]);
271
+ }
@@ -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,27 @@ 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: 'set-fold',
64
+ param: '<state>',
65
+ description: 'Hinge state of a foldable device, or an angle from 0 to 180',
66
+ // Source: FOLD_POSES in drivers/ios-fold.ts
67
+ values: [
68
+ { value: 'closed', description: '0\u00b0 — folded shut, cover display active' },
69
+ { value: 'book', description: '130\u00b0 — half open' },
70
+ { value: 'open', description: '180\u00b0 — open flat' },
71
+ ],
53
72
  },
54
73
  {
55
74
  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'] ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,10 +27,36 @@ 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
+ 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).
44
+
45
+ Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
46
+ 0/130/180. Named poses always swap the display; an arbitrary mid-way angle sets
47
+ the hinge but may leave the cover display active, because the system decides
48
+ when to swap on its own transition logic.
49
+
50
+ The first `set-fold` after a device boots takes a few seconds: it injects a
51
+ controller into the simulator's locationd (restarting it), because Apple ships
52
+ no fold setter in `simctl` or `devicectl`. The current angle is restored
53
+ afterwards, so injecting doesn't change the pose. Subsequent calls are instant. This is
54
+ iOS-simulator only.
55
+
56
+ `set-orientation` works on foldables too, but by the same route: they accept
57
+ `devicectl`'s setter and ignore it, so the command verifies the result and
58
+ rotates through the injected controller when needed.
59
+
34
60
  ### Attach to an existing browser (CDP)
35
61
 
36
62
  Instead of launching its own browser, the web driver can attach to one that's