appium-ios-remotexpc 5.2.0 → 5.2.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.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/scripts/lib/constants.mjs +4 -0
- package/scripts/lib/options.mjs +56 -0
- package/scripts/lib/progress.mjs +53 -0
- package/scripts/lib/root.mjs +36 -0
- package/scripts/lib/timers.mjs +7 -0
- package/scripts/lib/tunnel-registry.mjs +81 -0
- package/scripts/pair-appletv.mjs +50 -70
- package/scripts/start-appletv-tunnel.mjs +114 -106
- package/scripts/tunnel-creation.mjs +43 -99
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [5.2.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.2.0...v5.2.1) (2026-06-24)
|
|
2
|
+
|
|
3
|
+
### Miscellaneous Chores
|
|
4
|
+
|
|
5
|
+
* refactor tunnels creation and pairing scripts ([#247](https://github.com/appium/appium-ios-remotexpc/issues/247)) ([c614312](https://github.com/appium/appium-ios-remotexpc/commit/c614312dde31ce4019707ff3f079b57362a761ee))
|
|
6
|
+
|
|
1
7
|
## [5.2.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.1.6...v5.2.0) (2026-06-22)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/package.json
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} value
|
|
3
|
+
* @param {string} label
|
|
4
|
+
* @returns {number}
|
|
5
|
+
*/
|
|
6
|
+
export function parsePositiveIntegerOption(value, label) {
|
|
7
|
+
return parseIntegerOption(
|
|
8
|
+
value,
|
|
9
|
+
label,
|
|
10
|
+
(num) => num > 0,
|
|
11
|
+
'a positive integer in milliseconds',
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} value
|
|
17
|
+
* @param {string} label
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export function parseNonNegativeIntegerOption(value, label) {
|
|
21
|
+
return parseIntegerOption(
|
|
22
|
+
value,
|
|
23
|
+
label,
|
|
24
|
+
(num) => num >= 0,
|
|
25
|
+
'a non-negative integer (0 = unlimited)',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} value
|
|
31
|
+
* @param {string} label
|
|
32
|
+
* @returns {number}
|
|
33
|
+
*/
|
|
34
|
+
export function parsePortOption(value, label) {
|
|
35
|
+
return parseIntegerOption(
|
|
36
|
+
value,
|
|
37
|
+
label,
|
|
38
|
+
(num) => num > 0 && num <= 65535,
|
|
39
|
+
'an integer between 1 and 65535',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} value
|
|
45
|
+
* @param {string} label
|
|
46
|
+
* @param {(num: number) => boolean} isValid
|
|
47
|
+
* @param {string} expectation
|
|
48
|
+
* @returns {number}
|
|
49
|
+
*/
|
|
50
|
+
function parseIntegerOption(value, label, isValid, expectation) {
|
|
51
|
+
const num = Number.parseInt(value, 10);
|
|
52
|
+
if (!Number.isFinite(num) || !isValid(num)) {
|
|
53
|
+
throw new Error(`Invalid ${label}: ${value}. Expected ${expectation}.`);
|
|
54
|
+
}
|
|
55
|
+
return num;
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logs a timeout-based progress bar while an operation without native progress callbacks is running.
|
|
3
|
+
*
|
|
4
|
+
* @param {{log: {info: (message: string) => void}, label: string, startedAt: number, timeoutMs: number, barWidth: number, intervalMs: number}} opts
|
|
5
|
+
* @returns {{succeed: (message?: string) => void, fail: (message?: string) => void}}
|
|
6
|
+
*/
|
|
7
|
+
export function startTimeoutProgressLogger({
|
|
8
|
+
log,
|
|
9
|
+
label,
|
|
10
|
+
startedAt,
|
|
11
|
+
timeoutMs,
|
|
12
|
+
barWidth,
|
|
13
|
+
intervalMs,
|
|
14
|
+
}) {
|
|
15
|
+
/** @type {NodeJS.Timeout | null} */
|
|
16
|
+
let timer = null;
|
|
17
|
+
let isStopped = false;
|
|
18
|
+
|
|
19
|
+
const logProgress = (status, isComplete = false) => {
|
|
20
|
+
const elapsedMs = performance.now() - startedAt;
|
|
21
|
+
const boundedElapsedMs = Math.min(elapsedMs, timeoutMs);
|
|
22
|
+
const progress = isComplete ? 1 : boundedElapsedMs / timeoutMs;
|
|
23
|
+
const filledWidth = Math.round(progress * barWidth);
|
|
24
|
+
const emptyWidth = barWidth - filledWidth;
|
|
25
|
+
const bar = `${'#'.repeat(filledWidth)}${'-'.repeat(emptyWidth)}`;
|
|
26
|
+
log.info(
|
|
27
|
+
`${label}: [${bar}]${status && status !== 'waiting' ? ` - ${status}` : ''}`,
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const stop = (status, isComplete = false) => {
|
|
32
|
+
if (isStopped) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
isStopped = true;
|
|
36
|
+
if (timer) {
|
|
37
|
+
clearInterval(timer);
|
|
38
|
+
timer = null;
|
|
39
|
+
}
|
|
40
|
+
logProgress(status, isComplete);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
logProgress('waiting');
|
|
44
|
+
timer = setInterval(() => {
|
|
45
|
+
logProgress('waiting');
|
|
46
|
+
}, intervalMs);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
succeed: (message = 'done') => stop(message, true),
|
|
51
|
+
fail: (message = 'failed') => stop(message),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ensures a helper script is run with elevated privileges.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} relativeScriptPath
|
|
10
|
+
*/
|
|
11
|
+
export async function assertRoot(relativeScriptPath) {
|
|
12
|
+
if (process.platform === 'win32') {
|
|
13
|
+
await assertWindowsAdmin(relativeScriptPath);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof process.getuid !== 'function') {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (process.getuid() !== 0) {
|
|
21
|
+
throw new Error(`This script must be run as root/admin (e.g. sudo node "${relativeScriptPath}").`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} relativeScriptPath
|
|
27
|
+
*/
|
|
28
|
+
async function assertWindowsAdmin(relativeScriptPath) {
|
|
29
|
+
try {
|
|
30
|
+
await execFileAsync('net', ['session']);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`This script must be run as Administrator (e.g. from an elevated terminal: node "${relativeScriptPath}").`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { discoverServices, servicesToCatalog } from 'appium-ios-remotexpc';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @returns {import('appium-ios-remotexpc').TunnelRegistry}
|
|
5
|
+
*/
|
|
6
|
+
export function createEmptyTunnelRegistry() {
|
|
7
|
+
return {
|
|
8
|
+
tunnels: {},
|
|
9
|
+
metadata: {
|
|
10
|
+
lastUpdated: new Date().toISOString(),
|
|
11
|
+
totalTunnels: 0,
|
|
12
|
+
activeTunnels: 0,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} udid
|
|
19
|
+
* @param {import('appium-ios-remotexpc').TunnelRegistryEntry} entry
|
|
20
|
+
* @param {{info: (message: string) => void}} log
|
|
21
|
+
*/
|
|
22
|
+
export async function refreshServiceCatalog(udid, entry, log) {
|
|
23
|
+
log.info(`Refreshing RSD service catalog for ${udid}...`);
|
|
24
|
+
const services = await discoverServices(udid, entry.address, entry.rsdPort);
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
return {
|
|
27
|
+
...entry,
|
|
28
|
+
services: servicesToCatalog(services),
|
|
29
|
+
catalogUpdatedAt: now,
|
|
30
|
+
lastUpdated: now,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @template T
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {import('appium-ios-remotexpc').TunnelRegistryServer | null} opts.registryServer
|
|
38
|
+
* @param {T} opts.result
|
|
39
|
+
* @param {(result: T) => string} opts.getUdid
|
|
40
|
+
* @param {(result: T, existing: import('appium-ios-remotexpc').TunnelRegistryEntry | undefined, now: number) => import('appium-ios-remotexpc').TunnelRegistryEntry} opts.buildEntry
|
|
41
|
+
* @param {{info: (message: string) => void, warn: (message: string) => void}} opts.log
|
|
42
|
+
* @returns {Promise<boolean>}
|
|
43
|
+
*/
|
|
44
|
+
export async function publishDiscoveredTunnelEntry({
|
|
45
|
+
registryServer,
|
|
46
|
+
result,
|
|
47
|
+
getUdid,
|
|
48
|
+
buildEntry,
|
|
49
|
+
log,
|
|
50
|
+
}) {
|
|
51
|
+
if (!registryServer) {
|
|
52
|
+
throw new Error('Registry server is not started');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const udid = getUdid(result);
|
|
56
|
+
const rsdPort = result.tunnel.RsdPort;
|
|
57
|
+
if (typeof rsdPort !== 'number' || rsdPort <= 0) {
|
|
58
|
+
log.warn(
|
|
59
|
+
`Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`,
|
|
60
|
+
);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
registryServer.markTunnelPending(udid);
|
|
65
|
+
log.info(
|
|
66
|
+
`Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const services = await discoverServices(udid, result.tunnel.Address, rsdPort);
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const registry = registryServer.getRegistry();
|
|
72
|
+
const entry = buildEntry(result, registry.tunnels[udid], now);
|
|
73
|
+
entry.services = servicesToCatalog(services);
|
|
74
|
+
entry.catalogUpdatedAt = now;
|
|
75
|
+
|
|
76
|
+
registryServer.upsertReadyEntry(udid, entry);
|
|
77
|
+
log.info(
|
|
78
|
+
`Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`,
|
|
79
|
+
);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
package/scripts/pair-appletv.mjs
CHANGED
|
@@ -6,74 +6,20 @@
|
|
|
6
6
|
* npm run pair-appletv -- [options]
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { logger } from '@appium/support';
|
|
9
|
+
import { logger, util } from '@appium/support';
|
|
10
10
|
import { Command } from 'commander';
|
|
11
11
|
import {
|
|
12
12
|
AppleTVPairingService,
|
|
13
13
|
UserInputService,
|
|
14
14
|
} from 'appium-ios-remotexpc';
|
|
15
|
-
import {
|
|
15
|
+
import { DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS } from './lib/constants.mjs';
|
|
16
|
+
import { parsePositiveIntegerOption } from './lib/options.mjs';
|
|
17
|
+
import { startTimeoutProgressLogger } from './lib/progress.mjs';
|
|
16
18
|
|
|
17
19
|
const log = logger.getLogger('AppleTVPairing');
|
|
18
20
|
const APPLETV_PAIRING_DISCOVERY_PROGRESS_INTERVAL_MS = 1000;
|
|
19
21
|
const APPLETV_PAIRING_DISCOVERY_PROGRESS_BAR_WIDTH = 24;
|
|
20
22
|
|
|
21
|
-
function parsePositiveInteger(value) {
|
|
22
|
-
const count = Number.parseInt(value, 10);
|
|
23
|
-
if (!Number.isFinite(count) || count <= 0) {
|
|
24
|
-
throw new Error(
|
|
25
|
-
`Invalid timeout: ${value}. Expected a positive integer in milliseconds.`,
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
return count;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function startTimeoutProgressLogger({
|
|
32
|
-
label,
|
|
33
|
-
startedAt,
|
|
34
|
-
timeoutMs,
|
|
35
|
-
barWidth,
|
|
36
|
-
intervalMs,
|
|
37
|
-
}) {
|
|
38
|
-
let timer = null;
|
|
39
|
-
let isStopped = false;
|
|
40
|
-
|
|
41
|
-
const logProgress = (status, isComplete = false) => {
|
|
42
|
-
const elapsedMs = performance.now() - startedAt;
|
|
43
|
-
const boundedElapsedMs = Math.min(elapsedMs, timeoutMs);
|
|
44
|
-
const progress = isComplete ? 1 : boundedElapsedMs / timeoutMs;
|
|
45
|
-
const filledWidth = Math.round(progress * barWidth);
|
|
46
|
-
const emptyWidth = barWidth - filledWidth;
|
|
47
|
-
const bar = `${'#'.repeat(filledWidth)}${'-'.repeat(emptyWidth)}`;
|
|
48
|
-
log.info(
|
|
49
|
-
`${label}: [${bar}]${status && status !== 'waiting' ? ` - ${status}` : ''}`,
|
|
50
|
-
);
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const stop = (status, isComplete = false) => {
|
|
54
|
-
if (isStopped) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
isStopped = true;
|
|
58
|
-
if (timer) {
|
|
59
|
-
clearInterval(timer);
|
|
60
|
-
timer = null;
|
|
61
|
-
}
|
|
62
|
-
logProgress(status, isComplete);
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
logProgress('waiting');
|
|
66
|
-
timer = setInterval(() => {
|
|
67
|
-
logProgress('waiting');
|
|
68
|
-
}, intervalMs);
|
|
69
|
-
timer.unref?.();
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
succeed: (message = 'done') => stop(message, true),
|
|
73
|
-
fail: (message = 'failed') => stop(message),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
23
|
function discoverAppleTVPairingDevices(pairingService, timeoutMs) {
|
|
78
24
|
const startedAt = performance.now();
|
|
79
25
|
const promise = pairingService.discoverDevices({ timeoutMs });
|
|
@@ -82,6 +28,7 @@ function discoverAppleTVPairingDevices(pairingService, timeoutMs) {
|
|
|
82
28
|
|
|
83
29
|
async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
|
|
84
30
|
const progress = startTimeoutProgressLogger({
|
|
31
|
+
log,
|
|
85
32
|
label: 'Waiting for Apple TV pairing discovery',
|
|
86
33
|
startedAt: discovery.startedAt,
|
|
87
34
|
timeoutMs,
|
|
@@ -92,7 +39,7 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
|
|
|
92
39
|
try {
|
|
93
40
|
const devices = await discovery.promise;
|
|
94
41
|
progress.succeed(
|
|
95
|
-
`Apple TV pairing discovery completed: ${devices.length
|
|
42
|
+
`Apple TV pairing discovery completed: ${util.pluralize('device', devices.length, true)} found`,
|
|
96
43
|
);
|
|
97
44
|
return devices;
|
|
98
45
|
} catch (err) {
|
|
@@ -101,6 +48,44 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
|
|
|
101
48
|
}
|
|
102
49
|
}
|
|
103
50
|
|
|
51
|
+
async function discoverAndPairWithProgress(
|
|
52
|
+
pairingService,
|
|
53
|
+
deviceSelector,
|
|
54
|
+
discoveryTimeoutMs,
|
|
55
|
+
) {
|
|
56
|
+
const discovery = discoverAppleTVPairingDevices(
|
|
57
|
+
pairingService,
|
|
58
|
+
discoveryTimeoutMs,
|
|
59
|
+
);
|
|
60
|
+
const devices = await waitForAppleTVPairingDiscovery(
|
|
61
|
+
discovery,
|
|
62
|
+
discoveryTimeoutMs,
|
|
63
|
+
);
|
|
64
|
+
return await pairingService.discoverAndPair(deviceSelector, {
|
|
65
|
+
devices,
|
|
66
|
+
discoveryTimeoutMs,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {unknown} err
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function isNoAppleTVPairingDevicesFoundError(err) {
|
|
75
|
+
return (
|
|
76
|
+
err instanceof Error &&
|
|
77
|
+
(err.message === getNoAppleTVPairingDevicesMessage() ||
|
|
78
|
+
('code' in err && err.code === 'NO_DEVICES'))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function getNoAppleTVPairingDevicesMessage() {
|
|
86
|
+
return 'No Apple TV pairing devices found. Please ensure your Apple TV is on the same network and in pairing mode.';
|
|
87
|
+
}
|
|
88
|
+
|
|
104
89
|
async function main() {
|
|
105
90
|
const program = new Command();
|
|
106
91
|
program
|
|
@@ -113,31 +98,26 @@ async function main() {
|
|
|
113
98
|
.option(
|
|
114
99
|
'--discovery-timeout <ms>',
|
|
115
100
|
'Apple TV pairing discovery timeout in milliseconds',
|
|
116
|
-
|
|
101
|
+
(value) => parsePositiveIntegerOption(value, 'discovery timeout'),
|
|
117
102
|
);
|
|
118
103
|
|
|
119
104
|
program.parse(process.argv);
|
|
120
105
|
const options = program.opts();
|
|
121
106
|
const discoveryTimeoutMs =
|
|
122
|
-
options.discoveryTimeout ??
|
|
107
|
+
options.discoveryTimeout ?? DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS;
|
|
123
108
|
|
|
124
109
|
const userInput = new UserInputService();
|
|
125
110
|
const pairingService = new AppleTVPairingService(userInput);
|
|
126
|
-
const
|
|
111
|
+
const result = await discoverAndPairWithProgress(
|
|
127
112
|
pairingService,
|
|
113
|
+
options.device,
|
|
128
114
|
discoveryTimeoutMs,
|
|
129
115
|
);
|
|
130
|
-
const devices = await waitForAppleTVPairingDiscovery(
|
|
131
|
-
discovery,
|
|
132
|
-
discoveryTimeoutMs,
|
|
133
|
-
);
|
|
134
|
-
const result = await pairingService.discoverAndPair(options.device, {
|
|
135
|
-
devices,
|
|
136
|
-
discoveryTimeoutMs,
|
|
137
|
-
});
|
|
138
116
|
|
|
139
117
|
if (result.success) {
|
|
140
118
|
log.info(`Pairing successful! Record saved to: ${result.pairingFile}`);
|
|
119
|
+
} else if (isNoAppleTVPairingDevicesFoundError(result.error)) {
|
|
120
|
+
log.info(getNoAppleTVPairingDevicesMessage());
|
|
141
121
|
} else {
|
|
142
122
|
throw result.error ?? new Error('Pairing failed');
|
|
143
123
|
}
|
|
@@ -11,15 +11,31 @@ import {
|
|
|
11
11
|
AppleTVTunnelService,
|
|
12
12
|
TunnelManager,
|
|
13
13
|
TunnelReadinessCoordinator,
|
|
14
|
-
discoverServices,
|
|
15
|
-
servicesToCatalog,
|
|
16
14
|
startTunnelRegistryServer,
|
|
17
15
|
watchTunnelRegistryOnDead,
|
|
18
16
|
} from 'appium-ios-remotexpc';
|
|
19
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
parseNonNegativeIntegerOption,
|
|
19
|
+
parsePortOption,
|
|
20
|
+
parsePositiveIntegerOption,
|
|
21
|
+
} from './lib/options.mjs';
|
|
22
|
+
import { sleep } from './lib/timers.mjs';
|
|
23
|
+
import {
|
|
24
|
+
createEmptyTunnelRegistry,
|
|
25
|
+
publishDiscoveredTunnelEntry as publishTunnelRegistryEntry,
|
|
26
|
+
refreshServiceCatalog,
|
|
27
|
+
} from './lib/tunnel-registry.mjs';
|
|
28
|
+
import { DEFAULT_TUNNEL_REGISTRY_PORT, DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS } from './lib/constants.mjs';
|
|
29
|
+
import { assertRoot } from './lib/root.mjs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { startTimeoutProgressLogger } from './lib/progress.mjs';
|
|
20
33
|
|
|
21
34
|
const log = logger.getLogger('WiFiTunnel');
|
|
22
35
|
|
|
36
|
+
const APPLETV_TUNNEL_DISCOVERY_PROGRESS_INTERVAL_MS = 1000;
|
|
37
|
+
const APPLETV_TUNNEL_DISCOVERY_PROGRESS_BAR_WIDTH = 24;
|
|
38
|
+
|
|
23
39
|
/** @type {import('appium-ios-remotexpc').TunnelRegistryServer | null} */
|
|
24
40
|
let registryServer = null;
|
|
25
41
|
|
|
@@ -48,6 +64,54 @@ function stopLifecycleWatch(udid) {
|
|
|
48
64
|
}
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
/**
|
|
68
|
+
*
|
|
69
|
+
* @param {import('appium-ios-remotexpc').AppleTVTunnelService} tunnelService
|
|
70
|
+
* @param {number} timeoutMs
|
|
71
|
+
* @returns {Promise<number, Promise<AppleTVDevice[]>>}
|
|
72
|
+
*/
|
|
73
|
+
function startDevicesDiscovery(tunnelService, timeoutMs) {
|
|
74
|
+
const startedAt = performance.now();
|
|
75
|
+
const promise = tunnelService.discoverDevices({ timeoutMs });
|
|
76
|
+
return { startedAt, promise };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
*
|
|
81
|
+
* @param {Promise<AppleTVDevice[]>} discovery
|
|
82
|
+
* @param {number} timeoutMs
|
|
83
|
+
* @returns {Promise<AppleTVDevice[]>}
|
|
84
|
+
*/
|
|
85
|
+
async function waitForDevicesDiscovery(discovery, timeoutMs) {
|
|
86
|
+
const progress = startTimeoutProgressLogger({
|
|
87
|
+
log,
|
|
88
|
+
label: 'Waiting for wireless Apple TV devices discovery',
|
|
89
|
+
startedAt: discovery.startedAt,
|
|
90
|
+
timeoutMs,
|
|
91
|
+
barWidth: APPLETV_TUNNEL_DISCOVERY_PROGRESS_BAR_WIDTH,
|
|
92
|
+
intervalMs: APPLETV_TUNNEL_DISCOVERY_PROGRESS_INTERVAL_MS,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const devices = await discovery.promise;
|
|
97
|
+
progress.succeed(
|
|
98
|
+
`Wireless Apple TV devices discovery completed: ${util.pluralize('device', devices.length, true)} found`,
|
|
99
|
+
);
|
|
100
|
+
return devices;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
progress.fail('Wireless Apple TV devices discovery failed');
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {unknown} err
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
function isNoDevicesFoundError(err) {
|
|
112
|
+
return err instanceof Error && ('code' in err && err.code === 'NO_DEVICES') || err.message?.includes('No devices found');
|
|
113
|
+
}
|
|
114
|
+
|
|
51
115
|
function registerEstablishedTunnel(result) {
|
|
52
116
|
const udid = result.device.identifier;
|
|
53
117
|
stopLifecycleWatch(udid);
|
|
@@ -63,36 +127,6 @@ function registerEstablishedTunnel(result) {
|
|
|
63
127
|
}
|
|
64
128
|
let tunnelService = null;
|
|
65
129
|
|
|
66
|
-
function parsePort(value) {
|
|
67
|
-
const port = Number.parseInt(value, 10);
|
|
68
|
-
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`Invalid port: ${value}. Expected an integer between 1 and 65535.`,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
return port;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function parseNonNegativeInteger(value) {
|
|
77
|
-
const count = Number.parseInt(value, 10);
|
|
78
|
-
if (!Number.isFinite(count) || count < 0) {
|
|
79
|
-
throw new Error(
|
|
80
|
-
`Invalid retry count: ${value}. Expected a non-negative integer (0 = unlimited).`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
return count;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function parsePositiveInteger(value) {
|
|
87
|
-
const count = Number.parseInt(value, 10);
|
|
88
|
-
if (!Number.isFinite(count) || count <= 0) {
|
|
89
|
-
throw new Error(
|
|
90
|
-
`Invalid timeout: ${value}. Expected a positive integer in milliseconds.`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
return count;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
130
|
/**
|
|
97
131
|
* @param {object} registry
|
|
98
132
|
* @param {AppleTvEstablishedTunnel[]} successfulResults
|
|
@@ -166,25 +200,27 @@ function setupCleanupHandlers() {
|
|
|
166
200
|
|
|
167
201
|
process.on('SIGINT', async () => {
|
|
168
202
|
await cleanup('SIGINT (Ctrl+C)');
|
|
169
|
-
process.exit(0);
|
|
203
|
+
process.exit(process.exitCode || 0);
|
|
170
204
|
});
|
|
171
205
|
process.on('SIGTERM', async () => {
|
|
172
206
|
await cleanup('SIGTERM');
|
|
173
|
-
process.exit(0);
|
|
207
|
+
process.exit(process.exitCode || 0);
|
|
174
208
|
});
|
|
175
209
|
process.on('SIGHUP', async () => {
|
|
176
210
|
await cleanup('SIGHUP');
|
|
177
|
-
process.exit(0);
|
|
211
|
+
process.exit(process.exitCode || 0);
|
|
178
212
|
});
|
|
179
213
|
|
|
180
214
|
process.on('uncaughtException', async (error) => {
|
|
181
215
|
log.error('Uncaught Exception:', error);
|
|
182
216
|
await cleanup('Uncaught Exception');
|
|
217
|
+
process.exit(process.exitCode || 1);
|
|
183
218
|
});
|
|
184
219
|
|
|
185
220
|
process.on('unhandledRejection', async (reason, promise) => {
|
|
186
221
|
log.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
187
222
|
await cleanup('Unhandled Rejection');
|
|
223
|
+
process.exit(process.exitCode || 1);
|
|
188
224
|
});
|
|
189
225
|
}
|
|
190
226
|
|
|
@@ -220,21 +256,8 @@ async function establishOneTunnel(startResult) {
|
|
|
220
256
|
return result;
|
|
221
257
|
}
|
|
222
258
|
|
|
223
|
-
|
|
224
|
-
log.info(`Refreshing RSD service catalog for ${udid}...`);
|
|
225
|
-
const services = await discoverServices(udid, entry.address, entry.rsdPort);
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
return {
|
|
228
|
-
...entry,
|
|
229
|
-
services: servicesToCatalog(services),
|
|
230
|
-
catalogUpdatedAt: now,
|
|
231
|
-
lastUpdated: now,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function buildAppleTvTunnelEntryBase(result, existing) {
|
|
259
|
+
function buildAppleTvTunnelEntry(result, existing, now) {
|
|
236
260
|
const udid = result.device.identifier;
|
|
237
|
-
const now = Date.now();
|
|
238
261
|
const entry = {
|
|
239
262
|
udid,
|
|
240
263
|
deviceId: 0,
|
|
@@ -250,44 +273,13 @@ function buildAppleTvTunnelEntryBase(result, existing) {
|
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
async function publishDiscoveredTunnelEntry(result) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
log.warn(
|
|
261
|
-
`Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`,
|
|
262
|
-
);
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
registryServer.markTunnelPending(udid);
|
|
267
|
-
log.info(
|
|
268
|
-
`Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`,
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
const services = await discoverServices(
|
|
272
|
-
udid,
|
|
273
|
-
result.tunnel.Address,
|
|
274
|
-
rsdPort,
|
|
275
|
-
);
|
|
276
|
-
const now = Date.now();
|
|
277
|
-
const registry = registryServer.getRegistry();
|
|
278
|
-
const entry = buildAppleTvTunnelEntryBase(result, registry.tunnels[udid]);
|
|
279
|
-
entry.services = servicesToCatalog(services);
|
|
280
|
-
entry.catalogUpdatedAt = now;
|
|
281
|
-
|
|
282
|
-
registryServer.upsertReadyEntry(udid, entry);
|
|
283
|
-
log.info(
|
|
284
|
-
`Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`,
|
|
285
|
-
);
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function sleep(ms) {
|
|
290
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
276
|
+
return await publishTunnelRegistryEntry({
|
|
277
|
+
registryServer,
|
|
278
|
+
result,
|
|
279
|
+
getUdid: (r) => r.device.identifier,
|
|
280
|
+
buildEntry: buildAppleTvTunnelEntry,
|
|
281
|
+
log,
|
|
282
|
+
});
|
|
291
283
|
}
|
|
292
284
|
|
|
293
285
|
async function runAppleTvReconnectAttempts({
|
|
@@ -382,17 +374,17 @@ async function main() {
|
|
|
382
374
|
.option(
|
|
383
375
|
'--tunnel-registry-port <port>',
|
|
384
376
|
`Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
|
|
385
|
-
|
|
377
|
+
(value) => parsePortOption(value, 'tunnel registry port'),
|
|
386
378
|
)
|
|
387
379
|
.option(
|
|
388
380
|
'--reconnect-retries <count>',
|
|
389
381
|
'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
|
|
390
|
-
|
|
382
|
+
(value) => parseNonNegativeIntegerOption(value, 'retry count'),
|
|
391
383
|
)
|
|
392
384
|
.option(
|
|
393
385
|
'--discovery-timeout <ms>',
|
|
394
386
|
'Apple TV discovery timeout in milliseconds',
|
|
395
|
-
|
|
387
|
+
(value) => parsePositiveIntegerOption(value, 'discovery timeout'),
|
|
396
388
|
);
|
|
397
389
|
|
|
398
390
|
program.parse(process.argv);
|
|
@@ -401,6 +393,8 @@ async function main() {
|
|
|
401
393
|
const registryPort =
|
|
402
394
|
options.tunnelRegistryPort ?? DEFAULT_TUNNEL_REGISTRY_PORT;
|
|
403
395
|
|
|
396
|
+
await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
|
|
397
|
+
|
|
404
398
|
if (deviceIdentifier) {
|
|
405
399
|
log.info(`Targeting a single Apple TV: ${deviceIdentifier}`);
|
|
406
400
|
} else {
|
|
@@ -413,18 +407,12 @@ async function main() {
|
|
|
413
407
|
|
|
414
408
|
try {
|
|
415
409
|
const readiness = new TunnelReadinessCoordinator();
|
|
416
|
-
const registry =
|
|
417
|
-
tunnels: {},
|
|
418
|
-
metadata: {
|
|
419
|
-
lastUpdated: new Date().toISOString(),
|
|
420
|
-
totalTunnels: 0,
|
|
421
|
-
activeTunnels: 0,
|
|
422
|
-
},
|
|
423
|
-
};
|
|
410
|
+
const registry = createEmptyTunnelRegistry();
|
|
424
411
|
|
|
425
412
|
registryServer = await startTunnelRegistryServer(registry, registryPort, {
|
|
426
413
|
readiness,
|
|
427
|
-
refreshServices:
|
|
414
|
+
refreshServices: async (udid, entry) =>
|
|
415
|
+
await refreshServiceCatalog(udid, entry, log),
|
|
428
416
|
});
|
|
429
417
|
|
|
430
418
|
const reconnectTunnelByUdid = createAppleTvReconnectTunnelByUdid({
|
|
@@ -460,11 +448,27 @@ async function main() {
|
|
|
460
448
|
},
|
|
461
449
|
);
|
|
462
450
|
} else {
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
451
|
+
const discoveryTimeoutMs = options.discoveryTimeout ?? DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS;
|
|
452
|
+
const discovery = startDevicesDiscovery(
|
|
453
|
+
tunnelService,
|
|
454
|
+
discoveryTimeoutMs,
|
|
455
|
+
);
|
|
456
|
+
/** @type {AppleTVDevice[]} */
|
|
457
|
+
let devices = [];
|
|
458
|
+
try {
|
|
459
|
+
devices = await waitForDevicesDiscovery(
|
|
460
|
+
discovery,
|
|
461
|
+
discoveryTimeoutMs,
|
|
462
|
+
);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (isNoDevicesFoundError(err)) {
|
|
465
|
+
log.info('No wireless Apple TV devices found');
|
|
466
|
+
process.exit(process.exitCode || 0);
|
|
467
|
+
}
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
466
470
|
log.info(
|
|
467
|
-
`Discovered ${
|
|
471
|
+
`Discovered ${util.pluralize('Apple TV device', devices.length, true)}; establishing ${util.pluralize('tunnel', devices.length)}...`,
|
|
468
472
|
);
|
|
469
473
|
|
|
470
474
|
for (let i = 0; i < devices.length; i++) {
|
|
@@ -536,8 +540,12 @@ async function main() {
|
|
|
536
540
|
);
|
|
537
541
|
}
|
|
538
542
|
|
|
539
|
-
log.info(
|
|
540
|
-
|
|
543
|
+
log.info(
|
|
544
|
+
`\n✅ ${registryPublished.length} Apple TV ${util.pluralize('tunnel', registryPublished.length)} ready.`,
|
|
545
|
+
);
|
|
546
|
+
log.info(
|
|
547
|
+
`\nPress Ctrl+C to close ${util.pluralize('tunnel', registryPublished.length)} and exit.`,
|
|
548
|
+
);
|
|
541
549
|
|
|
542
550
|
process.stdin.resume();
|
|
543
551
|
} catch (error) {
|
|
@@ -3,21 +3,31 @@
|
|
|
3
3
|
* Create lockdown + CoreDeviceProxy tunnels for connected USB devices and expose the tunnel registry API.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { logger } from '@appium/support';
|
|
6
|
+
import { logger, util } from '@appium/support';
|
|
7
7
|
import { Command } from 'commander';
|
|
8
|
-
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import path from 'node:path';
|
|
9
10
|
import {
|
|
10
11
|
TunnelManager,
|
|
11
12
|
TunnelReadinessCoordinator,
|
|
12
13
|
createLockdownServiceByUDID,
|
|
13
14
|
createUsbmux,
|
|
14
|
-
discoverServices,
|
|
15
|
-
servicesToCatalog,
|
|
16
15
|
startCoreDeviceProxyTcp,
|
|
17
16
|
startTunnelRegistryServer,
|
|
18
17
|
watchTunnelRegistryOnDead,
|
|
19
18
|
} from 'appium-ios-remotexpc';
|
|
20
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
parseNonNegativeIntegerOption,
|
|
21
|
+
parsePortOption,
|
|
22
|
+
} from './lib/options.mjs';
|
|
23
|
+
import { sleep } from './lib/timers.mjs';
|
|
24
|
+
import {
|
|
25
|
+
createEmptyTunnelRegistry,
|
|
26
|
+
publishDiscoveredTunnelEntry as publishTunnelRegistryEntry,
|
|
27
|
+
refreshServiceCatalog,
|
|
28
|
+
} from './lib/tunnel-registry.mjs';
|
|
29
|
+
import { DEFAULT_TUNNEL_REGISTRY_PORT } from './lib/constants.mjs';
|
|
30
|
+
import { assertRoot } from './lib/root.mjs';
|
|
21
31
|
|
|
22
32
|
const log = logger.getLogger('TunnelCreation');
|
|
23
33
|
|
|
@@ -94,41 +104,8 @@ function dedupeDevicesByUdid(devices) {
|
|
|
94
104
|
return [...byUdid.values()];
|
|
95
105
|
}
|
|
96
106
|
|
|
97
|
-
function
|
|
98
|
-
const port = Number.parseInt(value, 10);
|
|
99
|
-
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
`Invalid port: ${value}. Expected an integer between 1 and 65535.`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
return port;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function parseNonNegativeInteger(value) {
|
|
108
|
-
const count = Number.parseInt(value, 10);
|
|
109
|
-
if (!Number.isFinite(count) || count < 0) {
|
|
110
|
-
throw new Error(
|
|
111
|
-
`Invalid retry count: ${value}. Expected a non-negative integer (0 = unlimited).`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
return count;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function refreshServiceCatalog(udid, entry) {
|
|
118
|
-
log.info(`Refreshing RSD service catalog for ${udid}...`);
|
|
119
|
-
const services = await discoverServices(udid, entry.address, entry.rsdPort);
|
|
120
|
-
const now = Date.now();
|
|
121
|
-
return {
|
|
122
|
-
...entry,
|
|
123
|
-
services: servicesToCatalog(services),
|
|
124
|
-
catalogUpdatedAt: now,
|
|
125
|
-
lastUpdated: now,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function buildTunnelEntryBase(result, existing) {
|
|
107
|
+
function buildTunnelEntry(result, existing, now) {
|
|
130
108
|
const udid = result.device.Properties.SerialNumber;
|
|
131
|
-
const now = Date.now();
|
|
132
109
|
const entry = {
|
|
133
110
|
udid,
|
|
134
111
|
deviceId: result.device.DeviceID,
|
|
@@ -144,40 +121,13 @@ function buildTunnelEntryBase(result, existing) {
|
|
|
144
121
|
}
|
|
145
122
|
|
|
146
123
|
async function publishDiscoveredTunnelEntry(result) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
log.warn(
|
|
155
|
-
`Skipping registry entry for ${udid}: no valid RSD port (got ${String(rsdPort)})`,
|
|
156
|
-
);
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
registryServer.markTunnelPending(udid);
|
|
161
|
-
log.info(
|
|
162
|
-
`Discovering RSD services for ${udid} at ${result.tunnel.Address}:${rsdPort}...`,
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const services = await discoverServices(
|
|
166
|
-
udid,
|
|
167
|
-
result.tunnel.Address,
|
|
168
|
-
rsdPort,
|
|
169
|
-
);
|
|
170
|
-
const now = Date.now();
|
|
171
|
-
const registry = registryServer.getRegistry();
|
|
172
|
-
const entry = buildTunnelEntryBase(result, registry.tunnels[udid]);
|
|
173
|
-
entry.services = servicesToCatalog(services);
|
|
174
|
-
entry.catalogUpdatedAt = now;
|
|
175
|
-
|
|
176
|
-
registryServer.upsertReadyEntry(udid, entry);
|
|
177
|
-
log.info(
|
|
178
|
-
`Published tunnel catalog for ${udid} (${Object.keys(entry.services).length} services)`,
|
|
179
|
-
);
|
|
180
|
-
return true;
|
|
124
|
+
return await publishTunnelRegistryEntry({
|
|
125
|
+
registryServer,
|
|
126
|
+
result,
|
|
127
|
+
getUdid: (r) => r.device.Properties.SerialNumber,
|
|
128
|
+
buildEntry: buildTunnelEntry,
|
|
129
|
+
log,
|
|
130
|
+
});
|
|
181
131
|
}
|
|
182
132
|
|
|
183
133
|
const registryWatcherStops = [];
|
|
@@ -222,10 +172,6 @@ function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}
|
|
|
222
172
|
);
|
|
223
173
|
}
|
|
224
174
|
|
|
225
|
-
function sleep(ms) {
|
|
226
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
175
|
async function runReconnectAttempts({
|
|
230
176
|
udid,
|
|
231
177
|
maxRetries,
|
|
@@ -328,25 +274,27 @@ function setupCleanupHandlers() {
|
|
|
328
274
|
|
|
329
275
|
process.on('SIGINT', async () => {
|
|
330
276
|
await cleanup('SIGINT (Ctrl+C)');
|
|
331
|
-
process.exit(0);
|
|
277
|
+
process.exit(process.exitCode || 0);
|
|
332
278
|
});
|
|
333
279
|
process.on('SIGTERM', async () => {
|
|
334
280
|
await cleanup('SIGTERM');
|
|
335
|
-
process.exit(0);
|
|
281
|
+
process.exit(process.exitCode || 0);
|
|
336
282
|
});
|
|
337
283
|
process.on('SIGHUP', async () => {
|
|
338
284
|
await cleanup('SIGHUP');
|
|
339
|
-
process.exit(0);
|
|
285
|
+
process.exit(process.exitCode || 0);
|
|
340
286
|
});
|
|
341
287
|
|
|
342
288
|
process.on('uncaughtException', async (error) => {
|
|
343
289
|
log.error('Uncaught Exception:', error);
|
|
344
290
|
await cleanup('Uncaught Exception');
|
|
291
|
+
process.exit(process.exitCode || 1);
|
|
345
292
|
});
|
|
346
293
|
|
|
347
294
|
process.on('unhandledRejection', async (reason, promise) => {
|
|
348
295
|
log.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
349
296
|
await cleanup('Unhandled Rejection');
|
|
297
|
+
process.exit(process.exitCode || 1);
|
|
350
298
|
});
|
|
351
299
|
}
|
|
352
300
|
|
|
@@ -433,18 +381,20 @@ async function main() {
|
|
|
433
381
|
.option(
|
|
434
382
|
'--tunnel-registry-port <port>',
|
|
435
383
|
`Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
|
|
436
|
-
|
|
384
|
+
(value) => parsePortOption(value, 'tunnel registry port'),
|
|
437
385
|
)
|
|
438
386
|
.option(
|
|
439
387
|
'--reconnect-retries <count>',
|
|
440
388
|
'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
|
|
441
|
-
|
|
389
|
+
(value) => parseNonNegativeIntegerOption(value, 'retry count'),
|
|
442
390
|
);
|
|
443
391
|
|
|
444
392
|
program.parse(process.argv);
|
|
445
393
|
const options = program.opts();
|
|
446
394
|
const specificUdid = options.udid ?? program.args[0] ?? undefined;
|
|
447
395
|
|
|
396
|
+
await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
|
|
397
|
+
|
|
448
398
|
if (specificUdid) {
|
|
449
399
|
log.info(
|
|
450
400
|
`Starting tunnel creation test for specific UDID: ${specificUdid}`,
|
|
@@ -476,7 +426,7 @@ async function main() {
|
|
|
476
426
|
process.exit(0);
|
|
477
427
|
}
|
|
478
428
|
|
|
479
|
-
log.info(`Found ${devices.length
|
|
429
|
+
log.info(`Found ${util.pluralize('connected device', devices.length, true)}:`);
|
|
480
430
|
devices.forEach((device, index) => {
|
|
481
431
|
log.info(` ${index + 1}. UDID: ${device.Properties.SerialNumber}`);
|
|
482
432
|
log.info(` Device ID: ${device.DeviceID}`);
|
|
@@ -506,25 +456,19 @@ async function main() {
|
|
|
506
456
|
devicesToProcess = dedupeDevicesByUdid(devicesToProcess);
|
|
507
457
|
if (devicesToProcess.length < beforeDedupe) {
|
|
508
458
|
log.info(
|
|
509
|
-
`Deduped ${beforeDedupe}
|
|
459
|
+
`Deduped ${util.pluralize('usbmux entry', beforeDedupe, true)} to ${util.pluralize('device', devicesToProcess.length, true)} (USB preferred over Network)`,
|
|
510
460
|
);
|
|
511
461
|
}
|
|
512
462
|
|
|
513
|
-
log.info(`\nProcessing ${devicesToProcess.length
|
|
463
|
+
log.info(`\nProcessing ${util.pluralize('device', devicesToProcess.length, true)}...`);
|
|
514
464
|
|
|
515
465
|
const readiness = new TunnelReadinessCoordinator();
|
|
516
|
-
const registry =
|
|
517
|
-
tunnels: {},
|
|
518
|
-
metadata: {
|
|
519
|
-
lastUpdated: new Date().toISOString(),
|
|
520
|
-
totalTunnels: 0,
|
|
521
|
-
activeTunnels: 0,
|
|
522
|
-
},
|
|
523
|
-
};
|
|
466
|
+
const registry = createEmptyTunnelRegistry();
|
|
524
467
|
|
|
525
468
|
registryServer = await startTunnelRegistryServer(registry, registryPort, {
|
|
526
469
|
readiness,
|
|
527
|
-
refreshServices:
|
|
470
|
+
refreshServices: async (udid, entry) =>
|
|
471
|
+
await refreshServiceCatalog(udid, entry, log),
|
|
528
472
|
});
|
|
529
473
|
|
|
530
474
|
const reconnectRetries = options.reconnectRetries;
|
|
@@ -560,19 +504,19 @@ async function main() {
|
|
|
560
504
|
}
|
|
561
505
|
|
|
562
506
|
if (devicesToProcess.length > 1) {
|
|
563
|
-
await
|
|
507
|
+
await sleep(1000);
|
|
564
508
|
}
|
|
565
509
|
}
|
|
566
510
|
|
|
567
511
|
log.info('\n=== TUNNEL CREATION SUMMARY ===');
|
|
568
512
|
const failed = results.filter((r) => !r.success);
|
|
569
513
|
|
|
570
|
-
log.info(`Total
|
|
571
|
-
log.info(`Successful
|
|
572
|
-
log.info(`Failed
|
|
514
|
+
log.info(`Total ${util.pluralize('device', results.length, true)} processed`);
|
|
515
|
+
log.info(`Successful ${util.pluralize('tunnel', successful.length, true)}`);
|
|
516
|
+
log.info(`Failed ${util.pluralize('tunnel', failed.length, true)}`);
|
|
573
517
|
|
|
574
518
|
if (successful.length > 0) {
|
|
575
|
-
log.info(
|
|
519
|
+
log.info(`\n✅ Successful ${util.pluralize('tunnel', successful.length)}:`);
|
|
576
520
|
log.info('\n📁 Tunnel registry API:');
|
|
577
521
|
log.info(' The tunnel registry is now available through the API at:');
|
|
578
522
|
log.info(` http://localhost:${registryPort}/remotexpc/tunnels`);
|