appium-ios-remotexpc 5.2.0 → 5.2.2
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 +12 -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 +61 -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 +71 -70
- package/scripts/start-appletv-tunnel.mjs +177 -107
- package/scripts/tunnel-creation.mjs +123 -101
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [5.2.2](https://github.com/appium/appium-ios-remotexpc/compare/v5.2.1...v5.2.2) (2026-06-24)
|
|
2
|
+
|
|
3
|
+
### Miscellaneous Chores
|
|
4
|
+
|
|
5
|
+
* Add scripts to typescript checks ([#253](https://github.com/appium/appium-ios-remotexpc/issues/253)) ([f06a206](https://github.com/appium/appium-ios-remotexpc/commit/f06a2060a41f834ca049cb31d9f453c12d9fc013))
|
|
6
|
+
|
|
7
|
+
## [5.2.1](https://github.com/appium/appium-ios-remotexpc/compare/v5.2.0...v5.2.1) (2026-06-24)
|
|
8
|
+
|
|
9
|
+
### Miscellaneous Chores
|
|
10
|
+
|
|
11
|
+
* 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))
|
|
12
|
+
|
|
1
13
|
## [5.2.0](https://github.com/appium/appium-ios-remotexpc/compare/v5.1.6...v5.2.0) (2026-06-22)
|
|
2
14
|
|
|
3
15
|
### 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,61 @@
|
|
|
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
|
+
/**
|
|
20
|
+
* @param {string} status
|
|
21
|
+
* @param {boolean} isComplete
|
|
22
|
+
*/
|
|
23
|
+
const logProgress = (status, isComplete = false) => {
|
|
24
|
+
const elapsedMs = performance.now() - startedAt;
|
|
25
|
+
const boundedElapsedMs = Math.min(elapsedMs, timeoutMs);
|
|
26
|
+
const progress = isComplete ? 1 : boundedElapsedMs / timeoutMs;
|
|
27
|
+
const filledWidth = Math.round(progress * barWidth);
|
|
28
|
+
const emptyWidth = barWidth - filledWidth;
|
|
29
|
+
const bar = `${'#'.repeat(filledWidth)}${'-'.repeat(emptyWidth)}`;
|
|
30
|
+
log.info(
|
|
31
|
+
`${label}: [${bar}]${status && status !== 'waiting' ? ` - ${status}` : ''}`,
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} status
|
|
37
|
+
* @param {boolean} isComplete
|
|
38
|
+
*/
|
|
39
|
+
const stop = (status, isComplete = false) => {
|
|
40
|
+
if (isStopped) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
isStopped = true;
|
|
44
|
+
if (timer) {
|
|
45
|
+
clearInterval(timer);
|
|
46
|
+
timer = null;
|
|
47
|
+
}
|
|
48
|
+
logProgress(status, isComplete);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
logProgress('waiting');
|
|
52
|
+
timer = setInterval(() => {
|
|
53
|
+
logProgress('waiting');
|
|
54
|
+
}, intervalMs);
|
|
55
|
+
timer.unref?.();
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
succeed: (message = 'done') => stop(message, true),
|
|
59
|
+
fail: (message = 'failed') => stop(message),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -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 & { tunnel: { Address: string, RsdPort?: number } }} 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,82 +6,40 @@
|
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('appium-ios-remotexpc').AppleTVPairingService} pairingService
|
|
25
|
+
* @param {number} timeoutMs
|
|
26
|
+
* @returns {{ startedAt: number, promise: Promise<AppleTVDevice[]> }}
|
|
27
|
+
*/
|
|
77
28
|
function discoverAppleTVPairingDevices(pairingService, timeoutMs) {
|
|
78
29
|
const startedAt = performance.now();
|
|
79
30
|
const promise = pairingService.discoverDevices({ timeoutMs });
|
|
80
31
|
return { startedAt, promise };
|
|
81
32
|
}
|
|
82
33
|
|
|
34
|
+
/**
|
|
35
|
+
*
|
|
36
|
+
* @param {{ startedAt: number, promise: Promise<AppleTVDevice[]> }} discovery
|
|
37
|
+
* @param {number} timeoutMs
|
|
38
|
+
* @returns {Promise<AppleTVDevice[]>}
|
|
39
|
+
*/
|
|
83
40
|
async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
|
|
84
41
|
const progress = startTimeoutProgressLogger({
|
|
42
|
+
log,
|
|
85
43
|
label: 'Waiting for Apple TV pairing discovery',
|
|
86
44
|
startedAt: discovery.startedAt,
|
|
87
45
|
timeoutMs,
|
|
@@ -92,7 +50,7 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
|
|
|
92
50
|
try {
|
|
93
51
|
const devices = await discovery.promise;
|
|
94
52
|
progress.succeed(
|
|
95
|
-
`Apple TV pairing discovery completed: ${devices.length
|
|
53
|
+
`Apple TV pairing discovery completed: ${util.pluralize('device', devices.length, true)} found`,
|
|
96
54
|
);
|
|
97
55
|
return devices;
|
|
98
56
|
} catch (err) {
|
|
@@ -101,6 +59,50 @@ async function waitForAppleTVPairingDiscovery(discovery, timeoutMs) {
|
|
|
101
59
|
}
|
|
102
60
|
}
|
|
103
61
|
|
|
62
|
+
/**
|
|
63
|
+
* @param {import('appium-ios-remotexpc').AppleTVPairingService} pairingService
|
|
64
|
+
* @param {string} deviceSelector
|
|
65
|
+
* @param {number} discoveryTimeoutMs
|
|
66
|
+
* @returns {Promise<{ success: boolean, deviceId: string, pairingFile?: string, error?: Error | null }>}
|
|
67
|
+
*/
|
|
68
|
+
async function discoverAndPairWithProgress(
|
|
69
|
+
pairingService,
|
|
70
|
+
deviceSelector,
|
|
71
|
+
discoveryTimeoutMs,
|
|
72
|
+
) {
|
|
73
|
+
const discovery = discoverAppleTVPairingDevices(
|
|
74
|
+
pairingService,
|
|
75
|
+
discoveryTimeoutMs,
|
|
76
|
+
);
|
|
77
|
+
const devices = await waitForAppleTVPairingDiscovery(
|
|
78
|
+
discovery,
|
|
79
|
+
discoveryTimeoutMs,
|
|
80
|
+
);
|
|
81
|
+
return await pairingService.discoverAndPair(deviceSelector, {
|
|
82
|
+
devices,
|
|
83
|
+
discoveryTimeoutMs,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {unknown} err
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function isNoAppleTVPairingDevicesFoundError(err) {
|
|
92
|
+
return (
|
|
93
|
+
err instanceof Error &&
|
|
94
|
+
(err.message === getNoAppleTVPairingDevicesMessage() ||
|
|
95
|
+
('code' in err && err.code === 'NO_DEVICES'))
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function getNoAppleTVPairingDevicesMessage() {
|
|
103
|
+
return 'No Apple TV pairing devices found. Please ensure your Apple TV is on the same network and in pairing mode.';
|
|
104
|
+
}
|
|
105
|
+
|
|
104
106
|
async function main() {
|
|
105
107
|
const program = new Command();
|
|
106
108
|
program
|
|
@@ -113,34 +115,33 @@ async function main() {
|
|
|
113
115
|
.option(
|
|
114
116
|
'--discovery-timeout <ms>',
|
|
115
117
|
'Apple TV pairing discovery timeout in milliseconds',
|
|
116
|
-
|
|
118
|
+
(value) => parsePositiveIntegerOption(value, 'discovery timeout'),
|
|
117
119
|
);
|
|
118
120
|
|
|
119
121
|
program.parse(process.argv);
|
|
120
122
|
const options = program.opts();
|
|
121
123
|
const discoveryTimeoutMs =
|
|
122
|
-
options.discoveryTimeout ??
|
|
124
|
+
options.discoveryTimeout ?? DEFAULT_APPLETV_PAIRING_DISCOVERY_TIMEOUT_MS;
|
|
123
125
|
|
|
124
126
|
const userInput = new UserInputService();
|
|
125
127
|
const pairingService = new AppleTVPairingService(userInput);
|
|
126
|
-
const
|
|
128
|
+
const result = await discoverAndPairWithProgress(
|
|
127
129
|
pairingService,
|
|
130
|
+
options.device,
|
|
128
131
|
discoveryTimeoutMs,
|
|
129
132
|
);
|
|
130
|
-
const devices = await waitForAppleTVPairingDiscovery(
|
|
131
|
-
discovery,
|
|
132
|
-
discoveryTimeoutMs,
|
|
133
|
-
);
|
|
134
|
-
const result = await pairingService.discoverAndPair(options.device, {
|
|
135
|
-
devices,
|
|
136
|
-
discoveryTimeoutMs,
|
|
137
|
-
});
|
|
138
133
|
|
|
139
134
|
if (result.success) {
|
|
140
135
|
log.info(`Pairing successful! Record saved to: ${result.pairingFile}`);
|
|
136
|
+
} else if (isNoAppleTVPairingDevicesFoundError(result.error)) {
|
|
137
|
+
log.info(getNoAppleTVPairingDevicesMessage());
|
|
141
138
|
} else {
|
|
142
139
|
throw result.error ?? new Error('Pairing failed');
|
|
143
140
|
}
|
|
144
141
|
}
|
|
145
142
|
|
|
146
143
|
await main();
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @typedef {import('appium-ios-remotexpc').AppleTVDevice} AppleTVDevice
|
|
147
|
+
*/
|
|
@@ -11,18 +11,35 @@ 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
|
|
|
42
|
+
/** @type {(() => void)[]} */
|
|
26
43
|
const registryWatcherStops = [];
|
|
27
44
|
/** @type {Map<string, Promise<void>>} */
|
|
28
45
|
const reconnectingByUdid = new Map();
|
|
@@ -32,6 +49,10 @@ const establishedTunnelsByUdid = new Map();
|
|
|
32
49
|
/** @type {Map<string, () => void>} */
|
|
33
50
|
const lifecycleWatchStopByUdid = new Map();
|
|
34
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
|
|
54
|
+
* @returns {Promise<void>}
|
|
55
|
+
*/
|
|
35
56
|
async function closeTunnelQuietly(tunnelConnection) {
|
|
36
57
|
try {
|
|
37
58
|
await tunnelConnection.closer();
|
|
@@ -40,6 +61,10 @@ async function closeTunnelQuietly(tunnelConnection) {
|
|
|
40
61
|
}
|
|
41
62
|
}
|
|
42
63
|
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} udid
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
43
68
|
function stopLifecycleWatch(udid) {
|
|
44
69
|
const stop = lifecycleWatchStopByUdid.get(udid);
|
|
45
70
|
if (stop) {
|
|
@@ -48,6 +73,58 @@ function stopLifecycleWatch(udid) {
|
|
|
48
73
|
}
|
|
49
74
|
}
|
|
50
75
|
|
|
76
|
+
/**
|
|
77
|
+
*
|
|
78
|
+
* @param {import('appium-ios-remotexpc').AppleTVTunnelService} tunnelService
|
|
79
|
+
* @param {number} timeoutMs
|
|
80
|
+
* @returns {{ startedAt: number, promise: Promise<AppleTVDevice[]> }}
|
|
81
|
+
*/
|
|
82
|
+
function startDevicesDiscovery(tunnelService, timeoutMs) {
|
|
83
|
+
const startedAt = performance.now();
|
|
84
|
+
const promise = tunnelService.discoverDevices({ timeoutMs });
|
|
85
|
+
return { startedAt, promise };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
*
|
|
90
|
+
* @param {{ startedAt: number, promise: Promise<AppleTVDevice[]> }} discovery
|
|
91
|
+
* @param {number} timeoutMs
|
|
92
|
+
* @returns {Promise<AppleTVDevice[]>}
|
|
93
|
+
*/
|
|
94
|
+
async function waitForDevicesDiscovery(discovery, timeoutMs) {
|
|
95
|
+
const progress = startTimeoutProgressLogger({
|
|
96
|
+
log,
|
|
97
|
+
label: 'Waiting for wireless Apple TV devices discovery',
|
|
98
|
+
startedAt: discovery.startedAt,
|
|
99
|
+
timeoutMs,
|
|
100
|
+
barWidth: APPLETV_TUNNEL_DISCOVERY_PROGRESS_BAR_WIDTH,
|
|
101
|
+
intervalMs: APPLETV_TUNNEL_DISCOVERY_PROGRESS_INTERVAL_MS,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const devices = await discovery.promise;
|
|
106
|
+
progress.succeed(
|
|
107
|
+
`Wireless Apple TV devices discovery completed: ${util.pluralize('device', devices.length, true)} found`,
|
|
108
|
+
);
|
|
109
|
+
return devices;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
progress.fail('Wireless Apple TV devices discovery failed');
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {unknown} err
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
function isNoDevicesFoundError(err) {
|
|
121
|
+
return err instanceof Error && ('code' in err && err.code === 'NO_DEVICES') || /** @type {Error} */ (err).message?.includes('No devices found');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {AppleTvEstablishedTunnel} result
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
51
128
|
function registerEstablishedTunnel(result) {
|
|
52
129
|
const udid = result.device.identifier;
|
|
53
130
|
stopLifecycleWatch(udid);
|
|
@@ -61,41 +138,15 @@ function registerEstablishedTunnel(result) {
|
|
|
61
138
|
}
|
|
62
139
|
establishedTunnelsByUdid.set(udid, result);
|
|
63
140
|
}
|
|
64
|
-
let tunnelService = null;
|
|
65
|
-
|
|
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
141
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
}
|
|
142
|
+
/** @type {import('appium-ios-remotexpc').AppleTVTunnelService | null} */
|
|
143
|
+
let tunnelService = null;
|
|
95
144
|
|
|
96
145
|
/**
|
|
97
|
-
* @param {
|
|
146
|
+
* @param {import('appium-ios-remotexpc').TunnelRegistry} registry
|
|
98
147
|
* @param {AppleTvEstablishedTunnel[]} successfulResults
|
|
148
|
+
* @param {object} callbacks
|
|
149
|
+
* @param {function({ udid: string, address: string }): Promise<void>} [callbacks.onTunnelDead]
|
|
99
150
|
*/
|
|
100
151
|
function attachAppleTvTunnelRegistryLifecycleWatch(registry, successfulResults, callbacks = {}) {
|
|
101
152
|
for (const result of successfulResults) {
|
|
@@ -129,7 +180,14 @@ function attachAppleTvTunnelRegistryLifecycleWatch(registry, successfulResults,
|
|
|
129
180
|
);
|
|
130
181
|
}
|
|
131
182
|
|
|
183
|
+
/**
|
|
184
|
+
* @returns {void}
|
|
185
|
+
*/
|
|
132
186
|
function setupCleanupHandlers() {
|
|
187
|
+
/**
|
|
188
|
+
* @param {string} signal
|
|
189
|
+
* @returns {Promise<void>}
|
|
190
|
+
*/
|
|
133
191
|
const cleanup = async (signal) => {
|
|
134
192
|
log.warn(`\nCleaning up (${signal})...`);
|
|
135
193
|
|
|
@@ -166,25 +224,27 @@ function setupCleanupHandlers() {
|
|
|
166
224
|
|
|
167
225
|
process.on('SIGINT', async () => {
|
|
168
226
|
await cleanup('SIGINT (Ctrl+C)');
|
|
169
|
-
process.exit(0);
|
|
227
|
+
process.exit(process.exitCode || 0);
|
|
170
228
|
});
|
|
171
229
|
process.on('SIGTERM', async () => {
|
|
172
230
|
await cleanup('SIGTERM');
|
|
173
|
-
process.exit(0);
|
|
231
|
+
process.exit(process.exitCode || 0);
|
|
174
232
|
});
|
|
175
233
|
process.on('SIGHUP', async () => {
|
|
176
234
|
await cleanup('SIGHUP');
|
|
177
|
-
process.exit(0);
|
|
235
|
+
process.exit(process.exitCode || 0);
|
|
178
236
|
});
|
|
179
237
|
|
|
180
238
|
process.on('uncaughtException', async (error) => {
|
|
181
239
|
log.error('Uncaught Exception:', error);
|
|
182
240
|
await cleanup('Uncaught Exception');
|
|
241
|
+
process.exit(process.exitCode || 1);
|
|
183
242
|
});
|
|
184
243
|
|
|
185
244
|
process.on('unhandledRejection', async (reason, promise) => {
|
|
186
245
|
log.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
187
246
|
await cleanup('Unhandled Rejection');
|
|
247
|
+
process.exit(process.exitCode || 1);
|
|
188
248
|
});
|
|
189
249
|
}
|
|
190
250
|
|
|
@@ -194,6 +254,7 @@ function setupCleanupHandlers() {
|
|
|
194
254
|
*/
|
|
195
255
|
async function establishOneTunnel(startResult) {
|
|
196
256
|
const { tcpSocket, psk, device } = startResult;
|
|
257
|
+
/** @type {{ notify: ((reason: string) => void) | null }} */
|
|
197
258
|
const lifecycle = { notify: null };
|
|
198
259
|
|
|
199
260
|
const tunnelConnection = await TunnelManager.getTunnelPsk(
|
|
@@ -211,6 +272,10 @@ async function establishOneTunnel(startResult) {
|
|
|
211
272
|
RsdPort: tunnelConnection.RsdPort,
|
|
212
273
|
},
|
|
213
274
|
tunnelConnection,
|
|
275
|
+
/**
|
|
276
|
+
* @param {(reason: string) => void} handler
|
|
277
|
+
* @returns {void}
|
|
278
|
+
*/
|
|
214
279
|
registerOnDead: (handler) => {
|
|
215
280
|
lifecycle.notify = handler;
|
|
216
281
|
},
|
|
@@ -220,21 +285,14 @@ async function establishOneTunnel(startResult) {
|
|
|
220
285
|
return result;
|
|
221
286
|
}
|
|
222
287
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
catalogUpdatedAt: now,
|
|
231
|
-
lastUpdated: now,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function buildAppleTvTunnelEntryBase(result, existing) {
|
|
288
|
+
/**
|
|
289
|
+
* @param {AppleTvEstablishedTunnel} result
|
|
290
|
+
* @param {import('appium-ios-remotexpc').TunnelRegistryEntry | undefined} existing
|
|
291
|
+
* @param {number} now
|
|
292
|
+
* @returns {import('appium-ios-remotexpc').TunnelRegistryEntry}
|
|
293
|
+
*/
|
|
294
|
+
function buildAppleTvTunnelEntry(result, existing, now) {
|
|
236
295
|
const udid = result.device.identifier;
|
|
237
|
-
const now = Date.now();
|
|
238
296
|
const entry = {
|
|
239
297
|
udid,
|
|
240
298
|
deviceId: 0,
|
|
@@ -249,47 +307,31 @@ function buildAppleTvTunnelEntryBase(result, existing) {
|
|
|
249
307
|
return entry;
|
|
250
308
|
}
|
|
251
309
|
|
|
310
|
+
/**
|
|
311
|
+
*
|
|
312
|
+
* @param {AppleTvEstablishedTunnel} result
|
|
313
|
+
* @returns {Promise<boolean>}
|
|
314
|
+
*/
|
|
252
315
|
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));
|
|
316
|
+
return await publishTunnelRegistryEntry({
|
|
317
|
+
registryServer,
|
|
318
|
+
result,
|
|
319
|
+
getUdid: (r) => r.device.identifier,
|
|
320
|
+
buildEntry: buildAppleTvTunnelEntry,
|
|
321
|
+
log,
|
|
322
|
+
});
|
|
291
323
|
}
|
|
292
324
|
|
|
325
|
+
/**
|
|
326
|
+
*
|
|
327
|
+
* @param {object} opts
|
|
328
|
+
* @param {string} opts.udid
|
|
329
|
+
* @param {number} opts.maxRetries
|
|
330
|
+
* @param {import('appium-ios-remotexpc').AppleTVTunnelService} opts.tunnelService
|
|
331
|
+
* @param {function(string): Promise<void>} opts.reconnectTunnelByUdid
|
|
332
|
+
* @param {number} opts.discoveryTimeoutMs
|
|
333
|
+
* @returns {Promise<void>}
|
|
334
|
+
*/
|
|
293
335
|
async function runAppleTvReconnectAttempts({
|
|
294
336
|
udid,
|
|
295
337
|
maxRetries,
|
|
@@ -335,6 +377,14 @@ async function runAppleTvReconnectAttempts({
|
|
|
335
377
|
log.error(`Reconnect retries exhausted for ${udid}`);
|
|
336
378
|
}
|
|
337
379
|
|
|
380
|
+
/**
|
|
381
|
+
*
|
|
382
|
+
* @param {object} opts
|
|
383
|
+
* @param {number} opts.reconnectRetries
|
|
384
|
+
* @param {import('appium-ios-remotexpc').AppleTVTunnelService} opts.tunnelService
|
|
385
|
+
* @param {number} opts.discoveryTimeoutMs
|
|
386
|
+
* @returns {function(string): Promise<void>}
|
|
387
|
+
*/
|
|
338
388
|
function createAppleTvReconnectTunnelByUdid({
|
|
339
389
|
reconnectRetries,
|
|
340
390
|
tunnelService,
|
|
@@ -382,17 +432,17 @@ async function main() {
|
|
|
382
432
|
.option(
|
|
383
433
|
'--tunnel-registry-port <port>',
|
|
384
434
|
`Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
|
|
385
|
-
|
|
435
|
+
(value) => parsePortOption(value, 'tunnel registry port'),
|
|
386
436
|
)
|
|
387
437
|
.option(
|
|
388
438
|
'--reconnect-retries <count>',
|
|
389
439
|
'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
|
|
390
|
-
|
|
440
|
+
(value) => parseNonNegativeIntegerOption(value, 'retry count'),
|
|
391
441
|
)
|
|
392
442
|
.option(
|
|
393
443
|
'--discovery-timeout <ms>',
|
|
394
444
|
'Apple TV discovery timeout in milliseconds',
|
|
395
|
-
|
|
445
|
+
(value) => parsePositiveIntegerOption(value, 'discovery timeout'),
|
|
396
446
|
);
|
|
397
447
|
|
|
398
448
|
program.parse(process.argv);
|
|
@@ -401,6 +451,8 @@ async function main() {
|
|
|
401
451
|
const registryPort =
|
|
402
452
|
options.tunnelRegistryPort ?? DEFAULT_TUNNEL_REGISTRY_PORT;
|
|
403
453
|
|
|
454
|
+
await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
|
|
455
|
+
|
|
404
456
|
if (deviceIdentifier) {
|
|
405
457
|
log.info(`Targeting a single Apple TV: ${deviceIdentifier}`);
|
|
406
458
|
} else {
|
|
@@ -413,18 +465,12 @@ async function main() {
|
|
|
413
465
|
|
|
414
466
|
try {
|
|
415
467
|
const readiness = new TunnelReadinessCoordinator();
|
|
416
|
-
const registry =
|
|
417
|
-
tunnels: {},
|
|
418
|
-
metadata: {
|
|
419
|
-
lastUpdated: new Date().toISOString(),
|
|
420
|
-
totalTunnels: 0,
|
|
421
|
-
activeTunnels: 0,
|
|
422
|
-
},
|
|
423
|
-
};
|
|
468
|
+
const registry = createEmptyTunnelRegistry();
|
|
424
469
|
|
|
425
470
|
registryServer = await startTunnelRegistryServer(registry, registryPort, {
|
|
426
471
|
readiness,
|
|
427
|
-
refreshServices:
|
|
472
|
+
refreshServices: async (udid, entry) =>
|
|
473
|
+
await refreshServiceCatalog(udid, entry, log),
|
|
428
474
|
});
|
|
429
475
|
|
|
430
476
|
const reconnectTunnelByUdid = createAppleTvReconnectTunnelByUdid({
|
|
@@ -460,11 +506,27 @@ async function main() {
|
|
|
460
506
|
},
|
|
461
507
|
);
|
|
462
508
|
} else {
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
509
|
+
const discoveryTimeoutMs = options.discoveryTimeout ?? DEFAULT_WIRELESS_APPLETV_DISCOVERY_TIMEOUT_MS;
|
|
510
|
+
const discovery = startDevicesDiscovery(
|
|
511
|
+
tunnelService,
|
|
512
|
+
discoveryTimeoutMs,
|
|
513
|
+
);
|
|
514
|
+
/** @type {AppleTVDevice[]} */
|
|
515
|
+
let devices = [];
|
|
516
|
+
try {
|
|
517
|
+
devices = await waitForDevicesDiscovery(
|
|
518
|
+
discovery,
|
|
519
|
+
discoveryTimeoutMs,
|
|
520
|
+
);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (isNoDevicesFoundError(err)) {
|
|
523
|
+
log.info('No wireless Apple TV devices found');
|
|
524
|
+
process.exit(process.exitCode || 0);
|
|
525
|
+
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
466
528
|
log.info(
|
|
467
|
-
`Discovered ${
|
|
529
|
+
`Discovered ${util.pluralize('Apple TV device', devices.length, true)}; establishing ${util.pluralize('tunnel', devices.length)}...`,
|
|
468
530
|
);
|
|
469
531
|
|
|
470
532
|
for (let i = 0; i < devices.length; i++) {
|
|
@@ -536,8 +598,12 @@ async function main() {
|
|
|
536
598
|
);
|
|
537
599
|
}
|
|
538
600
|
|
|
539
|
-
log.info(
|
|
540
|
-
|
|
601
|
+
log.info(
|
|
602
|
+
`\n✅ ${registryPublished.length} Apple TV ${util.pluralize('tunnel', registryPublished.length)} ready.`,
|
|
603
|
+
);
|
|
604
|
+
log.info(
|
|
605
|
+
`\nPress Ctrl+C to close ${util.pluralize('tunnel', registryPublished.length)} and exit.`,
|
|
606
|
+
);
|
|
541
607
|
|
|
542
608
|
process.stdin.resume();
|
|
543
609
|
} catch (error) {
|
|
@@ -557,3 +623,7 @@ await main();
|
|
|
557
623
|
* @property {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
|
|
558
624
|
* @property {(handler: (reason: string) => void) => void} registerOnDead
|
|
559
625
|
*/
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* @typedef {import('appium-ios-remotexpc').AppleTVDevice} AppleTVDevice
|
|
629
|
+
*/
|
|
@@ -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
|
|
|
@@ -30,6 +40,10 @@ const establishedTunnelsByUdid = new Map();
|
|
|
30
40
|
/** @type {Map<string, () => void>} */
|
|
31
41
|
const lifecycleWatchStopByUdid = new Map();
|
|
32
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
|
|
45
|
+
* @returns {Promise<void>}
|
|
46
|
+
*/
|
|
33
47
|
async function closeTunnelQuietly(tunnelConnection) {
|
|
34
48
|
try {
|
|
35
49
|
await tunnelConnection.closer();
|
|
@@ -38,6 +52,10 @@ async function closeTunnelQuietly(tunnelConnection) {
|
|
|
38
52
|
}
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} udid
|
|
57
|
+
* @returns {void}
|
|
58
|
+
*/
|
|
41
59
|
function stopLifecycleWatch(udid) {
|
|
42
60
|
const stop = lifecycleWatchStopByUdid.get(udid);
|
|
43
61
|
if (stop) {
|
|
@@ -46,6 +64,10 @@ function stopLifecycleWatch(udid) {
|
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
/**
|
|
68
|
+
* @param {TunnelCreationSuccessResult} result
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
49
71
|
function registerEstablishedTunnel(result) {
|
|
50
72
|
const udid = result.device.Properties.SerialNumber;
|
|
51
73
|
stopLifecycleWatch(udid);
|
|
@@ -60,6 +82,11 @@ function registerEstablishedTunnel(result) {
|
|
|
60
82
|
establishedTunnelsByUdid.set(udid, result);
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
/**
|
|
86
|
+
*
|
|
87
|
+
* @param {string} connectionType
|
|
88
|
+
* @returns {number}
|
|
89
|
+
*/
|
|
63
90
|
function connectionTypeRank(connectionType) {
|
|
64
91
|
if (connectionType === 'USB') {
|
|
65
92
|
return 0;
|
|
@@ -94,48 +121,25 @@ function dedupeDevicesByUdid(devices) {
|
|
|
94
121
|
return [...byUdid.values()];
|
|
95
122
|
}
|
|
96
123
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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) {
|
|
124
|
+
/**
|
|
125
|
+
*
|
|
126
|
+
* @param {TunnelCreationSuccessResult} result
|
|
127
|
+
* @param {import('appium-ios-remotexpc').TunnelRegistryEntry | undefined} existing
|
|
128
|
+
* @param {number} now
|
|
129
|
+
* @returns {import('appium-ios-remotexpc').TunnelRegistryEntry}
|
|
130
|
+
*/
|
|
131
|
+
function buildTunnelEntry(result, existing, now) {
|
|
130
132
|
const udid = result.device.Properties.SerialNumber;
|
|
131
|
-
|
|
133
|
+
/** @type {import('appium-ios-remotexpc').TunnelRegistryEntry} */
|
|
132
134
|
const entry = {
|
|
133
135
|
udid,
|
|
134
136
|
deviceId: result.device.DeviceID,
|
|
135
137
|
address: result.tunnel.Address,
|
|
136
138
|
rsdPort: result.tunnel.RsdPort,
|
|
137
139
|
services: {},
|
|
140
|
+
// @ts-expect-error - connectionType is not typed
|
|
138
141
|
connectionType: result.device.Properties.ConnectionType,
|
|
142
|
+
// @ts-expect-error - productId is not typed
|
|
139
143
|
productId: result.device.Properties.ProductID,
|
|
140
144
|
createdAt: existing?.createdAt ?? now,
|
|
141
145
|
lastUpdated: now,
|
|
@@ -143,43 +147,21 @@ function buildTunnelEntryBase(result, existing) {
|
|
|
143
147
|
return entry;
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
/**
|
|
151
|
+
* @param {TunnelCreationSuccessResult} result
|
|
152
|
+
* @returns {Promise<boolean>}
|
|
153
|
+
*/
|
|
146
154
|
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;
|
|
155
|
+
return await publishTunnelRegistryEntry({
|
|
156
|
+
registryServer,
|
|
157
|
+
result,
|
|
158
|
+
getUdid: (r) => r.device.Properties.SerialNumber,
|
|
159
|
+
buildEntry: buildTunnelEntry,
|
|
160
|
+
log,
|
|
161
|
+
});
|
|
181
162
|
}
|
|
182
163
|
|
|
164
|
+
/** @type {(() => void)[]} */
|
|
183
165
|
const registryWatcherStops = [];
|
|
184
166
|
/** @type {Map<string, Promise<void>>} */
|
|
185
167
|
const reconnectingByUdid = new Map();
|
|
@@ -187,8 +169,11 @@ const reconnectingByUdid = new Map();
|
|
|
187
169
|
/**
|
|
188
170
|
* When the native forwarder exits, drop the UDID from the HTTP registry and tear down state.
|
|
189
171
|
*
|
|
190
|
-
* @param {
|
|
172
|
+
* @param {import('appium-ios-remotexpc').TunnelRegistry} registry
|
|
191
173
|
* @param {TunnelCreationSuccessResult[]} successful
|
|
174
|
+
* @param {object} callbacks
|
|
175
|
+
* @param {function({ udid: string, address: string }): Promise<void>} [callbacks.onTunnelDead]
|
|
176
|
+
* @returns {void}
|
|
192
177
|
*/
|
|
193
178
|
function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}) {
|
|
194
179
|
for (const result of successful) {
|
|
@@ -222,10 +207,15 @@ function attachTunnelRegistryLifecycleWatch(registry, successful, callbacks = {}
|
|
|
222
207
|
);
|
|
223
208
|
}
|
|
224
209
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
210
|
+
/**
|
|
211
|
+
*
|
|
212
|
+
* @param {object} opts
|
|
213
|
+
* @param {string} opts.udid
|
|
214
|
+
* @param {number} opts.maxRetries
|
|
215
|
+
* @param {import('appium-ios-remotexpc').UsbmuxDevice} opts.device
|
|
216
|
+
* @param {function(string): Promise<void>} opts.reconnectTunnelByUdid
|
|
217
|
+
* @returns {Promise<void>}
|
|
218
|
+
*/
|
|
229
219
|
async function runReconnectAttempts({
|
|
230
220
|
udid,
|
|
231
221
|
maxRetries,
|
|
@@ -247,6 +237,7 @@ async function runReconnectAttempts({
|
|
|
247
237
|
registryServer.getRegistry(),
|
|
248
238
|
[result],
|
|
249
239
|
{
|
|
240
|
+
|
|
250
241
|
onTunnelDead: async ({ udid: droppedUdid }) => {
|
|
251
242
|
await reconnectTunnelByUdid(droppedUdid);
|
|
252
243
|
},
|
|
@@ -265,6 +256,12 @@ async function runReconnectAttempts({
|
|
|
265
256
|
log.error(`Reconnect retries exhausted for ${udid}`);
|
|
266
257
|
}
|
|
267
258
|
|
|
259
|
+
/**
|
|
260
|
+
* @param {object} opts
|
|
261
|
+
* @param {number} opts.reconnectRetries
|
|
262
|
+
* @param {Map<string, import('appium-ios-remotexpc').UsbmuxDevice>} opts.devicesByUdid
|
|
263
|
+
* @returns {function(string): Promise<void>}
|
|
264
|
+
*/
|
|
268
265
|
function createReconnectTunnelByUdid({
|
|
269
266
|
reconnectRetries,
|
|
270
267
|
devicesByUdid,
|
|
@@ -299,7 +296,14 @@ function createReconnectTunnelByUdid({
|
|
|
299
296
|
};
|
|
300
297
|
}
|
|
301
298
|
|
|
299
|
+
/**
|
|
300
|
+
* @returns {void}
|
|
301
|
+
*/
|
|
302
302
|
function setupCleanupHandlers() {
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} signal
|
|
305
|
+
* @returns {Promise<void>}
|
|
306
|
+
*/
|
|
303
307
|
const cleanup = async (signal) => {
|
|
304
308
|
log.warn(`\nCleaning up (${signal})...`);
|
|
305
309
|
|
|
@@ -328,28 +332,34 @@ function setupCleanupHandlers() {
|
|
|
328
332
|
|
|
329
333
|
process.on('SIGINT', async () => {
|
|
330
334
|
await cleanup('SIGINT (Ctrl+C)');
|
|
331
|
-
process.exit(0);
|
|
335
|
+
process.exit(process.exitCode || 0);
|
|
332
336
|
});
|
|
333
337
|
process.on('SIGTERM', async () => {
|
|
334
338
|
await cleanup('SIGTERM');
|
|
335
|
-
process.exit(0);
|
|
339
|
+
process.exit(process.exitCode || 0);
|
|
336
340
|
});
|
|
337
341
|
process.on('SIGHUP', async () => {
|
|
338
342
|
await cleanup('SIGHUP');
|
|
339
|
-
process.exit(0);
|
|
343
|
+
process.exit(process.exitCode || 0);
|
|
340
344
|
});
|
|
341
345
|
|
|
342
346
|
process.on('uncaughtException', async (error) => {
|
|
343
347
|
log.error('Uncaught Exception:', error);
|
|
344
348
|
await cleanup('Uncaught Exception');
|
|
349
|
+
process.exit(process.exitCode || 1);
|
|
345
350
|
});
|
|
346
351
|
|
|
347
352
|
process.on('unhandledRejection', async (reason, promise) => {
|
|
348
353
|
log.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
349
354
|
await cleanup('Unhandled Rejection');
|
|
355
|
+
process.exit(process.exitCode || 1);
|
|
350
356
|
});
|
|
351
357
|
}
|
|
352
358
|
|
|
359
|
+
/**
|
|
360
|
+
* @param {import('appium-ios-remotexpc').UsbmuxDevice} device
|
|
361
|
+
* @returns {Promise<TunnelCreationSuccessResult>}
|
|
362
|
+
*/
|
|
353
363
|
async function createTunnelForDevice(device) {
|
|
354
364
|
const udid = device.Properties.SerialNumber;
|
|
355
365
|
|
|
@@ -375,6 +385,7 @@ async function createTunnelForDevice(device) {
|
|
|
375
385
|
log.info('CoreDeviceProxy started successfully');
|
|
376
386
|
|
|
377
387
|
log.info(`Creating tunnel...`);
|
|
388
|
+
/** @type {{ notify: ((reason: string) => void) | null }} */
|
|
378
389
|
const lifecycle = { notify: null };
|
|
379
390
|
const tunnelConnection = await TunnelManager.getTunnel(
|
|
380
391
|
socket,
|
|
@@ -391,7 +402,9 @@ async function createTunnelForDevice(device) {
|
|
|
391
402
|
log.info(` Tunnel Address: ${tunnelConnection.Address}`);
|
|
392
403
|
log.info(` Tunnel RsdPort: ${tunnelConnection.RsdPort}`);
|
|
393
404
|
|
|
405
|
+
/** @type {TunnelCreationSuccessResult} */
|
|
394
406
|
const result = {
|
|
407
|
+
// @ts-expect-error - device is not typed
|
|
395
408
|
device,
|
|
396
409
|
tunnel: {
|
|
397
410
|
Address: tunnelConnection.Address,
|
|
@@ -399,6 +412,10 @@ async function createTunnelForDevice(device) {
|
|
|
399
412
|
},
|
|
400
413
|
success: true,
|
|
401
414
|
tunnelConnection,
|
|
415
|
+
/**
|
|
416
|
+
* @param {(reason: string) => void} handler
|
|
417
|
+
* @returns {void}
|
|
418
|
+
*/
|
|
402
419
|
registerOnDead: (handler) => {
|
|
403
420
|
lifecycle.notify = handler;
|
|
404
421
|
},
|
|
@@ -410,6 +427,7 @@ async function createTunnelForDevice(device) {
|
|
|
410
427
|
const errorMessage = `Failed to create tunnel for device ${udid}: ${error}`;
|
|
411
428
|
log.error(`❌ ${errorMessage}`);
|
|
412
429
|
return {
|
|
430
|
+
// @ts-expect-error - device is not typed
|
|
413
431
|
device,
|
|
414
432
|
tunnel: { Address: '', RsdPort: 0 },
|
|
415
433
|
success: false,
|
|
@@ -418,6 +436,9 @@ async function createTunnelForDevice(device) {
|
|
|
418
436
|
}
|
|
419
437
|
}
|
|
420
438
|
|
|
439
|
+
/**
|
|
440
|
+
* @returns {Promise<void>}
|
|
441
|
+
*/
|
|
421
442
|
async function main() {
|
|
422
443
|
setupCleanupHandlers();
|
|
423
444
|
|
|
@@ -433,18 +454,20 @@ async function main() {
|
|
|
433
454
|
.option(
|
|
434
455
|
'--tunnel-registry-port <port>',
|
|
435
456
|
`Port for tunnel registry API (default: ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
|
|
436
|
-
|
|
457
|
+
(value) => parsePortOption(value, 'tunnel registry port'),
|
|
437
458
|
)
|
|
438
459
|
.option(
|
|
439
460
|
'--reconnect-retries <count>',
|
|
440
461
|
'Reconnect retries after unexpected tunnel drop (0 = unlimited)',
|
|
441
|
-
|
|
462
|
+
(value) => parseNonNegativeIntegerOption(value, 'retry count'),
|
|
442
463
|
);
|
|
443
464
|
|
|
444
465
|
program.parse(process.argv);
|
|
445
466
|
const options = program.opts();
|
|
446
467
|
const specificUdid = options.udid ?? program.args[0] ?? undefined;
|
|
447
468
|
|
|
469
|
+
await assertRoot(path.join('scripts', path.basename(fileURLToPath(import.meta.url))));
|
|
470
|
+
|
|
448
471
|
if (specificUdid) {
|
|
449
472
|
log.info(
|
|
450
473
|
`Starting tunnel creation test for specific UDID: ${specificUdid}`,
|
|
@@ -476,7 +499,7 @@ async function main() {
|
|
|
476
499
|
process.exit(0);
|
|
477
500
|
}
|
|
478
501
|
|
|
479
|
-
log.info(`Found ${devices.length
|
|
502
|
+
log.info(`Found ${util.pluralize('connected device', devices.length, true)}:`);
|
|
480
503
|
devices.forEach((device, index) => {
|
|
481
504
|
log.info(` ${index + 1}. UDID: ${device.Properties.SerialNumber}`);
|
|
482
505
|
log.info(` Device ID: ${device.DeviceID}`);
|
|
@@ -506,25 +529,19 @@ async function main() {
|
|
|
506
529
|
devicesToProcess = dedupeDevicesByUdid(devicesToProcess);
|
|
507
530
|
if (devicesToProcess.length < beforeDedupe) {
|
|
508
531
|
log.info(
|
|
509
|
-
`Deduped ${beforeDedupe}
|
|
532
|
+
`Deduped ${util.pluralize('usbmux entry', beforeDedupe, true)} to ${util.pluralize('device', devicesToProcess.length, true)} (USB preferred over Network)`,
|
|
510
533
|
);
|
|
511
534
|
}
|
|
512
535
|
|
|
513
|
-
log.info(`\nProcessing ${devicesToProcess.length
|
|
536
|
+
log.info(`\nProcessing ${util.pluralize('device', devicesToProcess.length, true)}...`);
|
|
514
537
|
|
|
515
538
|
const readiness = new TunnelReadinessCoordinator();
|
|
516
|
-
const registry =
|
|
517
|
-
tunnels: {},
|
|
518
|
-
metadata: {
|
|
519
|
-
lastUpdated: new Date().toISOString(),
|
|
520
|
-
totalTunnels: 0,
|
|
521
|
-
activeTunnels: 0,
|
|
522
|
-
},
|
|
523
|
-
};
|
|
539
|
+
const registry = createEmptyTunnelRegistry();
|
|
524
540
|
|
|
525
541
|
registryServer = await startTunnelRegistryServer(registry, registryPort, {
|
|
526
542
|
readiness,
|
|
527
|
-
refreshServices:
|
|
543
|
+
refreshServices: async (udid, entry) =>
|
|
544
|
+
await refreshServiceCatalog(udid, entry, log),
|
|
528
545
|
});
|
|
529
546
|
|
|
530
547
|
const reconnectRetries = options.reconnectRetries;
|
|
@@ -548,6 +565,10 @@ async function main() {
|
|
|
548
565
|
registryServer.getRegistry(),
|
|
549
566
|
[result],
|
|
550
567
|
{
|
|
568
|
+
/**
|
|
569
|
+
* @param {{ udid: string }} ctx
|
|
570
|
+
* @returns {Promise<void>}
|
|
571
|
+
*/
|
|
551
572
|
onTunnelDead: async ({ udid }) => {
|
|
552
573
|
await reconnectTunnelByUdid(udid);
|
|
553
574
|
},
|
|
@@ -560,19 +581,19 @@ async function main() {
|
|
|
560
581
|
}
|
|
561
582
|
|
|
562
583
|
if (devicesToProcess.length > 1) {
|
|
563
|
-
await
|
|
584
|
+
await sleep(1000);
|
|
564
585
|
}
|
|
565
586
|
}
|
|
566
587
|
|
|
567
588
|
log.info('\n=== TUNNEL CREATION SUMMARY ===');
|
|
568
589
|
const failed = results.filter((r) => !r.success);
|
|
569
590
|
|
|
570
|
-
log.info(`Total
|
|
571
|
-
log.info(`Successful
|
|
572
|
-
log.info(`Failed
|
|
591
|
+
log.info(`Total ${util.pluralize('device', results.length, true)} processed`);
|
|
592
|
+
log.info(`Successful ${util.pluralize('tunnel', successful.length, true)}`);
|
|
593
|
+
log.info(`Failed ${util.pluralize('tunnel', failed.length, true)}`);
|
|
573
594
|
|
|
574
595
|
if (successful.length > 0) {
|
|
575
|
-
log.info(
|
|
596
|
+
log.info(`\n✅ Successful ${util.pluralize('tunnel', successful.length)}:`);
|
|
576
597
|
log.info('\n📁 Tunnel registry API:');
|
|
577
598
|
log.info(' The tunnel registry is now available through the API at:');
|
|
578
599
|
log.info(` http://localhost:${registryPort}/remotexpc/tunnels`);
|
|
@@ -610,8 +631,9 @@ await main();
|
|
|
610
631
|
* Successful tunnel row (USB lockdown + CoreDeviceProxy) used for the registry and lifecycle watch.
|
|
611
632
|
*
|
|
612
633
|
* @typedef {object} TunnelCreationSuccessResult
|
|
613
|
-
* @property {{ Properties: { SerialNumber: string }, DeviceID: number }} device
|
|
634
|
+
* @property {{ Properties: { SerialNumber: string, [key: string]: unknown }, DeviceID: number }} device
|
|
614
635
|
* @property {{ Address: string, RsdPort?: number }} tunnel
|
|
615
636
|
* @property {import('appium-ios-tuntap').TunnelConnection} tunnelConnection
|
|
616
637
|
* @property {(handler: (reason: string) => void) => void} registerOnDead
|
|
638
|
+
* @property {boolean} success
|
|
617
639
|
*/
|