appium-ios-remotexpc 5.22.1 → 5.24.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 +18 -0
- package/README.md +22 -28
- package/package.json +9 -23
- package/scripts/tunnel-creation.mjs +284 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
## 5.24.0 (2026-10-01)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* move appium-ios-remotexpc into the monorepo as packages/remotexpc ([#22](https://github.com/appium/appium-ios/issues/22)) ([e448236](https://github.com/appium/appium-ios/commit/e4482360b4e1712d3522b28d3c4f0b4d7b576ca7))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [5.23.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.22.1...v5.23.0) (2026-10-01)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **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)
|
|
18
|
+
|
|
1
19
|
## [5.22.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.22.0...v5.22.1) (2026-09-25)
|
|
2
20
|
|
|
3
21
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -22,19 +22,6 @@ This library provides functionality for:
|
|
|
22
22
|
npm install appium-ios-remotexpc
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
### Optional: CodeGraph for local code navigation
|
|
26
|
-
|
|
27
|
-
If you want a local semantic index for this repository, you can install [CodeGraph](https://github.com/colbymchenry/codegraph) locally and initialize it in the repo:
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
|
|
31
|
-
cd "<project folder>"
|
|
32
|
-
codegraph install
|
|
33
|
-
codegraph init
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
CodeGraph builds a local knowledge graph of symbols and call paths so AI coding agents, like Cursor or Claude, can answer questions and navigate the codebase faster with fewer file reads and reduced token consumption.
|
|
37
|
-
|
|
38
25
|
## Requirements
|
|
39
26
|
|
|
40
27
|
- Node.js 20 or later
|
|
@@ -183,30 +170,23 @@ For step-by-step pairing instructions, see the [Apple TV Pairing Instructions](d
|
|
|
183
170
|
### Setup
|
|
184
171
|
|
|
185
172
|
```bash
|
|
186
|
-
# Clone the
|
|
187
|
-
git clone https://github.com/
|
|
188
|
-
cd appium-ios
|
|
189
|
-
|
|
190
|
-
# Install dependencies
|
|
173
|
+
# Clone the monorepo and install dependencies
|
|
174
|
+
git clone https://github.com/appium/appium-ios.git
|
|
175
|
+
cd appium-ios
|
|
191
176
|
npm install
|
|
192
177
|
|
|
193
|
-
# Build
|
|
194
|
-
npm run build
|
|
178
|
+
# Build this package
|
|
179
|
+
npm run build --workspace appium-ios-remotexpc
|
|
195
180
|
```
|
|
196
181
|
|
|
197
182
|
### Continuous Integration
|
|
198
183
|
|
|
199
|
-
This
|
|
200
|
-
|
|
201
|
-
- **Lint and Build**: Automatically runs linting and builds the project on Node.js LTS.
|
|
202
|
-
- **Format Check**: Ensures code formatting adheres to project standards
|
|
203
|
-
- **Test Validation**: Validates that test files compile correctly (actual tests require physical devices)
|
|
204
|
-
- **Dependabot**: Automatically creates PRs for dependency updates weekly
|
|
205
|
-
|
|
206
|
-
All pull requests must pass these checks before merging. The workflows are defined in the `.github/workflows` directory.
|
|
184
|
+
This package is part of the [appium-ios](https://github.com/appium/appium-ios) monorepo and is covered by its root lint, format, build and unit-test workflows (see `.github/workflows`). Actual integration tests require physical devices and are not run in CI.
|
|
207
185
|
|
|
208
186
|
### Scripts
|
|
209
187
|
|
|
188
|
+
Run these from the monorepo root with `--workspace appium-ios-remotexpc`, or from `packages/remotexpc`.
|
|
189
|
+
|
|
210
190
|
- `npm run build` - Clean and build the project
|
|
211
191
|
- `npm run lint` - Run lint
|
|
212
192
|
- `npm run format` - Run format
|
|
@@ -218,6 +198,20 @@ CLI helpers under `scripts/` are ESM (`.mjs`) and load the library via the packa
|
|
|
218
198
|
|
|
219
199
|
- `npm run tunnel-creation` — Create USB tunnels and start the tunnel registry HTTP API (requires `sudo`)
|
|
220
200
|
- `npm run tunnel-creation -- --keep-open` — Same as above with `--keep-open` (for inspecting open sockets)
|
|
201
|
+
- `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.
|
|
202
|
+
|
|
203
|
+
`--watch-devices` combined with the other `tunnel-creation` flags:
|
|
204
|
+
|
|
205
|
+
| `--watch-devices` | `--reconnect-retries` | `--udid` | Behavior |
|
|
206
|
+
| ----------------- | --------------------- | -------- | -------- |
|
|
207
|
+
| 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. |
|
|
208
|
+
| off | on | any | Unchanged: a dropped tunnel is retried up to N times (0 = unlimited). Detaching a device does not stop the retries. |
|
|
209
|
+
| 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. |
|
|
210
|
+
| 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. |
|
|
211
|
+
| 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. |
|
|
212
|
+
| on | on | set | As "on / on / none", restricted to that UDID, with the same wait-at-startup behavior. |
|
|
213
|
+
|
|
214
|
+
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
215
|
- `npm run pair-appletv` — Pair an Apple TV over WiFi for Remote XPC (requires `sudo`)
|
|
222
216
|
- `npm run start-appletv-tunnel` — Start an Apple TV WiFi tunnel and tunnel registry (requires `sudo`)
|
|
223
217
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appium-ios-remotexpc",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.24.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"bugs": {
|
|
7
|
-
"url": "https://github.com/appium/appium-ios
|
|
7
|
+
"url": "https://github.com/appium/appium-ios/issues"
|
|
8
8
|
},
|
|
9
9
|
"license": "Apache-2.0",
|
|
10
10
|
"author": "Appium Contributors",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "
|
|
13
|
+
"url": "https://github.com/appium/appium-ios.git",
|
|
14
|
+
"directory": "packages/remotexpc"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"src",
|
|
@@ -30,15 +31,11 @@
|
|
|
30
31
|
}
|
|
31
32
|
},
|
|
32
33
|
"scripts": {
|
|
33
|
-
"build:es": "tsc",
|
|
34
|
+
"build:es": "tsc -b",
|
|
34
35
|
"build": "npm run clean:build && npm run build:es",
|
|
35
36
|
"clean:build": "rimraf ./build",
|
|
36
|
-
"format:check": "oxfmt -c oxfmt.config.mjs --check .",
|
|
37
|
-
"format": "oxfmt -c oxfmt.config.mjs .",
|
|
38
|
-
"lint:fix": "oxlint -c oxlint.config.mjs --fix .",
|
|
39
|
-
"lint": "oxlint -c oxlint.config.mjs .",
|
|
40
37
|
"pair-appletv": "sudo node scripts/pair-appletv.mjs",
|
|
41
|
-
"prepare": "
|
|
38
|
+
"prepare": "npm run build",
|
|
42
39
|
"start-appletv-tunnel": "sudo node scripts/start-appletv-tunnel.mjs",
|
|
43
40
|
"test:afc-installation-workflow": "node --enable-source-maps --test --test-timeout=300000 \"build/test/integration/afc-installation-workflow.spec.js\"",
|
|
44
41
|
"test:afc-push-perf": "node --enable-source-maps --test --test-timeout=180000 \"build/test/integration/afc-push-performance.spec.js\"",
|
|
@@ -89,7 +86,8 @@
|
|
|
89
86
|
"test:webinspector": "node --enable-source-maps --test --test-timeout=60000 \"build/test/integration/webinspector.spec.js\"",
|
|
90
87
|
"test:zipconduit-install": "node --enable-source-maps --test --test-timeout=600000 \"build/test/integration/zipconduit-install.spec.js\"",
|
|
91
88
|
"test": "npm run test:unit",
|
|
92
|
-
"tunnel-creation": "sudo node scripts/tunnel-creation.mjs"
|
|
89
|
+
"tunnel-creation": "sudo node scripts/tunnel-creation.mjs",
|
|
90
|
+
"clean": "npm run clean:build"
|
|
93
91
|
},
|
|
94
92
|
"dependencies": {
|
|
95
93
|
"@appium/strongbox": "^2.0.0",
|
|
@@ -105,28 +103,16 @@
|
|
|
105
103
|
},
|
|
106
104
|
"devDependencies": {
|
|
107
105
|
"@appium/oxc-config": "^1.1.0",
|
|
108
|
-
"@appium/semantic-release-config": "^1.1.0",
|
|
109
106
|
"@appium/tsconfig": "^1.2.0",
|
|
110
107
|
"@types/async-lock": "^1.4.2",
|
|
111
108
|
"@types/node": "^26.0.1",
|
|
112
109
|
"@types/sinon": "^22.0.0",
|
|
113
|
-
"husky": "^9.1.7",
|
|
114
|
-
"lint-staged": "^17.0.2",
|
|
115
110
|
"rimraf": "^6.0.1",
|
|
116
111
|
"sinon": "^22.0.0"
|
|
117
112
|
},
|
|
118
|
-
"lint-staged": {
|
|
119
|
-
"*.ts": [
|
|
120
|
-
"oxlint -c oxlint.config.mjs --fix",
|
|
121
|
-
"oxfmt -c oxfmt.config.mjs"
|
|
122
|
-
]
|
|
123
|
-
},
|
|
124
113
|
"engines": {
|
|
125
114
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
|
|
126
115
|
"npm": ">=10"
|
|
127
116
|
},
|
|
128
|
-
"
|
|
129
|
-
"appium-ios-tuntap": true,
|
|
130
|
-
"unrs-resolver": true
|
|
131
|
-
}
|
|
117
|
+
"gitHead": "5ae958e85151476faa49364385c85e000e6ff324"
|
|
132
118
|
}
|
|
@@ -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
|
|
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;
|