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.
- package/CHANGELOG.md +21 -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 +8 -12
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [0.3.1](https://github.com/appium/appium-ios-remotexpc/compare/v0.3.0...v0.3.1) (2025-08-31)
|
|
2
|
+
|
|
3
|
+
### Miscellaneous Chores
|
|
4
|
+
|
|
5
|
+
* add bonjour service and AppleTV device info defaults ([#53](https://github.com/appium/appium-ios-remotexpc/issues/53)) ([a5924f8](https://github.com/appium/appium-ios-remotexpc/commit/a5924f8da8142dfd16219fe40f2421c528f534ce))
|
|
6
|
+
|
|
7
|
+
## [0.3.0](https://github.com/appium/appium-ios-remotexpc/compare/v0.2.0...v0.3.0) (2025-08-26)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* Tune Node.js engines ([#60](https://github.com/appium/appium-ios-remotexpc/issues/60)) ([ab3a259](https://github.com/appium/appium-ios-remotexpc/commit/ab3a25966569c4fa07c50aad250c91cf9a837507))
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* use 0.9+ explicitly for xmldom reps ([#63](https://github.com/appium/appium-ios-remotexpc/issues/63)) ([4940032](https://github.com/appium/appium-ios-remotexpc/commit/4940032d439e5dd6a4537c8b8dde8ed49be7ebb9))
|
|
16
|
+
|
|
17
|
+
### Miscellaneous Chores
|
|
18
|
+
|
|
19
|
+
* **deps:** bump actions/checkout from 3 to 4 ([#57](https://github.com/appium/appium-ios-remotexpc/issues/57)) ([4d80170](https://github.com/appium/appium-ios-remotexpc/commit/4d80170359af0b41edfb11016ecc08aa6867a28c))
|
|
20
|
+
* **deps:** bump actions/setup-node from 3 to 4 ([#58](https://github.com/appium/appium-ios-remotexpc/issues/58)) ([1f2abdd](https://github.com/appium/appium-ios-remotexpc/commit/1f2abddc315900825d5a94dd817b234a393e1f85))
|
|
21
|
+
|
|
1
22
|
## [0.2.0](https://github.com/appium/appium-ios-remotexpc/compare/v0.1.1...v0.2.0) (2025-08-14)
|
|
2
23
|
|
|
3
24
|
### Features
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AppleTVDeviceInfo } from '../types.js';
|
|
2
|
+
type OpackSerialized = Buffer;
|
|
3
|
+
/**
|
|
4
|
+
* Creates a standardized Apple TV device information object with default values
|
|
5
|
+
* and the specified identifier as the account ID.
|
|
6
|
+
*
|
|
7
|
+
* @param identifier - Unique identifier to use as the account ID
|
|
8
|
+
* @returns Complete Apple TV device information object
|
|
9
|
+
*/
|
|
10
|
+
export declare function createAppleTVDeviceInfo(identifier: string): AppleTVDeviceInfo;
|
|
11
|
+
/**
|
|
12
|
+
* Encodes Apple TV device information into a binary buffer using Opack2 serialization.
|
|
13
|
+
* This creates the device info object and immediately serializes it for transmission.
|
|
14
|
+
*
|
|
15
|
+
* @param identifier - Unique identifier to use as the account ID
|
|
16
|
+
* @returns Serialized device information as a Buffer
|
|
17
|
+
*/
|
|
18
|
+
export declare function encodeAppleTVDeviceInfo(identifier: string): OpackSerialized;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/lib/apple-tv/deviceInfo/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD,KAAK,eAAe,GAAG,MAAM,CAAC;AAU9B;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,GAAG,iBAAiB,CAU7E;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAM3E"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { Opack2 } from '../encryption/index.js';
|
|
3
|
+
const DEFAULT_ALT_IRK = Buffer.from([
|
|
4
|
+
0xe9, 0xe8, 0x2d, 0xc0, 0x6a, 0x49, 0x79, 0x6b, 0x56, 0x6f, 0x54, 0x00, 0x19,
|
|
5
|
+
0xb1, 0xc7, 0x7b,
|
|
6
|
+
]);
|
|
7
|
+
const DEFAULT_BT_ADDR = '11:22:33:44:55:66';
|
|
8
|
+
const DEFAULT_MAC_BUFFER = Buffer.from([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
|
|
9
|
+
const DEFAULT_PAIRING_SERIAL = 'AAAAAAAAAAAA';
|
|
10
|
+
/**
|
|
11
|
+
* Creates a standardized Apple TV device information object with default values
|
|
12
|
+
* and the specified identifier as the account ID.
|
|
13
|
+
*
|
|
14
|
+
* @param identifier - Unique identifier to use as the account ID
|
|
15
|
+
* @returns Complete Apple TV device information object
|
|
16
|
+
*/
|
|
17
|
+
export function createAppleTVDeviceInfo(identifier) {
|
|
18
|
+
return {
|
|
19
|
+
altIRK: DEFAULT_ALT_IRK,
|
|
20
|
+
btAddr: DEFAULT_BT_ADDR,
|
|
21
|
+
mac: DEFAULT_MAC_BUFFER,
|
|
22
|
+
remotePairingSerialNumber: DEFAULT_PAIRING_SERIAL,
|
|
23
|
+
accountID: identifier,
|
|
24
|
+
model: 'computer-model',
|
|
25
|
+
name: hostname(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Encodes Apple TV device information into a binary buffer using Opack2 serialization.
|
|
30
|
+
* This creates the device info object and immediately serializes it for transmission.
|
|
31
|
+
*
|
|
32
|
+
* @param identifier - Unique identifier to use as the account ID
|
|
33
|
+
* @returns Serialized device information as a Buffer
|
|
34
|
+
*/
|
|
35
|
+
export function encodeAppleTVDeviceInfo(identifier) {
|
|
36
|
+
const deviceInfo = createAppleTVDeviceInfo(identifier);
|
|
37
|
+
// Cast to SerializableValue to ensure type compatibility with Opack2.dumps
|
|
38
|
+
// The AppleTVDeviceInfo structure is compatible with OPACK2 serialization
|
|
39
|
+
return Opack2.dumps(deviceInfo);
|
|
40
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { AppleTVDeviceInfo, PairingKeys, PairingResult, PairingConfig, TLV8Item, PairingDataComponentTypeValue, Opack2Value, Opack2Array, Opack2Dictionary, } from './types.js';
|
|
2
|
+
export * from './errors.js';
|
|
3
|
+
export * from './constants.js';
|
|
4
|
+
export * from './utils/index.js';
|
|
5
|
+
export * from './deviceInfo/index.js';
|
|
6
|
+
export * from './encryption/index.js';
|
|
7
|
+
export * from './tlv/index.js';
|
|
8
|
+
export * from './srp/index.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/apple-tv/index.ts"],"names":[],"mappings":"AACA,YAAY,EACV,iBAAiB,EACjB,WAAW,EACX,aAAa,EACb,aAAa,EACb,QAAQ,EACR,6BAA6B,EAC7B,WAAW,EACX,WAAW,EACX,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/**
|
|
3
|
+
* Interface for a discovered Bonjour service
|
|
4
|
+
*/
|
|
5
|
+
export interface BonjourService {
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
domain: string;
|
|
9
|
+
hostname?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
txtRecord?: Record<string, string>;
|
|
12
|
+
interfaceIndex?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Interface for AppleTV device discovered via Bonjour
|
|
16
|
+
*/
|
|
17
|
+
export interface AppleTVDevice {
|
|
18
|
+
name: string;
|
|
19
|
+
identifier: string;
|
|
20
|
+
hostname: string;
|
|
21
|
+
ip?: string;
|
|
22
|
+
port: number;
|
|
23
|
+
model: string;
|
|
24
|
+
version: string;
|
|
25
|
+
minVersion: string;
|
|
26
|
+
authTag?: string;
|
|
27
|
+
interfaceIndex?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Type alias for service discovery results
|
|
31
|
+
*/
|
|
32
|
+
export type ServiceDiscoveryResult = Array<{
|
|
33
|
+
action: string;
|
|
34
|
+
service: BonjourService;
|
|
35
|
+
}>;
|
|
36
|
+
export declare class BonjourDiscovery extends EventEmitter {
|
|
37
|
+
private _browseProcess?;
|
|
38
|
+
private _isDiscovering;
|
|
39
|
+
private readonly _discoveredServices;
|
|
40
|
+
/**
|
|
41
|
+
* Start browsing for Bonjour services
|
|
42
|
+
*/
|
|
43
|
+
startBrowsing(serviceType?: string, domain?: string): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Stop browsing for services
|
|
46
|
+
*/
|
|
47
|
+
stopBrowsing(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Get all discovered services
|
|
50
|
+
*/
|
|
51
|
+
getDiscoveredServices(): BonjourService[];
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a specific service to get detailed information
|
|
54
|
+
*/
|
|
55
|
+
resolveService(serviceName: string, serviceType?: string, domain?: string): Promise<BonjourService>;
|
|
56
|
+
/**
|
|
57
|
+
* Discover Apple TV devices with IP address resolution
|
|
58
|
+
*/
|
|
59
|
+
discoverAppleTVDevicesWithIP(timeoutMs?: number): Promise<AppleTVDevice[]>;
|
|
60
|
+
/**
|
|
61
|
+
* Process browse output using the parser
|
|
62
|
+
*/
|
|
63
|
+
processBrowseOutput(output: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* Initialize a browsing process
|
|
66
|
+
*/
|
|
67
|
+
private initializeBrowsing;
|
|
68
|
+
/**
|
|
69
|
+
* Setup event handlers for an ongoing browse process
|
|
70
|
+
*/
|
|
71
|
+
private setupBrowseEventHandlers;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve all discovered services
|
|
74
|
+
*/
|
|
75
|
+
private resolveAllServices;
|
|
76
|
+
/**
|
|
77
|
+
* Cleanup resources
|
|
78
|
+
*/
|
|
79
|
+
private cleanup;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=bonjour-discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bonjour-discovery.d.ts","sourceRoot":"","sources":["../../../../src/lib/bonjour/bonjour-discovery.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAiB3C;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG,KAAK,CAAC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,cAAc,CAAC;CACzB,CAAC,CAAC;AA6XH,qBAAa,gBAAiB,SAAQ,YAAY;IAChD,OAAO,CAAC,cAAc,CAAC,CAAe;IACtC,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA0C;IAE9E;;OAEG;IACG,aAAa,CACjB,WAAW,GAAE,MAA+C,EAC5D,MAAM,GAAE,MAA+B,GACtC,OAAO,CAAC,IAAI,CAAC;IAgBhB;;OAEG;IACH,YAAY,IAAI,IAAI;IAQpB;;OAEG;IACH,qBAAqB,IAAI,cAAc,EAAE;IAIzC;;OAEG;IACG,cAAc,CAClB,WAAW,EAAE,MAAM,EACnB,WAAW,GAAE,MAA+C,EAC5D,MAAM,GAAE,MAA+B,GACtC,OAAO,CAAC,cAAc,CAAC;IAoB1B;;OAEG;IACG,4BAA4B,CAChC,SAAS,GAAE,MAA2C,GACrD,OAAO,CAAC,aAAa,EAAE,CAAC;IAmB3B;;OAEG;IACH,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAsBzC;;OAEG;YACW,kBAAkB;IA4BhC;;OAEG;IACH,OAAO,CAAC,wBAAwB;IAuBhC;;OAEG;YACW,kBAAkB;IAuBhC;;OAEG;IACH,OAAO,CAAC,OAAO;CAMhB"}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { logger } from '@appium/support';
|
|
2
|
+
import { 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
|
+
import { BONJOUR_DEFAULT_DOMAIN, BONJOUR_SERVICE_TYPES, BONJOUR_TIMEOUTS, DNS_SD_ACTIONS, DNS_SD_COMMANDS, DNS_SD_PATTERNS, } from './constants.js';
|
|
8
|
+
const log = logger.getLogger('BonjourDiscovery');
|
|
9
|
+
const DNS_SD_COMMAND = 'dns-sd';
|
|
10
|
+
/**
|
|
11
|
+
* Execute a dns-sd command with a timeout.
|
|
12
|
+
*
|
|
13
|
+
* @param args Arguments passed to the dns-sd CLI.
|
|
14
|
+
* @param timeoutMs Timeout in milliseconds.
|
|
15
|
+
* @param outputHandler Called for each stdout chunk from dns-sd:
|
|
16
|
+
* - returns true: chunk handled; no result recorded; continue listening
|
|
17
|
+
* - returns a truthy non-true value (e.g., object): store as result to return when the process ends
|
|
18
|
+
* - returns false/undefined/null: ignore; continue listening
|
|
19
|
+
* - throws: mark command as failed
|
|
20
|
+
* @param timeoutMessage Error message used if the command times out.
|
|
21
|
+
* @returns The value produced by outputHandler, if any, when the process ends.
|
|
22
|
+
*/
|
|
23
|
+
async function executeDnsSdCommand(args, timeoutMs, outputHandler, timeoutMessage) {
|
|
24
|
+
const child = spawn(DNS_SD_COMMAND, args);
|
|
25
|
+
try {
|
|
26
|
+
const result = await waitForProcessResult(child, timeoutMs, outputHandler, timeoutMessage);
|
|
27
|
+
if (!result.success) {
|
|
28
|
+
throw result.error || new Error('Process execution failed');
|
|
29
|
+
}
|
|
30
|
+
return result.data;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
if (!child.killed) {
|
|
34
|
+
child.kill('SIGTERM');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create a long-running browse process
|
|
40
|
+
*/
|
|
41
|
+
function createDnsSdBrowseProcess(serviceType, domain) {
|
|
42
|
+
return spawn(DNS_SD_COMMAND, [DNS_SD_COMMANDS.BROWSE, serviceType, domain]);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Wait for a child process result with a timeout.
|
|
46
|
+
*
|
|
47
|
+
* @param process Child process to observe.
|
|
48
|
+
* @param timeoutMs Timeout in milliseconds.
|
|
49
|
+
* @param outputHandler Called for each stdout chunk from dns-sd:
|
|
50
|
+
* - returns true: chunk handled; no result recorded; continue listening
|
|
51
|
+
* - returns a truthy non-true value (e.g., object): store as result to return when the process ends
|
|
52
|
+
* - returns false/undefined/null: ignore; continue listening
|
|
53
|
+
* - throws: mark command as failed
|
|
54
|
+
* @param timeoutMessage Error message used if the operation times out.
|
|
55
|
+
* @returns A ProcessResult indicating success and any data from outputHandler.
|
|
56
|
+
*/
|
|
57
|
+
async function waitForProcessResult(process, timeoutMs, outputHandler, timeoutMessage) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let isResolved = false;
|
|
60
|
+
let result;
|
|
61
|
+
let exitCode = null;
|
|
62
|
+
let hasError = false;
|
|
63
|
+
let errorMessage = '';
|
|
64
|
+
const timeout = setTimeout(() => {
|
|
65
|
+
if (!isResolved) {
|
|
66
|
+
isResolved = true;
|
|
67
|
+
reject(new Error(timeoutMessage));
|
|
68
|
+
}
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
const cleanup = () => {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
if (!isResolved) {
|
|
73
|
+
isResolved = true;
|
|
74
|
+
if (hasError) {
|
|
75
|
+
reject(new Error(errorMessage));
|
|
76
|
+
}
|
|
77
|
+
else if (exitCode !== null && exitCode !== 0) {
|
|
78
|
+
reject(new Error(`Process exited with code ${exitCode}`));
|
|
79
|
+
}
|
|
80
|
+
else if (result !== undefined) {
|
|
81
|
+
resolve({ success: true, data: result });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
resolve({ success: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
process.stdout?.on('data', (data) => {
|
|
89
|
+
if (isResolved) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const output = data.toString();
|
|
93
|
+
log.debug(`[dns-sd] output: ${output}`);
|
|
94
|
+
try {
|
|
95
|
+
const handlerResult = outputHandler(output);
|
|
96
|
+
if (handlerResult === true) {
|
|
97
|
+
result = undefined;
|
|
98
|
+
}
|
|
99
|
+
else if (handlerResult) {
|
|
100
|
+
result = handlerResult;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
hasError = true;
|
|
105
|
+
errorMessage = `Output handler error: ${error}`;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
process.stderr?.on('data', (data) => {
|
|
109
|
+
if (isResolved) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const error = data.toString();
|
|
113
|
+
log.error(`[dns-sd] error: ${error}`);
|
|
114
|
+
hasError = true;
|
|
115
|
+
errorMessage = `Process failed: ${error}`;
|
|
116
|
+
});
|
|
117
|
+
process.on('error', (error) => {
|
|
118
|
+
if (isResolved) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
log.error(`[dns-sd] failed to start process: ${error}`);
|
|
122
|
+
hasError = true;
|
|
123
|
+
errorMessage = `Failed to start process: ${error}`;
|
|
124
|
+
});
|
|
125
|
+
process.on('exit', (code) => {
|
|
126
|
+
exitCode = code;
|
|
127
|
+
if (code !== null && code !== 0) {
|
|
128
|
+
log.error(`[dns-sd] process exited with error code: ${code}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
process.on('close', (code) => {
|
|
132
|
+
log.debug(`[dns-sd] process closed with code: ${code}`);
|
|
133
|
+
cleanup();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Parse browse output and extract service information
|
|
139
|
+
*/
|
|
140
|
+
function parseBrowseOutput(output) {
|
|
141
|
+
const reducer = (acc, line) => {
|
|
142
|
+
const match = line.match(DNS_SD_PATTERNS.BROWSE_LINE);
|
|
143
|
+
if (!match) {
|
|
144
|
+
return acc;
|
|
145
|
+
}
|
|
146
|
+
const [, , action, , interfaceIndex, domain, serviceType, name] = match;
|
|
147
|
+
const trimmedName = name.trim();
|
|
148
|
+
const service = {
|
|
149
|
+
name: trimmedName,
|
|
150
|
+
type: serviceType,
|
|
151
|
+
domain,
|
|
152
|
+
interfaceIndex: parseInt(interfaceIndex, 10),
|
|
153
|
+
};
|
|
154
|
+
acc.push({ action, service });
|
|
155
|
+
return acc;
|
|
156
|
+
};
|
|
157
|
+
return output
|
|
158
|
+
.split('\n')
|
|
159
|
+
.filter((line) => !shouldSkipLine(line))
|
|
160
|
+
.reduce(reducer, []);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Parse resolve output and extract service details
|
|
164
|
+
*/
|
|
165
|
+
function parseResolveOutput(output, serviceName, serviceType, domain) {
|
|
166
|
+
return parseOutput(output, (line, result) => {
|
|
167
|
+
// If we already found a result, return it (early termination)
|
|
168
|
+
if (result) {
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
const reachableMatch = line.match(DNS_SD_PATTERNS.REACHABLE);
|
|
172
|
+
if (reachableMatch) {
|
|
173
|
+
const [, hostname, port, interfaceIndex] = reachableMatch;
|
|
174
|
+
const txtRecord = parseTxtRecord(output);
|
|
175
|
+
return {
|
|
176
|
+
name: serviceName,
|
|
177
|
+
type: serviceType,
|
|
178
|
+
domain,
|
|
179
|
+
hostname,
|
|
180
|
+
port: parseInt(port, 10),
|
|
181
|
+
txtRecord,
|
|
182
|
+
interfaceIndex: parseInt(interfaceIndex, 10),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}, null);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Generic method to parse output with different reducer functions
|
|
190
|
+
*/
|
|
191
|
+
function parseOutput(output, reducer, initialValue) {
|
|
192
|
+
const lines = output.split('\n');
|
|
193
|
+
let result = initialValue;
|
|
194
|
+
for (const line of lines) {
|
|
195
|
+
if (shouldSkipLine(line)) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
result = reducer(line, result);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Parse TXT record from output
|
|
204
|
+
*/
|
|
205
|
+
function parseTxtRecord(output) {
|
|
206
|
+
const txtRecord = {};
|
|
207
|
+
const txtMatch = output.match(DNS_SD_PATTERNS.TXT_RECORD);
|
|
208
|
+
if (txtMatch) {
|
|
209
|
+
const [, identifier, authTag, model, name, ver, minVer] = txtMatch;
|
|
210
|
+
txtRecord.identifier = identifier;
|
|
211
|
+
txtRecord.authTag = authTag;
|
|
212
|
+
txtRecord.model = model;
|
|
213
|
+
txtRecord.name = name;
|
|
214
|
+
txtRecord.ver = ver;
|
|
215
|
+
txtRecord.minVer = minVer;
|
|
216
|
+
}
|
|
217
|
+
return txtRecord;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check if line should be skipped
|
|
221
|
+
*/
|
|
222
|
+
function shouldSkipLine(line) {
|
|
223
|
+
return (line.includes('Timestamp') || line.includes('---') || line.trim() === '');
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Resolve hostname to IP address
|
|
227
|
+
*/
|
|
228
|
+
async function resolveIPAddress(hostname) {
|
|
229
|
+
try {
|
|
230
|
+
const address = await resolve4(hostname);
|
|
231
|
+
log.info(`[ServiceResolver] Resolved ${hostname} to IPv4: ${address}`);
|
|
232
|
+
return address;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
log.warn(`[ServiceResolver] Failed to resolve hostname ${hostname} to IPv4: ${error}`);
|
|
236
|
+
// For .local hostnames, try without the trailing dot
|
|
237
|
+
if (hostname.endsWith('.local.')) {
|
|
238
|
+
const cleanHostname = hostname.slice(0, -1); // Remove trailing dot
|
|
239
|
+
try {
|
|
240
|
+
const address = await resolve4(cleanHostname);
|
|
241
|
+
log.info(`[ServiceResolver] Resolved ${cleanHostname} to IPv4: ${address}`);
|
|
242
|
+
return address;
|
|
243
|
+
}
|
|
244
|
+
catch (retryError) {
|
|
245
|
+
log.warn(`[ServiceResolver] Failed to resolve ${cleanHostname} to IPv4: ${retryError}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Convert a resolved Bonjour service to an Apple TV device with IP resolution
|
|
253
|
+
*/
|
|
254
|
+
async function convertToAppleTVDeviceWithIP(service) {
|
|
255
|
+
if (!isValidService(service)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const { txtRecord, hostname, port } = service;
|
|
259
|
+
if (!txtRecord || !hasRequiredTxtFields(txtRecord)) {
|
|
260
|
+
log.warn(`[AppleTVDeviceConverter] Service ${service.name} missing required TXT record fields`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (!hostname || !port) {
|
|
264
|
+
log.warn(`[AppleTVDeviceConverter] Service ${service.name} missing hostname or port`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const ipAddresses = await resolveIPAddress(hostname);
|
|
268
|
+
// Select default first one
|
|
269
|
+
// TODO: needs a decision to select from cli, if the user wants to select from the available ip's
|
|
270
|
+
const ip = ipAddresses?.[0];
|
|
271
|
+
return {
|
|
272
|
+
name: service.name,
|
|
273
|
+
identifier: txtRecord.identifier,
|
|
274
|
+
hostname,
|
|
275
|
+
ip,
|
|
276
|
+
port,
|
|
277
|
+
model: txtRecord.model,
|
|
278
|
+
version: txtRecord.ver,
|
|
279
|
+
minVersion: txtRecord.minVer || '17',
|
|
280
|
+
authTag: txtRecord.authTag,
|
|
281
|
+
interfaceIndex: service.interfaceIndex,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Check if the service has required fields
|
|
286
|
+
*/
|
|
287
|
+
function isValidService(service) {
|
|
288
|
+
return Boolean(service.hostname && service.port && service.txtRecord);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Check if TXT record has required fields
|
|
292
|
+
*/
|
|
293
|
+
function hasRequiredTxtFields(txtRecord) {
|
|
294
|
+
return Boolean(txtRecord.identifier && txtRecord.model && txtRecord.ver);
|
|
295
|
+
}
|
|
296
|
+
/* =========================
|
|
297
|
+
* Main Bonjour discovery service orchestrator
|
|
298
|
+
* =========================
|
|
299
|
+
*/
|
|
300
|
+
export class BonjourDiscovery extends EventEmitter {
|
|
301
|
+
_browseProcess;
|
|
302
|
+
_isDiscovering = false;
|
|
303
|
+
_discoveredServices = new Map();
|
|
304
|
+
/**
|
|
305
|
+
* Start browsing for Bonjour services
|
|
306
|
+
*/
|
|
307
|
+
async startBrowsing(serviceType = BONJOUR_SERVICE_TYPES.APPLE_TV_PAIRING, domain = BONJOUR_DEFAULT_DOMAIN) {
|
|
308
|
+
if (this._isDiscovering) {
|
|
309
|
+
log.warn('Already discovering services');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
log.info(`Starting Bonjour discovery for ${serviceType}.${domain}`);
|
|
313
|
+
try {
|
|
314
|
+
await this.initializeBrowsing(serviceType, domain);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
this.cleanup();
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Stop browsing for services
|
|
323
|
+
*/
|
|
324
|
+
stopBrowsing() {
|
|
325
|
+
if (this._browseProcess && !this._browseProcess.killed) {
|
|
326
|
+
log.info('Stopping Bonjour discovery');
|
|
327
|
+
this._browseProcess.kill('SIGTERM');
|
|
328
|
+
}
|
|
329
|
+
this.cleanup();
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get all discovered services
|
|
333
|
+
*/
|
|
334
|
+
getDiscoveredServices() {
|
|
335
|
+
return Array.from(this._discoveredServices.values());
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Resolve a specific service to get detailed information
|
|
339
|
+
*/
|
|
340
|
+
async resolveService(serviceName, serviceType = BONJOUR_SERVICE_TYPES.APPLE_TV_PAIRING, domain = BONJOUR_DEFAULT_DOMAIN) {
|
|
341
|
+
log.info(`[ServiceResolver] Resolving service: ${serviceName}.${serviceType}.${domain}`);
|
|
342
|
+
const service = await executeDnsSdCommand([DNS_SD_COMMANDS.RESOLVE, serviceName, serviceType, domain], BONJOUR_TIMEOUTS.SERVICE_RESOLUTION, (output) => parseResolveOutput(output, serviceName, serviceType, domain), `Service resolution timeout for ${serviceName}`);
|
|
343
|
+
if (!service) {
|
|
344
|
+
throw new Error(`Failed to resolve service ${serviceName}`);
|
|
345
|
+
}
|
|
346
|
+
return service;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Discover Apple TV devices with IP address resolution
|
|
350
|
+
*/
|
|
351
|
+
async discoverAppleTVDevicesWithIP(timeoutMs = BONJOUR_TIMEOUTS.DEFAULT_DISCOVERY) {
|
|
352
|
+
log.info('Starting Apple TV device discovery with IP resolution');
|
|
353
|
+
try {
|
|
354
|
+
await this.startBrowsing();
|
|
355
|
+
await delay(timeoutMs);
|
|
356
|
+
const devices = await this.resolveAllServices();
|
|
357
|
+
log.info(`Discovered ${devices.length} Apple TV device(s) with IP addresses:`, devices);
|
|
358
|
+
return devices;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
this.stopBrowsing();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Process browse output using the parser
|
|
366
|
+
*/
|
|
367
|
+
processBrowseOutput(output) {
|
|
368
|
+
const results = parseBrowseOutput(output);
|
|
369
|
+
for (const { action, service } of results) {
|
|
370
|
+
switch (action) {
|
|
371
|
+
case DNS_SD_ACTIONS.ADD:
|
|
372
|
+
this._discoveredServices.set(service.name, service);
|
|
373
|
+
this.emit('serviceAdded', service);
|
|
374
|
+
log.info(`Discovered service: ${service.name}`);
|
|
375
|
+
break;
|
|
376
|
+
case DNS_SD_ACTIONS.REMOVE:
|
|
377
|
+
this._discoveredServices.delete(service.name);
|
|
378
|
+
this.emit('serviceRemoved', service.name);
|
|
379
|
+
log.info(`Service removed: ${service.name}`);
|
|
380
|
+
break;
|
|
381
|
+
default:
|
|
382
|
+
log.debug(`Unknown action: ${action}`);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Initialize a browsing process
|
|
389
|
+
*/
|
|
390
|
+
async initializeBrowsing(serviceType, domain) {
|
|
391
|
+
this._isDiscovering = true;
|
|
392
|
+
this._discoveredServices.clear();
|
|
393
|
+
const browseProcess = createDnsSdBrowseProcess(serviceType, domain);
|
|
394
|
+
this._browseProcess = browseProcess;
|
|
395
|
+
try {
|
|
396
|
+
await executeDnsSdCommand([DNS_SD_COMMANDS.BROWSE, serviceType, domain], BONJOUR_TIMEOUTS.BROWSE_STARTUP, (output) => {
|
|
397
|
+
this.processBrowseOutput(output);
|
|
398
|
+
return output.includes(DNS_SD_PATTERNS.STARTING);
|
|
399
|
+
}, 'DNS-SD browse startup timeout');
|
|
400
|
+
this.setupBrowseEventHandlers(browseProcess);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
this._isDiscovering = false;
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Setup event handlers for an ongoing browse process
|
|
409
|
+
*/
|
|
410
|
+
setupBrowseEventHandlers(process) {
|
|
411
|
+
process.stdout?.on('data', (data) => {
|
|
412
|
+
const output = data.toString();
|
|
413
|
+
log.debug(`dns-sd browse output: ${output}`);
|
|
414
|
+
this.processBrowseOutput(output);
|
|
415
|
+
});
|
|
416
|
+
process.stderr?.on('data', (data) => {
|
|
417
|
+
const error = data.toString();
|
|
418
|
+
log.error(`dns-sd browse error: ${error}`);
|
|
419
|
+
});
|
|
420
|
+
process.on('exit', (code) => {
|
|
421
|
+
if (code !== null && code !== 0) {
|
|
422
|
+
log.error(`dns-sd browse process exited with error code: ${code}`);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
process.on('close', (code) => {
|
|
426
|
+
log.debug(`dns-sd browse process closed with code: ${code}`);
|
|
427
|
+
this.cleanup();
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Resolve all discovered services
|
|
432
|
+
*/
|
|
433
|
+
async resolveAllServices() {
|
|
434
|
+
const services = this.getDiscoveredServices();
|
|
435
|
+
log.info(`Found ${services.length} services to resolve`);
|
|
436
|
+
const devices = [];
|
|
437
|
+
for (const service of services) {
|
|
438
|
+
try {
|
|
439
|
+
log.info(`Attempting to resolve service: ${service.name}`);
|
|
440
|
+
const resolvedService = await this.resolveService(service.name);
|
|
441
|
+
const device = await convertToAppleTVDeviceWithIP(resolvedService);
|
|
442
|
+
if (device) {
|
|
443
|
+
devices.push(device);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
log.warn(`Failed to resolve service ${service.name}: ${error}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return devices;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Cleanup resources
|
|
454
|
+
*/
|
|
455
|
+
cleanup() {
|
|
456
|
+
log.debug('Cleaning up BonjourDiscovery resources');
|
|
457
|
+
this._browseProcess = undefined;
|
|
458
|
+
this._isDiscovering = false;
|
|
459
|
+
this._discoveredServices.clear();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants for Bonjour discovery
|
|
3
|
+
*/
|
|
4
|
+
export declare const BONJOUR_TIMEOUTS: {
|
|
5
|
+
readonly BROWSE_STARTUP: 5000;
|
|
6
|
+
readonly SERVICE_RESOLUTION: 10000;
|
|
7
|
+
readonly DEFAULT_DISCOVERY: 5000;
|
|
8
|
+
};
|
|
9
|
+
export declare const BONJOUR_SERVICE_TYPES: {
|
|
10
|
+
readonly APPLE_TV_PAIRING: "_remotepairing-manual-pairing._tcp";
|
|
11
|
+
};
|
|
12
|
+
export declare const BONJOUR_DEFAULT_DOMAIN = "local";
|
|
13
|
+
export declare const DNS_SD_COMMANDS: {
|
|
14
|
+
readonly BROWSE: "-B";
|
|
15
|
+
readonly RESOLVE: "-L";
|
|
16
|
+
};
|
|
17
|
+
export declare const DNS_SD_ACTIONS: {
|
|
18
|
+
readonly ADD: "Add";
|
|
19
|
+
readonly REMOVE: "Rmv";
|
|
20
|
+
};
|
|
21
|
+
export declare const DNS_SD_PATTERNS: {
|
|
22
|
+
readonly STARTING: "...STARTING...";
|
|
23
|
+
readonly BROWSE_LINE: RegExp;
|
|
24
|
+
readonly REACHABLE: RegExp;
|
|
25
|
+
readonly TXT_RECORD: RegExp;
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=constants.d.ts.map
|