appium-ios-remotexpc 0.2.0 → 0.3.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.
@@ -0,0 +1,650 @@
1
+ import { logger } from '@appium/support';
2
+ import { type ChildProcess, spawn } from 'node:child_process';
3
+ import { resolve4 } from 'node:dns/promises';
4
+ import { EventEmitter } from 'node:events';
5
+ import { clearTimeout, setTimeout } from 'node:timers';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+
8
+ import {
9
+ BONJOUR_DEFAULT_DOMAIN,
10
+ BONJOUR_SERVICE_TYPES,
11
+ BONJOUR_TIMEOUTS,
12
+ DNS_SD_ACTIONS,
13
+ DNS_SD_COMMANDS,
14
+ DNS_SD_PATTERNS,
15
+ } from './constants.js';
16
+
17
+ const log = logger.getLogger('BonjourDiscovery');
18
+
19
+ const DNS_SD_COMMAND = 'dns-sd';
20
+
21
+ /**
22
+ * Interface for a discovered Bonjour service
23
+ */
24
+ export interface BonjourService {
25
+ name: string;
26
+ type: string;
27
+ domain: string;
28
+ hostname?: string;
29
+ port?: number;
30
+ txtRecord?: Record<string, string>;
31
+ interfaceIndex?: number;
32
+ }
33
+
34
+ /**
35
+ * Interface for AppleTV device discovered via Bonjour
36
+ */
37
+ export interface AppleTVDevice {
38
+ name: string;
39
+ identifier: string;
40
+ hostname: string;
41
+ ip?: string;
42
+ port: number;
43
+ model: string;
44
+ version: string;
45
+ minVersion: string;
46
+ authTag?: string;
47
+ interfaceIndex?: number;
48
+ }
49
+
50
+ /**
51
+ * Type alias for service discovery results
52
+ */
53
+ export type ServiceDiscoveryResult = Array<{
54
+ action: string;
55
+ service: BonjourService;
56
+ }>;
57
+
58
+ /**
59
+ * Process output handler result
60
+ */
61
+ interface ProcessResult {
62
+ success: boolean;
63
+ data?: any;
64
+ error?: Error;
65
+ }
66
+
67
+ /**
68
+ * Execute a dns-sd command with a timeout.
69
+ *
70
+ * @param args Arguments passed to the dns-sd CLI.
71
+ * @param timeoutMs Timeout in milliseconds.
72
+ * @param outputHandler Called for each stdout chunk from dns-sd:
73
+ * - returns true: chunk handled; no result recorded; continue listening
74
+ * - returns a truthy non-true value (e.g., object): store as result to return when the process ends
75
+ * - returns false/undefined/null: ignore; continue listening
76
+ * - throws: mark command as failed
77
+ * @param timeoutMessage Error message used if the command times out.
78
+ * @returns The value produced by outputHandler, if any, when the process ends.
79
+ */
80
+ async function executeDnsSdCommand<T>(
81
+ args: string[],
82
+ timeoutMs: number,
83
+ outputHandler: (output: string) => T | boolean,
84
+ timeoutMessage: string,
85
+ ): Promise<T> {
86
+ const child = spawn(DNS_SD_COMMAND, args);
87
+ try {
88
+ const result = await waitForProcessResult<T>(
89
+ child,
90
+ timeoutMs,
91
+ outputHandler,
92
+ timeoutMessage,
93
+ );
94
+
95
+ if (!result.success) {
96
+ throw result.error || new Error('Process execution failed');
97
+ }
98
+ return result.data as T;
99
+ } finally {
100
+ if (!child.killed) {
101
+ child.kill('SIGTERM');
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Create a long-running browse process
108
+ */
109
+ function createDnsSdBrowseProcess(
110
+ serviceType: string,
111
+ domain: string,
112
+ ): ChildProcess {
113
+ return spawn(DNS_SD_COMMAND, [DNS_SD_COMMANDS.BROWSE, serviceType, domain]);
114
+ }
115
+
116
+ /**
117
+ * Wait for a child process result with a timeout.
118
+ *
119
+ * @param process Child process to observe.
120
+ * @param timeoutMs Timeout in milliseconds.
121
+ * @param outputHandler Called for each stdout chunk from dns-sd:
122
+ * - returns true: chunk handled; no result recorded; continue listening
123
+ * - returns a truthy non-true value (e.g., object): store as result to return when the process ends
124
+ * - returns false/undefined/null: ignore; continue listening
125
+ * - throws: mark command as failed
126
+ * @param timeoutMessage Error message used if the operation times out.
127
+ * @returns A ProcessResult indicating success and any data from outputHandler.
128
+ */
129
+ async function waitForProcessResult<T>(
130
+ process: ChildProcess,
131
+ timeoutMs: number,
132
+ outputHandler: (output: string) => T | boolean,
133
+ timeoutMessage: string,
134
+ ): Promise<ProcessResult> {
135
+ return new Promise((resolve, reject) => {
136
+ let isResolved = false;
137
+ let result: T | undefined;
138
+ let exitCode: number | null = null;
139
+ let hasError = false;
140
+ let errorMessage = '';
141
+
142
+ const timeout = setTimeout(() => {
143
+ if (!isResolved) {
144
+ isResolved = true;
145
+ reject(new Error(timeoutMessage));
146
+ }
147
+ }, timeoutMs);
148
+
149
+ const cleanup = () => {
150
+ clearTimeout(timeout);
151
+ if (!isResolved) {
152
+ isResolved = true;
153
+
154
+ if (hasError) {
155
+ reject(new Error(errorMessage));
156
+ } else if (exitCode !== null && exitCode !== 0) {
157
+ reject(new Error(`Process exited with code ${exitCode}`));
158
+ } else if (result !== undefined) {
159
+ resolve({ success: true, data: result });
160
+ } else {
161
+ resolve({ success: true });
162
+ }
163
+ }
164
+ };
165
+
166
+ process.stdout?.on('data', (data: Buffer) => {
167
+ if (isResolved) {
168
+ return;
169
+ }
170
+
171
+ const output = data.toString();
172
+ log.debug(`[dns-sd] output: ${output}`);
173
+
174
+ try {
175
+ const handlerResult = outputHandler(output);
176
+ if (handlerResult === true) {
177
+ result = undefined;
178
+ } else if (handlerResult) {
179
+ result = handlerResult as T;
180
+ }
181
+ } catch (error) {
182
+ hasError = true;
183
+ errorMessage = `Output handler error: ${error}`;
184
+ }
185
+ });
186
+
187
+ process.stderr?.on('data', (data: Buffer) => {
188
+ if (isResolved) {
189
+ return;
190
+ }
191
+
192
+ const error = data.toString();
193
+ log.error(`[dns-sd] error: ${error}`);
194
+ hasError = true;
195
+ errorMessage = `Process failed: ${error}`;
196
+ });
197
+
198
+ process.on('error', (error: Error) => {
199
+ if (isResolved) {
200
+ return;
201
+ }
202
+
203
+ log.error(`[dns-sd] failed to start process: ${error}`);
204
+ hasError = true;
205
+ errorMessage = `Failed to start process: ${error}`;
206
+ });
207
+
208
+ process.on('exit', (code: number | null) => {
209
+ exitCode = code;
210
+ if (code !== null && code !== 0) {
211
+ log.error(`[dns-sd] process exited with error code: ${code}`);
212
+ }
213
+ });
214
+ process.on('close', (code: number | null) => {
215
+ log.debug(`[dns-sd] process closed with code: ${code}`);
216
+ cleanup();
217
+ });
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Parse browse output and extract service information
223
+ */
224
+ function parseBrowseOutput(output: string): ServiceDiscoveryResult {
225
+ const reducer = (
226
+ acc: ServiceDiscoveryResult,
227
+ line: string,
228
+ ): ServiceDiscoveryResult => {
229
+ const match = line.match(DNS_SD_PATTERNS.BROWSE_LINE);
230
+ if (!match) {
231
+ return acc;
232
+ }
233
+
234
+ const [, , action, , interfaceIndex, domain, serviceType, name] = match;
235
+ const trimmedName = name.trim();
236
+
237
+ const service: BonjourService = {
238
+ name: trimmedName,
239
+ type: serviceType,
240
+ domain,
241
+ interfaceIndex: parseInt(interfaceIndex, 10),
242
+ };
243
+
244
+ acc.push({ action, service });
245
+ return acc;
246
+ };
247
+
248
+ return output
249
+ .split('\n')
250
+ .filter((line) => !shouldSkipLine(line))
251
+ .reduce<ServiceDiscoveryResult>(reducer, []);
252
+ }
253
+
254
+ /**
255
+ * Parse resolve output and extract service details
256
+ */
257
+ function parseResolveOutput(
258
+ output: string,
259
+ serviceName: string,
260
+ serviceType: string,
261
+ domain: string,
262
+ ): BonjourService | null {
263
+ return parseOutput<BonjourService | null>(
264
+ output,
265
+ (line: string, result: BonjourService | null) => {
266
+ // If we already found a result, return it (early termination)
267
+ if (result) {
268
+ return result;
269
+ }
270
+
271
+ const reachableMatch = line.match(DNS_SD_PATTERNS.REACHABLE);
272
+ if (reachableMatch) {
273
+ const [, hostname, port, interfaceIndex] = reachableMatch;
274
+ const txtRecord = parseTxtRecord(output);
275
+
276
+ return {
277
+ name: serviceName,
278
+ type: serviceType,
279
+ domain,
280
+ hostname,
281
+ port: parseInt(port, 10),
282
+ txtRecord,
283
+ interfaceIndex: parseInt(interfaceIndex, 10),
284
+ };
285
+ }
286
+ return result;
287
+ },
288
+ null as BonjourService | null,
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Generic method to parse output with different reducer functions
294
+ */
295
+ function parseOutput<T>(
296
+ output: string,
297
+ reducer: (line: string, accumulator: T) => T,
298
+ initialValue: T,
299
+ ): T {
300
+ const lines = output.split('\n');
301
+ let result = initialValue;
302
+
303
+ for (const line of lines) {
304
+ if (shouldSkipLine(line)) {
305
+ continue;
306
+ }
307
+ result = reducer(line, result);
308
+ }
309
+
310
+ return result;
311
+ }
312
+
313
+ /**
314
+ * Parse TXT record from output
315
+ */
316
+ function parseTxtRecord(output: string): Record<string, string> {
317
+ const txtRecord: Record<string, string> = {};
318
+ const txtMatch = output.match(DNS_SD_PATTERNS.TXT_RECORD);
319
+
320
+ if (txtMatch) {
321
+ const [, identifier, authTag, model, name, ver, minVer] = txtMatch;
322
+ txtRecord.identifier = identifier;
323
+ txtRecord.authTag = authTag;
324
+ txtRecord.model = model;
325
+ txtRecord.name = name;
326
+ txtRecord.ver = ver;
327
+ txtRecord.minVer = minVer;
328
+ }
329
+
330
+ return txtRecord;
331
+ }
332
+
333
+ /**
334
+ * Check if line should be skipped
335
+ */
336
+ function shouldSkipLine(line: string): boolean {
337
+ return (
338
+ line.includes('Timestamp') || line.includes('---') || line.trim() === ''
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Resolve hostname to IP address
344
+ */
345
+ async function resolveIPAddress(
346
+ hostname: string,
347
+ ): Promise<string[] | undefined> {
348
+ try {
349
+ const address = await resolve4(hostname);
350
+ log.info(`[ServiceResolver] Resolved ${hostname} to IPv4: ${address}`);
351
+ return address;
352
+ } catch (error) {
353
+ log.warn(
354
+ `[ServiceResolver] Failed to resolve hostname ${hostname} to IPv4: ${error}`,
355
+ );
356
+ // For .local hostnames, try without the trailing dot
357
+ if (hostname.endsWith('.local.')) {
358
+ const cleanHostname = hostname.slice(0, -1); // Remove trailing dot
359
+ try {
360
+ const address = await resolve4(cleanHostname);
361
+ log.info(
362
+ `[ServiceResolver] Resolved ${cleanHostname} to IPv4: ${address}`,
363
+ );
364
+ return address;
365
+ } catch (retryError) {
366
+ log.warn(
367
+ `[ServiceResolver] Failed to resolve ${cleanHostname} to IPv4: ${retryError}`,
368
+ );
369
+ }
370
+ }
371
+ return undefined;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Convert a resolved Bonjour service to an Apple TV device with IP resolution
377
+ */
378
+ async function convertToAppleTVDeviceWithIP(
379
+ service: BonjourService,
380
+ ): Promise<AppleTVDevice | null> {
381
+ if (!isValidService(service)) {
382
+ return null;
383
+ }
384
+
385
+ const { txtRecord, hostname, port } = service;
386
+ if (!txtRecord || !hasRequiredTxtFields(txtRecord)) {
387
+ log.warn(
388
+ `[AppleTVDeviceConverter] Service ${service.name} missing required TXT record fields`,
389
+ );
390
+ return null;
391
+ }
392
+
393
+ if (!hostname || !port) {
394
+ log.warn(
395
+ `[AppleTVDeviceConverter] Service ${service.name} missing hostname or port`,
396
+ );
397
+ return null;
398
+ }
399
+
400
+ const ipAddresses = await resolveIPAddress(hostname);
401
+ // Select default first one
402
+ // TODO: needs a decision to select from cli, if the user wants to select from the available ip's
403
+ const ip = ipAddresses?.[0];
404
+
405
+ return {
406
+ name: service.name,
407
+ identifier: txtRecord.identifier,
408
+ hostname,
409
+ ip,
410
+ port,
411
+ model: txtRecord.model,
412
+ version: txtRecord.ver,
413
+ minVersion: txtRecord.minVer || '17',
414
+ authTag: txtRecord.authTag,
415
+ interfaceIndex: service.interfaceIndex,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Check if the service has required fields
421
+ */
422
+ function isValidService(service: BonjourService): boolean {
423
+ return Boolean(service.hostname && service.port && service.txtRecord);
424
+ }
425
+
426
+ /**
427
+ * Check if TXT record has required fields
428
+ */
429
+ function hasRequiredTxtFields(txtRecord: Record<string, string>): boolean {
430
+ return Boolean(txtRecord.identifier && txtRecord.model && txtRecord.ver);
431
+ }
432
+
433
+ /* =========================
434
+ * Main Bonjour discovery service orchestrator
435
+ * =========================
436
+ */
437
+ export class BonjourDiscovery extends EventEmitter {
438
+ private _browseProcess?: ChildProcess;
439
+ private _isDiscovering = false;
440
+ private readonly _discoveredServices: Map<string, BonjourService> = new Map();
441
+
442
+ /**
443
+ * Start browsing for Bonjour services
444
+ */
445
+ async startBrowsing(
446
+ serviceType: string = BONJOUR_SERVICE_TYPES.APPLE_TV_PAIRING,
447
+ domain: string = BONJOUR_DEFAULT_DOMAIN,
448
+ ): Promise<void> {
449
+ if (this._isDiscovering) {
450
+ log.warn('Already discovering services');
451
+ return;
452
+ }
453
+
454
+ log.info(`Starting Bonjour discovery for ${serviceType}.${domain}`);
455
+
456
+ try {
457
+ await this.initializeBrowsing(serviceType, domain);
458
+ } catch (error) {
459
+ this.cleanup();
460
+ throw error;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Stop browsing for services
466
+ */
467
+ stopBrowsing(): void {
468
+ if (this._browseProcess && !this._browseProcess.killed) {
469
+ log.info('Stopping Bonjour discovery');
470
+ this._browseProcess.kill('SIGTERM');
471
+ }
472
+ this.cleanup();
473
+ }
474
+
475
+ /**
476
+ * Get all discovered services
477
+ */
478
+ getDiscoveredServices(): BonjourService[] {
479
+ return Array.from(this._discoveredServices.values());
480
+ }
481
+
482
+ /**
483
+ * Resolve a specific service to get detailed information
484
+ */
485
+ async resolveService(
486
+ serviceName: string,
487
+ serviceType: string = BONJOUR_SERVICE_TYPES.APPLE_TV_PAIRING,
488
+ domain: string = BONJOUR_DEFAULT_DOMAIN,
489
+ ): Promise<BonjourService> {
490
+ log.info(
491
+ `[ServiceResolver] Resolving service: ${serviceName}.${serviceType}.${domain}`,
492
+ );
493
+
494
+ const service = await executeDnsSdCommand<BonjourService | null>(
495
+ [DNS_SD_COMMANDS.RESOLVE, serviceName, serviceType, domain],
496
+ BONJOUR_TIMEOUTS.SERVICE_RESOLUTION,
497
+ (output: string) =>
498
+ parseResolveOutput(output, serviceName, serviceType, domain),
499
+ `Service resolution timeout for ${serviceName}`,
500
+ );
501
+
502
+ if (!service) {
503
+ throw new Error(`Failed to resolve service ${serviceName}`);
504
+ }
505
+
506
+ return service;
507
+ }
508
+
509
+ /**
510
+ * Discover Apple TV devices with IP address resolution
511
+ */
512
+ async discoverAppleTVDevicesWithIP(
513
+ timeoutMs: number = BONJOUR_TIMEOUTS.DEFAULT_DISCOVERY,
514
+ ): Promise<AppleTVDevice[]> {
515
+ log.info('Starting Apple TV device discovery with IP resolution');
516
+
517
+ try {
518
+ await this.startBrowsing();
519
+ await delay(timeoutMs);
520
+
521
+ const devices = await this.resolveAllServices();
522
+ log.info(
523
+ `Discovered ${devices.length} Apple TV device(s) with IP addresses:`,
524
+ devices,
525
+ );
526
+
527
+ return devices;
528
+ } finally {
529
+ this.stopBrowsing();
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Process browse output using the parser
535
+ */
536
+ processBrowseOutput(output: string): void {
537
+ const results = parseBrowseOutput(output);
538
+
539
+ for (const { action, service } of results) {
540
+ switch (action) {
541
+ case DNS_SD_ACTIONS.ADD:
542
+ this._discoveredServices.set(service.name, service);
543
+ this.emit('serviceAdded', service);
544
+ log.info(`Discovered service: ${service.name}`);
545
+ break;
546
+ case DNS_SD_ACTIONS.REMOVE:
547
+ this._discoveredServices.delete(service.name);
548
+ this.emit('serviceRemoved', service.name);
549
+ log.info(`Service removed: ${service.name}`);
550
+ break;
551
+ default:
552
+ log.debug(`Unknown action: ${action}`);
553
+ break;
554
+ }
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Initialize a browsing process
560
+ */
561
+ private async initializeBrowsing(
562
+ serviceType: string,
563
+ domain: string,
564
+ ): Promise<void> {
565
+ this._isDiscovering = true;
566
+ this._discoveredServices.clear();
567
+
568
+ const browseProcess = createDnsSdBrowseProcess(serviceType, domain);
569
+ this._browseProcess = browseProcess;
570
+
571
+ try {
572
+ await executeDnsSdCommand(
573
+ [DNS_SD_COMMANDS.BROWSE, serviceType, domain],
574
+ BONJOUR_TIMEOUTS.BROWSE_STARTUP,
575
+ (output: string) => {
576
+ this.processBrowseOutput(output);
577
+ return output.includes(DNS_SD_PATTERNS.STARTING);
578
+ },
579
+ 'DNS-SD browse startup timeout',
580
+ );
581
+
582
+ this.setupBrowseEventHandlers(browseProcess);
583
+ } catch (error) {
584
+ this._isDiscovering = false;
585
+ throw error;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Setup event handlers for an ongoing browse process
591
+ */
592
+ private setupBrowseEventHandlers(process: ChildProcess): void {
593
+ process.stdout?.on('data', (data: Buffer) => {
594
+ const output = data.toString();
595
+ log.debug(`dns-sd browse output: ${output}`);
596
+ this.processBrowseOutput(output);
597
+ });
598
+
599
+ process.stderr?.on('data', (data: Buffer) => {
600
+ const error = data.toString();
601
+ log.error(`dns-sd browse error: ${error}`);
602
+ });
603
+
604
+ process.on('exit', (code: number | null) => {
605
+ if (code !== null && code !== 0) {
606
+ log.error(`dns-sd browse process exited with error code: ${code}`);
607
+ }
608
+ });
609
+ process.on('close', (code: number | null) => {
610
+ log.debug(`dns-sd browse process closed with code: ${code}`);
611
+ this.cleanup();
612
+ });
613
+ }
614
+
615
+ /**
616
+ * Resolve all discovered services
617
+ */
618
+ private async resolveAllServices(): Promise<AppleTVDevice[]> {
619
+ const services = this.getDiscoveredServices();
620
+ log.info(`Found ${services.length} services to resolve`);
621
+
622
+ const devices: AppleTVDevice[] = [];
623
+
624
+ for (const service of services) {
625
+ try {
626
+ log.info(`Attempting to resolve service: ${service.name}`);
627
+ const resolvedService = await this.resolveService(service.name);
628
+ const device = await convertToAppleTVDeviceWithIP(resolvedService);
629
+
630
+ if (device) {
631
+ devices.push(device);
632
+ }
633
+ } catch (error) {
634
+ log.warn(`Failed to resolve service ${service.name}: ${error}`);
635
+ }
636
+ }
637
+
638
+ return devices;
639
+ }
640
+
641
+ /**
642
+ * Cleanup resources
643
+ */
644
+ private cleanup(): void {
645
+ log.debug('Cleaning up BonjourDiscovery resources');
646
+ this._browseProcess = undefined;
647
+ this._isDiscovering = false;
648
+ this._discoveredServices.clear();
649
+ }
650
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Constants for Bonjour discovery
3
+ */
4
+
5
+ // Timeout values in milliseconds
6
+ export const BONJOUR_TIMEOUTS = {
7
+ BROWSE_STARTUP: 5000,
8
+ SERVICE_RESOLUTION: 10000,
9
+ DEFAULT_DISCOVERY: 5000,
10
+ } as const;
11
+
12
+ // Service types
13
+ export const BONJOUR_SERVICE_TYPES = {
14
+ APPLE_TV_PAIRING: '_remotepairing-manual-pairing._tcp',
15
+ } as const;
16
+
17
+ export const BONJOUR_DEFAULT_DOMAIN = 'local';
18
+
19
+ // DNS-SD command arguments
20
+ export const DNS_SD_COMMANDS = {
21
+ BROWSE: '-B',
22
+ RESOLVE: '-L',
23
+ } as const;
24
+
25
+ // DNS-SD action types
26
+ export const DNS_SD_ACTIONS = {
27
+ ADD: 'Add',
28
+ REMOVE: 'Rmv',
29
+ } as const;
30
+
31
+ // DNS-SD output patterns
32
+ export const DNS_SD_PATTERNS = {
33
+ STARTING: '...STARTING...',
34
+ BROWSE_LINE:
35
+ /^\s*(\d+:\d+:\d+\.\d+)\s+(Add|Rmv)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+)$/,
36
+ REACHABLE: /can be reached at ([^:]+):(\d+) \(interface (\d+)\)/,
37
+ TXT_RECORD:
38
+ /identifier=([^\s]+).*?authTag=([^\s]+).*?model=([^\s]+).*?name=([^\s]+).*?ver=([^\s]+).*?minVer=([^\s]+)/,
39
+ } as const;
@@ -0,0 +1,26 @@
1
+ import { type AppleTVDevice, BonjourDiscovery } from './bonjour-discovery.js';
2
+
3
+ export {
4
+ BONJOUR_TIMEOUTS,
5
+ BONJOUR_SERVICE_TYPES,
6
+ BONJOUR_DEFAULT_DOMAIN,
7
+ } from './constants.js';
8
+
9
+ /**
10
+ * Create a new Bonjour discovery instance
11
+ */
12
+ export function createBonjourDiscovery(): BonjourDiscovery {
13
+ return new BonjourDiscovery();
14
+ }
15
+
16
+ /**
17
+ * Discover Apple TV devices using Bonjour with IP address resolution
18
+ * @param timeoutMs - Discovery timeout in milliseconds (default: 5000)
19
+ * @returns Promise that resolves to an array of discovered Apple TV devices with resolved IP addresses
20
+ */
21
+ export async function discoverAppleTVDevicesWithIP(
22
+ timeoutMs: number = 5000,
23
+ ): Promise<AppleTVDevice[]> {
24
+ const discovery = createBonjourDiscovery();
25
+ return discovery.discoverAppleTVDevicesWithIP(timeoutMs);
26
+ }