appium-ios-remotexpc 0.3.0 → 0.3.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/build/src/lib/apple-tv/deviceInfo/index.d.ts +20 -0
- package/build/src/lib/apple-tv/deviceInfo/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/deviceInfo/index.js +40 -0
- package/build/src/lib/apple-tv/index.d.ts +9 -0
- package/build/src/lib/apple-tv/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/index.js +7 -0
- package/build/src/lib/bonjour/bonjour-discovery.d.ts +81 -0
- package/build/src/lib/bonjour/bonjour-discovery.d.ts.map +1 -0
- package/build/src/lib/bonjour/bonjour-discovery.js +461 -0
- package/build/src/lib/bonjour/constants.d.ts +27 -0
- package/build/src/lib/bonjour/constants.d.ts.map +1 -0
- package/build/src/lib/bonjour/constants.js +31 -0
- package/build/src/lib/bonjour/index.d.ts +13 -0
- package/build/src/lib/bonjour/index.d.ts.map +1 -0
- package/build/src/lib/bonjour/index.js +17 -0
- package/package.json +5 -5
- package/src/lib/apple-tv/deviceInfo/index.ts +48 -0
- package/src/lib/apple-tv/index.ts +19 -0
- package/src/lib/bonjour/bonjour-discovery.ts +650 -0
- package/src/lib/bonjour/constants.ts +39 -0
- package/src/lib/bonjour/index.ts +26 -0
|
@@ -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
|
+
}
|