appium-ios-remotexpc 5.22.1 → 5.23.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [5.23.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.22.1...v5.23.0) (2026-10-01)
2
+
3
+ ### Features
4
+
5
+ * **tunnel-creation:** add opt-in --watch-devices hot-plug handling ([#337](https://github.com/appium/appium-ios-remotexpc/issues/337)) ([eb7c885](https://github.com/appium/appium-ios-remotexpc/commit/eb7c885adaea2c7dc37f56b7a9151ac06541a298)), closes [Usbmux#_eventStreams](https://github.com/appium/Usbmux/issues/_eventStreams)
6
+
1
7
  ## [5.22.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.22.0...v5.22.1) (2026-09-25)
2
8
 
3
9
  ### Bug Fixes
package/README.md CHANGED
@@ -218,6 +218,20 @@ CLI helpers under `scripts/` are ESM (`.mjs`) and load the library via the packa
218
218
 
219
219
  - `npm run tunnel-creation` — Create USB tunnels and start the tunnel registry HTTP API (requires `sudo`)
220
220
  - `npm run tunnel-creation -- --keep-open` — Same as above with `--keep-open` (for inspecting open sockets)
221
+ - `npm run tunnel-creation -- --watch-devices` — Keep running after startup, creating tunnels for devices attached later and removing detached ones (usbmux-visible devices only). Combine with `--udid` to watch a single device — startup then waits for it instead of requiring it to already be connected. Devices attached while watching use the same `--reconnect-retries` policy as devices found at startup; `--keep-open` applies independently.
222
+
223
+ `--watch-devices` combined with the other `tunnel-creation` flags:
224
+
225
+ | `--watch-devices` | `--reconnect-retries` | `--udid` | Behavior |
226
+ | ----------------- | --------------------- | -------- | -------- |
227
+ | off | off | any | Unchanged: devices are listed once at startup. A device attached later, or one whose tunnel drops, is not picked up until the script restarts. With `--udid`, exits with an error if that device isn't connected. |
228
+ | off | on | any | Unchanged: a dropped tunnel is retried up to N times (0 = unlimited). Detaching a device does not stop the retries. |
229
+ | on | off | none | Tunnels for all devices at startup, then for every device attached later. Detached devices lose their tunnel and registry entry. A dropped tunnel is not retried; unplugging and re-plugging the device creates a new one. |
230
+ | on | on | none | As above, and dropped tunnels are retried. Retries for a device stop as soon as it detaches. An attach during a retry is ignored; the retry loop picks the device up on its next attempt. |
231
+ | on | off | set | As "on / off / none", but only that UDID is watched (matched case-insensitively). Startup waits for it to attach instead of exiting if it isn't connected. |
232
+ | on | on | set | As "on / on / none", restricted to that UDID, with the same wait-at-startup behavior. |
233
+
234
+ In every `--watch-devices` row, if usbmuxd restarts the watcher resubscribes and removes devices that were unplugged in the meantime. `--keep-open` applies independently.
221
235
  - `npm run pair-appletv` — Pair an Apple TV over WiFi for Remote XPC (requires `sudo`)
222
236
  - `npm run start-appletv-tunnel` — Start an Apple TV WiFi tunnel and tunnel registry (requires `sudo`)
223
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-remotexpc",
3
- "version": "5.22.1",
3
+ "version": "5.23.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "bugs": {
@@ -33,6 +33,9 @@ const log = logger.getLogger('TunnelCreation');
33
33
  /** @type {import('appium-ios-remotexpc').TunnelRegistryServer | null} */
34
34
  let registryServer = null;
35
35
 
36
+ /** @type {DeviceWatcher | null} */
37
+ let deviceWatcher = null;
38
+
36
39
  /** @type {Map<string, TunnelCreationSuccessResult>} */
37
40
  const establishedTunnelsByUdid = new Map();
38
41
 
@@ -78,6 +81,19 @@ function registerEstablishedTunnel(result) {
78
81
  establishedTunnelsByUdid.set(udid, result);
79
82
  }
80
83
 
84
+ /**
85
+ * Drops the bookkeeping entry of a tunnel that died, unless a newer tunnel has replaced it already.
86
+ *
87
+ * @param {string} udid
88
+ * @param {string} deadAddress
89
+ * @returns {void}
90
+ */
91
+ function forgetEstablishedTunnel(udid, deadAddress) {
92
+ if (establishedTunnelsByUdid.get(udid)?.tunnel.Address === deadAddress) {
93
+ establishedTunnelsByUdid.delete(udid);
94
+ }
95
+ }
96
+
81
97
  /**
82
98
  *
83
99
  * @param {string} connectionType
@@ -93,6 +109,18 @@ function connectionTypeRank(connectionType) {
93
109
  return 2;
94
110
  }
95
111
 
112
+ /**
113
+ * UDID comparison used elsewhere in the library (`Usbmux.findDevice`, lockdown) is case-insensitive,
114
+ * so a user-supplied `--udid` must match the same way.
115
+ *
116
+ * @param {string} a
117
+ * @param {string} b
118
+ * @returns {boolean}
119
+ */
120
+ function isSameUdid(a, b) {
121
+ return a.toLowerCase() === b.toLowerCase();
122
+ }
123
+
96
124
  /**
97
125
  * usbmux may list the same UDID twice (USB + Network). Prefer USB for tunneling.
98
126
  *
@@ -186,6 +214,8 @@ function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}
186
214
  },
187
215
  onTunnelDead: async ({udid: droppedUdid, address}) => {
188
216
  await TunnelManager.closeTunnelByAddress(address).catch(() => {});
217
+ // A dead tunnel must not keep blocking later attaches of this UDID, whether or not a reconnect follows
218
+ forgetEstablishedTunnel(droppedUdid, address);
189
219
  if (callbacks.onTunnelDead) {
190
220
  await callbacks.onTunnelDead({udid: droppedUdid, address});
191
221
  }
@@ -204,12 +234,18 @@ function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}
204
234
  * @param {string} opts.udid
205
235
  * @param {number} opts.maxRetries
206
236
  * @param {import('appium-ios-remotexpc').UsbmuxDevice} opts.device
237
+ * @param {Set<string>} [opts.watchedUdids] - UDIDs currently attached; only defined with `--watch-devices`.
238
+ * Retrying stops once the UDID is no longer in the set. Without it, retries are never cut short by detach.
207
239
  * @param {function(string): Promise<void>} opts.reconnectTunnelByUdid
208
240
  * @returns {Promise<void>}
209
241
  */
210
- async function runReconnectAttempts({udid, maxRetries, device, reconnectTunnelByUdid}) {
242
+ async function runReconnectAttempts({udid, maxRetries, device, watchedUdids, reconnectTunnelByUdid}) {
211
243
  let attempt = 0;
212
244
  while (maxRetries === 0 || attempt < maxRetries) {
245
+ if (watchedUdids && !watchedUdids.has(udid)) {
246
+ log.info(`Stopped reconnecting ${udid}: device detached`);
247
+ return;
248
+ }
213
249
  attempt += 1;
214
250
  log.warn(
215
251
  `Reconnecting dropped tunnel for ${udid} (attempt ${attempt}${maxRetries === 0 ? ', unlimited mode' : `/${maxRetries}`})...`,
@@ -241,9 +277,10 @@ async function runReconnectAttempts({udid, maxRetries, device, reconnectTunnelBy
241
277
  * @param {object} opts
242
278
  * @param {number} opts.reconnectRetries
243
279
  * @param {Map<string, import('appium-ios-remotexpc').UsbmuxDevice>} opts.devicesByUdid
280
+ * @param {Set<string>} [opts.watchedUdids] - See {@link runReconnectAttempts}
244
281
  * @returns {function(string): Promise<void>}
245
282
  */
246
- function createReconnectTunnelByUdid({reconnectRetries, devicesByUdid}) {
283
+ function createReconnectTunnelByUdid({reconnectRetries, devicesByUdid, watchedUdids}) {
247
284
  return async function reconnectTunnelByUdid(udid) {
248
285
  if (typeof reconnectRetries !== 'number') {
249
286
  return;
@@ -262,6 +299,7 @@ function createReconnectTunnelByUdid({reconnectRetries, devicesByUdid}) {
262
299
  udid,
263
300
  maxRetries: reconnectRetries,
264
301
  device,
302
+ watchedUdids,
265
303
  reconnectTunnelByUdid,
266
304
  });
267
305
 
@@ -285,6 +323,8 @@ function setupCleanupHandlers() {
285
323
  const cleanup = async (signal) => {
286
324
  log.warn(`\nCleaning up (${signal})...`);
287
325
 
326
+ deviceWatcher?.stop();
327
+
288
328
  while (registryWatcherStops.length > 0) {
289
329
  const stop = registryWatcherStops.pop();
290
330
  try {
@@ -409,6 +449,225 @@ async function createTunnelForDevice(device) {
409
449
  }
410
450
  }
411
451
 
452
+ /**
453
+ * Tears down the tunnel and registry entry of a device that is no longer attached.
454
+ *
455
+ * @param {string} udid
456
+ * @returns {Promise<void>}
457
+ */
458
+ async function removeDetachedDevice(udid) {
459
+ stopLifecycleWatch(udid);
460
+ registryServer?.removeTunnelEntry(udid);
461
+ const established = establishedTunnelsByUdid.get(udid);
462
+ establishedTunnelsByUdid.delete(udid);
463
+ if (established?.tunnelConnection) {
464
+ await closeTunnelQuietly(established.tunnelConnection);
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Follows usbmuxd attach/detach notifications after the initial batch (`--watch-devices`), so
470
+ * devices plugged in or re-trusted later get a tunnel and registry entry without a restart. Only
471
+ * covers devices visible to usbmuxd. Resubscribes if the usbmuxd connection drops (e.g. usbmuxd
472
+ * restarts) and stops once `stop()` is called.
473
+ *
474
+ * Shares state with the reconnect path: an attach is ignored while a tunnel exists or a reconnect
475
+ * is in flight for that UDID, and a detach removes the UDID from `watchedUdids`, which stops any
476
+ * pending reconnect attempts for it.
477
+ */
478
+ class DeviceWatcher {
479
+ /**
480
+ * @param {object} opts
481
+ * @param {Map<string, import('appium-ios-remotexpc').UsbmuxDevice>} opts.devicesByUdid - Preferred usbmux entry per tracked UDID, used as device context by reconnects
482
+ * @param {Set<string>} opts.watchedUdids - UDIDs currently attached; shared with the reconnect path
483
+ * @param {string | undefined} opts.specificUdid - When set, events for other UDIDs are ignored
484
+ * @param {function(string): Promise<void>} opts.reconnectTunnelByUdid
485
+ */
486
+ constructor({devicesByUdid, watchedUdids, specificUdid, reconnectTunnelByUdid}) {
487
+ this.devicesByUdid = devicesByUdid;
488
+ this.watchedUdids = watchedUdids;
489
+ this.specificUdid = specificUdid;
490
+ this.reconnectTunnelByUdid = reconnectTunnelByUdid;
491
+ /** @type {Map<number, import('appium-ios-remotexpc').UsbmuxDevice>} */
492
+ this.attachedByDeviceId = new Map();
493
+ this.abortController = new AbortController();
494
+ }
495
+
496
+ /**
497
+ * @param {string} udid
498
+ * @returns {import('appium-ios-remotexpc').UsbmuxDevice[]}
499
+ */
500
+ attachedEntriesFor(udid) {
501
+ return [...this.attachedByDeviceId.values()].filter((device) => device.Properties.SerialNumber === udid);
502
+ }
503
+
504
+ /**
505
+ * @param {string} udid
506
+ * @returns {void}
507
+ */
508
+ forget(udid) {
509
+ this.devicesByUdid.delete(udid);
510
+ this.watchedUdids.delete(udid);
511
+ }
512
+
513
+ /** @returns {void} */
514
+ stop() {
515
+ this.abortController.abort();
516
+ }
517
+
518
+ /**
519
+ * @param {import('appium-ios-remotexpc').UsbmuxDeviceEvent} event
520
+ * @returns {Promise<void>}
521
+ */
522
+ async handleEvent(event) {
523
+ switch (event.type) {
524
+ case 'attach':
525
+ await this.handleAttach(event.device);
526
+ break;
527
+ case 'detach':
528
+ await this.handleDetach(event.deviceId);
529
+ break;
530
+ default:
531
+ log.debug(`Ignoring unknown usbmux event: ${JSON.stringify(event)}`);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * @param {import('appium-ios-remotexpc').UsbmuxDevice} device
537
+ * @returns {Promise<void>}
538
+ */
539
+ async handleAttach(device) {
540
+ const udid = device.Properties.SerialNumber;
541
+ if (this.specificUdid && !isSameUdid(udid, this.specificUdid)) {
542
+ return;
543
+ }
544
+ this.attachedByDeviceId.set(device.DeviceID, device);
545
+ const [preferred] = dedupeDevicesByUdid(this.attachedEntriesFor(udid));
546
+ this.devicesByUdid.set(udid, preferred);
547
+ this.watchedUdids.add(udid);
548
+
549
+ if (establishedTunnelsByUdid.has(udid) || reconnectingByUdid.has(udid)) {
550
+ log.debug(`Ignoring attach of ${udid} (${device.Properties.ConnectionType}): tunnel already managed`);
551
+ return;
552
+ }
553
+
554
+ log.info(`\n🔌 Device attached: ${udid}`);
555
+ const result = await createTunnelForDevice(preferred);
556
+ if (!result.success) {
557
+ return;
558
+ }
559
+ attachTunnelRegistryLifecycleWatch(registryServer.getRegistry(), [result], {
560
+ onTunnelDead: async ({udid: droppedUdid}) => {
561
+ await this.reconnectTunnelByUdid(droppedUdid);
562
+ },
563
+ });
564
+ let published = false;
565
+ try {
566
+ published = await publishDiscoveredTunnelEntry(result);
567
+ } catch (err) {
568
+ log.warn(`Failed to publish tunnel for ${udid}: ${err}`);
569
+ }
570
+ if (!published) {
571
+ // Otherwise the live but unpublished tunnel would make every later attach of this UDID a no-op
572
+ await removeDetachedDevice(udid);
573
+ }
574
+ }
575
+
576
+ /**
577
+ * @param {number} deviceId
578
+ * @returns {Promise<void>}
579
+ */
580
+ async handleDetach(deviceId) {
581
+ const device = this.attachedByDeviceId.get(deviceId);
582
+ if (!device) {
583
+ return;
584
+ }
585
+ this.attachedByDeviceId.delete(deviceId);
586
+ const udid = device.Properties.SerialNumber;
587
+
588
+ const [remaining] = dedupeDevicesByUdid(this.attachedEntriesFor(udid));
589
+ if (remaining) {
590
+ // e.g. USB unplugged while still reachable over Network; the lifecycle watch handles the drop
591
+ log.info(
592
+ `Device ${udid} lost its ${device.Properties.ConnectionType} connection, still attached via ${remaining.Properties.ConnectionType}`,
593
+ );
594
+ this.devicesByUdid.set(udid, remaining);
595
+ return;
596
+ }
597
+
598
+ log.info(`\n🔌 Device detached: ${udid}`);
599
+ this.forget(udid);
600
+ await removeDetachedDevice(udid);
601
+ }
602
+
603
+ /**
604
+ * Devices unplugged while no subscription was active (during startup, or while usbmuxd was down)
605
+ * are never reported as detached.
606
+ *
607
+ * @returns {Promise<void>}
608
+ */
609
+ async detachDevicesMissingFromUsbmux() {
610
+ const lister = await createUsbmux();
611
+ let listed;
612
+ try {
613
+ listed = new Set((await lister.listDevices()).map((device) => device.Properties.SerialNumber));
614
+ } finally {
615
+ await lister.close().catch((err) => log.warn(`Failed to close usbmux listing connection: ${err}`));
616
+ }
617
+ for (const udid of [...this.watchedUdids]) {
618
+ if (!listed.has(udid)) {
619
+ log.info(`\n🔌 Device detached while usbmuxd was unavailable: ${udid}`);
620
+ this.forget(udid);
621
+ await removeDetachedDevice(udid);
622
+ }
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Runs the subscribe/handle/resubscribe loop until `stop()` is called.
628
+ *
629
+ * @returns {Promise<void>}
630
+ */
631
+ async run() {
632
+ const {signal} = this.abortController;
633
+ let backoffMs = 1000;
634
+
635
+ while (!signal.aborted) {
636
+ /** @type {import('appium-ios-remotexpc').Usbmux | null} */
637
+ let usbmux = null;
638
+ try {
639
+ usbmux = await createUsbmux();
640
+ // Listen re-reports every attached device, possibly under new DeviceIDs (e.g. replugged while
641
+ // startup was still creating tunnels, or usbmuxd restarted), so entries from before are stale
642
+ this.attachedByDeviceId.clear();
643
+ const events = usbmux.listen({signal});
644
+ log.info('Watching usbmuxd for device attach/detach events...');
645
+ await this.detachDevicesMissingFromUsbmux();
646
+ for await (const event of events) {
647
+ backoffMs = 1000;
648
+ try {
649
+ await this.handleEvent(event);
650
+ } catch (err) {
651
+ // One failed event must not end the subscription
652
+ log.warn(`Failed to handle usbmux ${event.type} event: ${err}`);
653
+ }
654
+ }
655
+ } catch (err) {
656
+ if (!signal.aborted) {
657
+ log.warn(`Device watch interrupted (${err}); resubscribing in ${backoffMs}ms`);
658
+ }
659
+ } finally {
660
+ await usbmux?.close().catch((err) => log.warn(`Failed to close usbmux watch connection: ${err}`));
661
+ }
662
+ if (signal.aborted) {
663
+ break;
664
+ }
665
+ await sleep(backoffMs);
666
+ backoffMs = Math.min(backoffMs * 2, 30000);
667
+ }
668
+ }
669
+ }
670
+
412
671
  /**
413
672
  * @returns {Promise<void>}
414
673
  */
@@ -429,6 +688,12 @@ async function main() {
429
688
  )
430
689
  .option('--reconnect-retries <count>', 'Reconnect retries after unexpected tunnel drop (0 = unlimited)', (value) =>
431
690
  parseNonNegativeIntegerOption(value, 'retry count'),
691
+ )
692
+ .option(
693
+ '--watch-devices',
694
+ 'Keep watching usbmuxd after startup: create tunnels for newly attached devices and remove detached ones (usbmux-visible devices only). ' +
695
+ 'With --udid, only that UDID is watched, and startup no longer requires it to already be connected. ' +
696
+ 'Tunnels created after startup use the same --reconnect-retries policy as the initial ones. Independent of --keep-open.',
432
697
  );
433
698
 
434
699
  program.parse(process.argv);
@@ -458,7 +723,7 @@ async function main() {
458
723
 
459
724
  await usbmux.close();
460
725
 
461
- if (devices.length === 0) {
726
+ if (devices.length === 0 && !options.watchDevices) {
462
727
  log.warn('No devices found. Make sure iOS devices are connected and trusted.');
463
728
  process.exit(0);
464
729
  }
@@ -473,9 +738,11 @@ async function main() {
473
738
 
474
739
  let devicesToProcess = devices;
475
740
  if (specificUdid) {
476
- devicesToProcess = devices.filter((device) => device.Properties.SerialNumber === specificUdid);
741
+ devicesToProcess = devices.filter((device) => isSameUdid(device.Properties.SerialNumber, specificUdid));
477
742
 
478
- if (devicesToProcess.length === 0) {
743
+ if (devicesToProcess.length === 0 && options.watchDevices) {
744
+ log.warn(`Device with UDID ${specificUdid} is not connected yet; waiting for it to attach.`);
745
+ } else if (devicesToProcess.length === 0) {
479
746
  log.error(`Device with UDID ${specificUdid} not found in connected devices.`);
480
747
  log.error('Available devices:');
481
748
  devices.forEach((device) => {
@@ -505,9 +772,11 @@ async function main() {
505
772
 
506
773
  const reconnectRetries = options.reconnectRetries;
507
774
  const devicesByUdid = new Map(devicesToProcess.map((device) => [device.Properties.SerialNumber, device]));
775
+ const watchedUdids = options.watchDevices ? new Set(devicesByUdid.keys()) : undefined;
508
776
  const reconnectTunnelByUdid = createReconnectTunnelByUdid({
509
777
  reconnectRetries,
510
778
  devicesByUdid,
779
+ watchedUdids,
511
780
  });
512
781
 
513
782
  const results = [];
@@ -562,6 +831,16 @@ async function main() {
562
831
  const firstUdid = successful[0].device.Properties.SerialNumber;
563
832
  log.info(` curl "http://localhost:${registryPort}/remotexpc/tunnels/${firstUdid}?waitMs=15000"`);
564
833
  }
834
+
835
+ if (options.watchDevices) {
836
+ deviceWatcher = new DeviceWatcher({
837
+ devicesByUdid,
838
+ watchedUdids,
839
+ specificUdid,
840
+ reconnectTunnelByUdid,
841
+ });
842
+ await deviceWatcher.run();
843
+ }
565
844
  } catch (error) {
566
845
  log.error(`Error during tunnel creation test: ${error}`);
567
846
  throw error;