Skip to content

Commit 94c3a19

Browse files
feat: Add doctor checks for remotexpc (#2748)
1 parent 36b193f commit 94c3a19

2 files changed

Lines changed: 360 additions & 5 deletions

File tree

lib/doctor/optional-checks.ts

Lines changed: 359 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {resolveExecutablePath} from './utils';
2-
import {doctor} from 'appium/support';
2+
import {doctor, fs, node} from 'appium/support';
3+
import axios from 'axios';
34
import type {IDoctorCheck, AppiumLogger, DoctorCheckResult} from '@appium/types';
45
import '@colors/colors';
5-
import {exec} from 'teen_process';
6+
import {exec, SubProcess} from 'teen_process';
7+
import memoize from 'lodash/memoize';
68

79
export class OptionalSimulatorCheck implements IDoctorCheck {
810
log!: AppiumLogger;
@@ -21,9 +23,11 @@ export class OptionalSimulatorCheck implements IDoctorCheck {
2123
try {
2224
// https://github.com/appium/appium/issues/12093#issuecomment-459358120
2325
await exec('xcrun', ['simctl', 'help']);
24-
} catch (err) {
26+
} catch (err: any) {
2527
return doctor.nokOptional(
26-
`Testing on Simulator is not possible. Cannot run 'xcrun simctl': ${(err as any).stderr || (err as Error).message}`,
28+
`Testing on Simulator is not possible. Cannot run 'xcrun simctl': ${
29+
err?.stderr || (err as Error).message
30+
}`,
2731
);
2832
}
2933

@@ -123,6 +127,357 @@ export class OptionalFfmpegCheck implements IDoctorCheck {
123127
}
124128
export const optionalFfmpegCheck = new OptionalFfmpegCheck();
125129

130+
const REMOTE_XPC_PACKAGE_NAME = 'appium-ios-remotexpc';
131+
132+
const isRemoteXpcDependencyAvailable = memoize(
133+
async function ensureRemoteXpcDependencyAvailable(): Promise<boolean> {
134+
try {
135+
// We only care that the module can be imported; we don't need to use it here.
136+
await import(REMOTE_XPC_PACKAGE_NAME);
137+
return true;
138+
} catch {
139+
return false;
140+
}
141+
},
142+
);
143+
144+
const getXcuitestDriverRoot = memoize(function getXcuitestDriverRoot(): string | null {
145+
return node.getModuleRootSync('appium-xcuitest-driver', __filename);
146+
});
147+
148+
export class OptionalIosRemoteXpcDependencyCheck implements IDoctorCheck {
149+
log!: AppiumLogger;
150+
static readonly README_LINK = 'https://github.com/appium/appium-ios-remotexpc';
151+
152+
async diagnose(): Promise<DoctorCheckResult> {
153+
const available = await isRemoteXpcDependencyAvailable();
154+
if (available) {
155+
return doctor.okOptional(
156+
`${REMOTE_XPC_PACKAGE_NAME} is installed and can be imported. ` +
157+
`Remote XPC-based features are available for real devices (iOS/tvOS 18+).`,
158+
);
159+
}
160+
return doctor.nokOptional(
161+
`${REMOTE_XPC_PACKAGE_NAME} is not installed or cannot be imported. ` +
162+
`Install it as an optional dependency if you plan to use Remote XPC-based features ` +
163+
`on real devices (iOS/tvOS 18+). Tests may still run without it, but some ` +
164+
`advanced functionality might not work or be unavailable.`,
165+
);
166+
}
167+
168+
async fix(): Promise<string> {
169+
const driverRoot = getXcuitestDriverRoot();
170+
const locationHint = driverRoot ? `cd "${driverRoot}"; ` : '';
171+
return (
172+
`${`${REMOTE_XPC_PACKAGE_NAME}`.bold} provides Remote XPC communication ` +
173+
`and tunneling support for real devices (iOS/tvOS 18+). ` +
174+
`Run '${locationHint}npm install ${REMOTE_XPC_PACKAGE_NAME}'. ` +
175+
`For more information, see ${OptionalIosRemoteXpcDependencyCheck.README_LINK}.`
176+
);
177+
}
178+
179+
hasAutofix(): boolean {
180+
return false;
181+
}
182+
183+
isOptional(): boolean {
184+
return true;
185+
}
186+
}
187+
export const optionalIosRemoteXpcDependencyCheck = new OptionalIosRemoteXpcDependencyCheck();
188+
189+
const TUNNEL_SCRIPT_TIMEOUT_MS = 5000;
190+
const API_READY_PATTERN = /:\d+\/remotexpc\/tunnels/;
191+
192+
export class OptionalTunnelAvailabilityCheck implements IDoctorCheck {
193+
log!: AppiumLogger;
194+
static readonly README_LINK = 'https://github.com/appium/appium-ios-tuntap';
195+
static readonly TUNNEL_CREATION_COMMAND = 'appium driver run xcuitest tunnel-creation';
196+
197+
async diagnose(): Promise<DoctorCheckResult> {
198+
const remoteXpcAvailable = await isRemoteXpcDependencyAvailable();
199+
if (!remoteXpcAvailable) {
200+
return doctor.nokOptional(
201+
`Remote XPC tunnel availability cannot be checked because ` +
202+
`${REMOTE_XPC_PACKAGE_NAME} is not installed or cannot be imported. ` +
203+
`Install it first using the '${REMOTE_XPC_PACKAGE_NAME}' optional check.`,
204+
);
205+
}
206+
207+
const platform = process.platform;
208+
if (platform !== 'darwin' && platform !== 'linux') {
209+
return doctor.okOptional(
210+
`Tunnel availability status cannot be automatically verified on platform '${platform}'.`,
211+
);
212+
}
213+
214+
const candidatePorts = await this._getListeningTcpPorts();
215+
if (candidatePorts.length > 0) {
216+
const registryResult = await this._probeTunnelRegistry(candidatePorts);
217+
if (registryResult) {
218+
return registryResult;
219+
}
220+
}
221+
222+
return await this._runTunnelCreationScript();
223+
}
224+
225+
/**
226+
* Returns listening TCP ports. Uses pure Node on Linux (/proc/net/tcp, tcp6); uses netstat on macOS.
227+
*/
228+
private async _getListeningTcpPorts(): Promise<number[]> {
229+
if (process.platform === 'linux') {
230+
return await this._getListeningTcpPortsLinux();
231+
}
232+
if (process.platform === 'darwin') {
233+
return await this._getListeningTcpPortsDarwin();
234+
}
235+
return [];
236+
}
237+
238+
/**
239+
* Linux: parse /proc/net/tcp and /proc/net/tcp6 (pure Node, no exec). State 0A = LISTEN.
240+
*/
241+
private async _getListeningTcpPortsLinux(): Promise<number[]> {
242+
const ports = new Set<number>();
243+
const files = ['/proc/net/tcp', '/proc/net/tcp6'] as const;
244+
for (const file of files) {
245+
try {
246+
const raw = await fs.readFile(file, 'utf8');
247+
const lines = raw.split('\n');
248+
for (let i = 1; i < lines.length; i++) {
249+
const parts = lines[i].trim().split(/\s+/);
250+
if (parts.length < 4) {
251+
continue;
252+
}
253+
const state = parts[3];
254+
if (state !== '0A') {
255+
continue; // 0A = LISTEN
256+
}
257+
const localAddr = parts[1];
258+
const colon = localAddr.lastIndexOf(':');
259+
if (colon === -1) {
260+
continue;
261+
}
262+
const portHex = localAddr.slice(colon + 1);
263+
const port = Number.parseInt(portHex, 16);
264+
if (Number.isInteger(port) && port > 0 && port <= 65535) {
265+
ports.add(port);
266+
}
267+
}
268+
} catch {
269+
// File missing or unreadable (e.g. not Linux or permissions)
270+
}
271+
}
272+
return Array.from(ports);
273+
}
274+
275+
/**
276+
* macOS: netstat -anv -p tcp (Node has no API for system-wide listening ports).
277+
*/
278+
private async _getListeningTcpPortsDarwin(): Promise<number[]> {
279+
try {
280+
const {stdout} = await exec('netstat', ['-anv', '-p', 'tcp']);
281+
const ports = new Set<number>();
282+
for (const line of stdout.split('\n')) {
283+
const trimmed = line.trim();
284+
if (!trimmed || !trimmed.toLowerCase().startsWith('tcp')) {
285+
continue;
286+
}
287+
const parts = trimmed.split(/\s+/);
288+
if (parts.length < 4) {
289+
continue;
290+
}
291+
const portMatch = /\.(\d+)$/.exec(parts[3]);
292+
if (!portMatch) {
293+
continue;
294+
}
295+
const port = Number.parseInt(portMatch[1], 10);
296+
if (Number.isInteger(port) && port > 0) {
297+
ports.add(port);
298+
}
299+
}
300+
return Array.from(ports);
301+
} catch {
302+
return [];
303+
}
304+
}
305+
306+
/**
307+
* Probes candidate ports for the tunnel registry API in parallel; resolves as soon as any succeed, else null.
308+
*/
309+
private async _probeTunnelRegistry(ports: number[]): Promise<DoctorCheckResult | null> {
310+
if (ports.length === 0) {
311+
return null;
312+
}
313+
314+
return await new Promise<DoctorCheckResult | null>((resolve) => {
315+
let settled = false;
316+
let remaining = ports.length;
317+
318+
const maybeResolveNull = () => {
319+
remaining -= 1;
320+
if (!settled && remaining === 0) {
321+
settled = true;
322+
resolve(null);
323+
}
324+
};
325+
326+
for (const port of ports) {
327+
(async () => {
328+
try {
329+
const res = await axios.get(`http://127.0.0.1:${port}/remotexpc/tunnels`, {
330+
timeout: 1000,
331+
validateStatus: (status) => status === 200,
332+
});
333+
const data = res.data as any;
334+
if (!settled && data != null && typeof data === 'object' && data.status === 'OK') {
335+
settled = true;
336+
resolve(
337+
doctor.okOptional(
338+
`Detected an active Remote XPC tunnel registry process on port ${port}. ` +
339+
`The Remote XPC tunnel infrastructure appears to be available, so Remote XPC-based ` +
340+
`features for real devices (iOS/tvOS 18+) should be available.`,
341+
),
342+
);
343+
return;
344+
}
345+
} catch {
346+
// Ignore individual probe failures; we'll resolve to null only if all fail.
347+
}
348+
if (!settled) {
349+
maybeResolveNull();
350+
}
351+
})();
352+
}
353+
});
354+
}
355+
356+
/**
357+
* Runs the tunnel-creation driver script as a subprocess to avoid blocking doctor if the script hangs.
358+
* Waits for exit, TUNNEL_SCRIPT_TIMEOUT_MS (5s), or output string indicating registry is up;
359+
* then evaluates or stops the process.
360+
*/
361+
private async _runTunnelCreationScript(): Promise<DoctorCheckResult> {
362+
const homeCwd = process.env.HOME || process.cwd();
363+
const driverRoot = getXcuitestDriverRoot();
364+
365+
let combinedOutput = '';
366+
let resolveApiReady: () => void;
367+
const apiReadyPromise = new Promise<{reason: 'api'}>((resolve) => {
368+
resolveApiReady = () => resolve({reason: 'api'});
369+
});
370+
const sub =
371+
driverRoot != null
372+
? new SubProcess(process.execPath, ['./scripts/tunnel-creation.mjs'], {cwd: driverRoot})
373+
: new SubProcess('appium', ['driver', 'run', 'xcuitest', 'tunnel-creation'], {
374+
cwd: homeCwd,
375+
});
376+
const appendLine = (line: string) => {
377+
combinedOutput += line + '\n';
378+
if (API_READY_PATTERN.test(line)) {
379+
resolveApiReady();
380+
}
381+
};
382+
sub.on('line-stdout', appendLine);
383+
sub.on('line-stderr', appendLine);
384+
385+
const exitPromise = new Promise<{reason: 'exit'; code?: number; signal?: string}>((resolve) => {
386+
sub.once('exit', (code, signal) => resolve({reason: 'exit', code, signal}));
387+
});
388+
const timeoutPromise = new Promise<{reason: 'timeout'}>((resolve) => {
389+
setTimeout(() => resolve({reason: 'timeout'}), TUNNEL_SCRIPT_TIMEOUT_MS);
390+
});
391+
392+
try {
393+
await sub.start(0);
394+
} catch (err) {
395+
const message = ((err as any).stderr || (err as Error).message || '').toString();
396+
return doctor.nokOptional(
397+
`Could not start '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}'. ` +
398+
`Without a working tunnel, Remote XPC-based functionality on real devices (iOS/tvOS 18+) might not work or be unavailable. ` +
399+
`Details: ${message}`,
400+
);
401+
}
402+
403+
const winner = await Promise.race([exitPromise, timeoutPromise, apiReadyPromise]);
404+
405+
if (winner.reason === 'exit') {
406+
const code = (winner as {reason: 'exit'; code?: number; signal?: string}).code;
407+
return this._evaluateTunnelScriptOutput(combinedOutput.trim(), code);
408+
}
409+
410+
if (sub.isRunning) {
411+
try {
412+
await sub.stop('SIGTERM', 500);
413+
} catch {
414+
// ignore
415+
}
416+
}
417+
return doctor.okOptional(
418+
`The tunnel script was started; the registry was detected or the check timed out. ` +
419+
`Tunnel infrastructure for real devices (iOS/tvOS 18+) should be available when run with sufficient privileges.`,
420+
);
421+
}
422+
423+
/**
424+
* Interprets tunnel-creation script stdout+stderr and optional exit code; returns the appropriate doctor result.
425+
* Output pattern matches take priority over a non-zero exit code.
426+
*/
427+
private _evaluateTunnelScriptOutput(
428+
combinedOutput: string,
429+
exitCode?: number | null,
430+
): DoctorCheckResult {
431+
if (/No devices found/i.test(combinedOutput)) {
432+
return doctor.okOptional(
433+
`The Remote XPC tunnel-creation script can be invoked via '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}', ` +
434+
`but no real devices are currently connected.`,
435+
);
436+
}
437+
if (/operation not permitted|permission denied/i.test(combinedOutput)) {
438+
return doctor.okOptional(
439+
`The tunnel-creation script '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' is available, ` +
440+
`but could not create a TUN/TAP interface without elevated privileges (` +
441+
`${'Operation not permitted'.bold}). ` +
442+
`This is expected when not running with sudo/root. ` +
443+
`When you actually need Remote XPC-based functionality for real devices (iOS/tvOS 18+), ` +
444+
`run the same command with sufficient privileges to establish the tunnel.`,
445+
);
446+
}
447+
if (exitCode != null && exitCode !== 0) {
448+
return doctor.nokOptional(
449+
`The tunnel script exited with code ${exitCode}. ` +
450+
`Without a working tunnel, Remote XPC-based functionality on real devices (iOS/tvOS 18+) might not work. ` +
451+
(combinedOutput ? `Output:\n${combinedOutput}` : ''),
452+
);
453+
}
454+
return doctor.okOptional(
455+
`Successfully ran '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' without sudo. ` +
456+
`The Remote XPC tunnel infrastructure should be available for creating tunnels.` +
457+
(combinedOutput ? `\nLast output:\n${combinedOutput}` : ''),
458+
);
459+
}
460+
461+
async fix(): Promise<string> {
462+
return (
463+
`The Remote XPC tunnel infrastructure is used for IPv6 tunneling when testing against real ` +
464+
`devices (iOS/tvOS 18+). ` +
465+
`To explicitly start or verify tunnels when needed, run ` +
466+
`'${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' with sudo/root privileges. ` +
467+
`See ${OptionalTunnelAvailabilityCheck.README_LINK} for more details about tunnel usage.`
468+
);
469+
}
470+
471+
hasAutofix(): boolean {
472+
return false;
473+
}
474+
475+
isOptional(): boolean {
476+
return true;
477+
}
478+
}
479+
export const optionalTunnelAvailabilityCheck = new OptionalTunnelAvailabilityCheck();
480+
126481
interface SimulatorPlatform {
127482
displayName: string;
128483
name: string;

0 commit comments

Comments
 (0)