11import { resolveExecutablePath } from './utils' ;
2- import { doctor } from 'appium/support' ;
2+ import { doctor , fs , node } from 'appium/support' ;
3+ import axios from 'axios' ;
34import type { IDoctorCheck , AppiumLogger , DoctorCheckResult } from '@appium/types' ;
45import '@colors/colors' ;
5- import { exec } from 'teen_process' ;
6+ import { exec , SubProcess } from 'teen_process' ;
7+ import memoize from 'lodash/memoize' ;
68
79export 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}
124128export 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 + \/ r e m o t e x p c \/ t u n n e l s / ;
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 ( / N o d e v i c e s f o u n d / 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 ( / o p e r a t i o n n o t p e r m i t t e d | p e r m i s s i o n d e n i e d / 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+
126481interface SimulatorPlatform {
127482 displayName : string ;
128483 name : string ;
0 commit comments