appium-ios-remotexpc 0.12.0 → 0.13.0
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 +6 -0
- package/build/src/constants.d.ts +2 -0
- package/build/src/constants.d.ts.map +1 -0
- package/build/src/constants.js +3 -0
- package/build/src/index.d.ts +2 -1
- package/build/src/index.d.ts.map +1 -1
- package/build/src/index.js +2 -1
- package/build/src/lib/apple-tv/constants.d.ts +0 -1
- package/build/src/lib/apple-tv/constants.d.ts.map +1 -1
- package/build/src/lib/apple-tv/constants.js +0 -1
- package/build/src/lib/apple-tv/discovery/device-discovery.d.ts +10 -0
- package/build/src/lib/apple-tv/discovery/device-discovery.d.ts.map +1 -0
- package/build/src/lib/apple-tv/discovery/device-discovery.js +22 -0
- package/build/src/lib/apple-tv/discovery/index.d.ts +2 -0
- package/build/src/lib/apple-tv/discovery/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/discovery/index.js +1 -0
- package/build/src/lib/apple-tv/index.d.ts +5 -0
- package/build/src/lib/apple-tv/index.d.ts.map +1 -1
- package/build/src/lib/apple-tv/index.js +5 -0
- package/build/src/lib/apple-tv/network/constants.d.ts +10 -0
- package/build/src/lib/apple-tv/network/constants.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/constants.js +9 -0
- package/build/src/lib/apple-tv/network/index.d.ts +4 -0
- package/build/src/lib/apple-tv/network/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/index.js +2 -0
- package/build/src/lib/apple-tv/network/network-client.d.ts +16 -0
- package/build/src/lib/apple-tv/network/network-client.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/network-client.js +169 -0
- package/build/src/lib/apple-tv/network/types.d.ts +8 -0
- package/build/src/lib/apple-tv/network/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/network/types.js +1 -0
- package/build/src/lib/apple-tv/pairing/index.d.ts +3 -0
- package/build/src/lib/apple-tv/pairing/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing/index.js +2 -0
- package/build/src/lib/apple-tv/pairing/pairing-service.d.ts +15 -0
- package/build/src/lib/apple-tv/pairing/pairing-service.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing/pairing-service.js +112 -0
- package/build/src/lib/apple-tv/pairing/user-input-service.d.ts +8 -0
- package/build/src/lib/apple-tv/pairing/user-input-service.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing/user-input-service.js +61 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts +18 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/constants.js +17 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.d.ts +4 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/index.js +2 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts +159 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.js +494 -0
- package/build/src/lib/apple-tv/pairing-protocol/types.d.ts +57 -0
- package/build/src/lib/apple-tv/pairing-protocol/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/pairing-protocol/types.js +1 -0
- package/build/src/lib/apple-tv/storage/index.d.ts +3 -0
- package/build/src/lib/apple-tv/storage/index.d.ts.map +1 -0
- package/build/src/lib/apple-tv/storage/index.js +1 -0
- package/build/src/lib/apple-tv/storage/pairing-storage.d.ts +12 -0
- package/build/src/lib/apple-tv/storage/pairing-storage.d.ts.map +1 -0
- package/build/src/lib/apple-tv/storage/pairing-storage.js +36 -0
- package/build/src/lib/apple-tv/storage/types.d.ts +5 -0
- package/build/src/lib/apple-tv/storage/types.d.ts.map +1 -0
- package/build/src/lib/apple-tv/storage/types.js +1 -0
- package/build/src/lib/apple-tv/types.d.ts +0 -1
- package/build/src/lib/apple-tv/types.d.ts.map +1 -1
- package/build/src/lib/bonjour/bonjour-discovery.d.ts.map +1 -1
- package/build/src/lib/bonjour/bonjour-discovery.js +2 -0
- package/package.json +4 -2
- package/scripts/pair-appletv.ts +79 -0
- package/scripts/test-tunnel-creation.ts +1 -1
- package/src/constants.ts +4 -0
- package/src/index.ts +2 -0
- package/src/lib/apple-tv/constants.ts +0 -1
- package/src/lib/apple-tv/discovery/device-discovery.ts +34 -0
- package/src/lib/apple-tv/discovery/index.ts +1 -0
- package/src/lib/apple-tv/index.ts +5 -0
- package/src/lib/apple-tv/network/constants.ts +9 -0
- package/src/lib/apple-tv/network/index.ts +3 -0
- package/src/lib/apple-tv/network/network-client.ts +214 -0
- package/src/lib/apple-tv/network/types.ts +7 -0
- package/src/lib/apple-tv/pairing/index.ts +2 -0
- package/src/lib/apple-tv/pairing/pairing-service.ts +175 -0
- package/src/lib/apple-tv/pairing/user-input-service.ts +71 -0
- package/src/lib/apple-tv/pairing-protocol/constants.ts +19 -0
- package/src/lib/apple-tv/pairing-protocol/index.ts +8 -0
- package/src/lib/apple-tv/pairing-protocol/pairing-protocol.ts +636 -0
- package/src/lib/apple-tv/pairing-protocol/types.ts +60 -0
- package/src/lib/apple-tv/storage/index.ts +2 -0
- package/src/lib/apple-tv/storage/pairing-storage.ts +60 -0
- package/src/lib/apple-tv/storage/types.ts +9 -0
- package/src/lib/apple-tv/types.ts +0 -1
- package/src/lib/bonjour/bonjour-discovery.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appium-ios-remotexpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"main": "build/src/index.js",
|
|
5
5
|
"types": "build/src/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -32,11 +32,13 @@
|
|
|
32
32
|
"test:image-mounter": "mocha test/integration/mobile-image-mounter-test.ts --exit --timeout 1m",
|
|
33
33
|
"test:mobile-config": "mocha test/integration/mobile-config-test.ts --exit --timeout 1m",
|
|
34
34
|
"test:springboard": "mocha test/integration/springboard-service-test.ts --exit --timeout 1m",
|
|
35
|
+
"test:unit": "NODE_ENV=test mocha 'test/unit/**/*.ts' --exit --timeout 2m",
|
|
36
|
+
"tunnel-creation": "sudo tsx scripts/test-tunnel-creation.ts",
|
|
37
|
+
"pair-appletv": "sudo tsx scripts/pair-appletv.ts",
|
|
35
38
|
"test:webinspector": "mocha test/integration/webinspector-test.ts --exit --timeout 1m",
|
|
36
39
|
"test:misagent": "mocha test/integration/misagent-service-test.ts --exit --timeout 1m",
|
|
37
40
|
"test:afc": "mocha test/integration/afc-test.ts --exit --timeout 1m",
|
|
38
41
|
"test:power-assertion": "mocha test/integration/power-assertion-test.ts --exit --timeout 1m",
|
|
39
|
-
"test:unit": "mocha 'test/unit/**/*.ts' --exit --timeout 2m",
|
|
40
42
|
"test:tunnel-creation": "sudo tsx scripts/test-tunnel-creation.ts",
|
|
41
43
|
"test:tunnel-creation:lsof": "sudo tsx scripts/test-tunnel-creation.ts --keep-open"
|
|
42
44
|
},
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { logger } from '@appium/support';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AppleTVPairingService,
|
|
6
|
+
UserInputService,
|
|
7
|
+
} from '../src/lib/apple-tv/index.js';
|
|
8
|
+
|
|
9
|
+
interface CLIArgs {
|
|
10
|
+
device?: string;
|
|
11
|
+
help?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseArgs(): CLIArgs {
|
|
15
|
+
const args: CLIArgs = {};
|
|
16
|
+
const cliArgs = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < cliArgs.length; i++) {
|
|
19
|
+
const arg = cliArgs[i];
|
|
20
|
+
if (arg === '--device' || arg === '-d') {
|
|
21
|
+
args.device = cliArgs[++i];
|
|
22
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
23
|
+
args.help = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return args;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printHelp(): void {
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.log(`
|
|
33
|
+
Apple TV Pairing Script
|
|
34
|
+
|
|
35
|
+
Usage: pair-appletv [options]
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
-d, --device <selector> Specify device to pair with. Can be:
|
|
39
|
+
- Device name (e.g., "Living Room")
|
|
40
|
+
- Device identifier (e.g., "AA:BB:CC:DD:EE:FF")
|
|
41
|
+
- Device index (e.g., "0", "1", "2")
|
|
42
|
+
If not specified and multiple devices are found,
|
|
43
|
+
you will be prompted to choose one.
|
|
44
|
+
-h, --help Show this help message
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
pair-appletv # Discover and select device interactively
|
|
48
|
+
pair-appletv --device "Living Room" # Pair with device named "Living Room"
|
|
49
|
+
pair-appletv --device 0 # Pair with first discovered device
|
|
50
|
+
pair-appletv -d AA:BB:CC:DD:EE:FF # Pair with device by identifier
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// CLI interface
|
|
55
|
+
async function main(): Promise<void> {
|
|
56
|
+
const log = logger.getLogger('AppleTVPairing');
|
|
57
|
+
const args = parseArgs();
|
|
58
|
+
|
|
59
|
+
if (args.help) {
|
|
60
|
+
printHelp();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const userInput = new UserInputService();
|
|
65
|
+
const pairingService = new AppleTVPairingService(userInput);
|
|
66
|
+
const result = await pairingService.discoverAndPair(args.device);
|
|
67
|
+
|
|
68
|
+
if (result.success) {
|
|
69
|
+
log.info(`Pairing successful! Record saved to: ${result.pairingFile}`);
|
|
70
|
+
} else {
|
|
71
|
+
const error = result.error ?? new Error('Pairing failed');
|
|
72
|
+
log.error(`Pairing failed: ${error.message}`);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch(() => {
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
package/src/constants.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { STRONGBOX_CONTAINER_NAME } from './constants.js';
|
|
1
2
|
import { createLockdownServiceByUDID } from './lib/lockdown/index.js';
|
|
2
3
|
import {
|
|
3
4
|
PacketStreamClient,
|
|
@@ -38,6 +39,7 @@ export type {
|
|
|
38
39
|
} from './lib/types.js';
|
|
39
40
|
export { PowerAssertionType } from './lib/types.js';
|
|
40
41
|
export {
|
|
42
|
+
STRONGBOX_CONTAINER_NAME,
|
|
41
43
|
createUsbmux,
|
|
42
44
|
Services,
|
|
43
45
|
Usbmux,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { logger } from '@appium/support';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AppleTVDevice,
|
|
5
|
+
BonjourDiscovery,
|
|
6
|
+
} from '../../bonjour/bonjour-discovery.js';
|
|
7
|
+
import { PairingError } from '../errors.js';
|
|
8
|
+
import type { PairingConfig } from '../types.js';
|
|
9
|
+
|
|
10
|
+
/** Discovers Apple TV devices on the local network using Bonjour */
|
|
11
|
+
export class DeviceDiscoveryService {
|
|
12
|
+
private readonly log = logger.getLogger('DeviceDiscoveryService');
|
|
13
|
+
|
|
14
|
+
constructor(private readonly config: PairingConfig) {}
|
|
15
|
+
|
|
16
|
+
async discoverDevices(): Promise<AppleTVDevice[]> {
|
|
17
|
+
try {
|
|
18
|
+
const discovery = new BonjourDiscovery();
|
|
19
|
+
this.log.info(
|
|
20
|
+
`Discovering Apple TV devices (waiting ${this.config.discoveryTimeout / 1000} seconds)...`,
|
|
21
|
+
);
|
|
22
|
+
return await discovery.discoverAppleTVDevicesWithIP(
|
|
23
|
+
this.config.discoveryTimeout,
|
|
24
|
+
);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
this.log.error('Device discovery failed:', error);
|
|
27
|
+
throw new PairingError(
|
|
28
|
+
'Device discovery failed',
|
|
29
|
+
'DISCOVERY_ERROR',
|
|
30
|
+
error,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DeviceDiscoveryService } from './device-discovery.js';
|
|
@@ -17,3 +17,8 @@ export * from './deviceInfo/index.js';
|
|
|
17
17
|
export * from './encryption/index.js';
|
|
18
18
|
export * from './tlv/index.js';
|
|
19
19
|
export * from './srp/index.js';
|
|
20
|
+
export * from './network/index.js';
|
|
21
|
+
export * from './pairing-protocol/index.js';
|
|
22
|
+
export * from './storage/index.js';
|
|
23
|
+
export * from './discovery/index.js';
|
|
24
|
+
export * from './pairing/index.js';
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { logger } from '@appium/support';
|
|
2
|
+
import * as net from 'node:net';
|
|
3
|
+
|
|
4
|
+
import { NetworkError } from '../errors.js';
|
|
5
|
+
import type { PairingConfig } from '../types.js';
|
|
6
|
+
import { NETWORK_CONSTANTS } from './constants.js';
|
|
7
|
+
import type { NetworkClientInterface } from './types.js';
|
|
8
|
+
|
|
9
|
+
const log = logger.getLogger('NetworkClient');
|
|
10
|
+
|
|
11
|
+
/** Handles TCP socket communication with Apple TV devices */
|
|
12
|
+
export class NetworkClient implements NetworkClientInterface {
|
|
13
|
+
private socket: net.Socket | null = null;
|
|
14
|
+
private connectionTimeoutId: NodeJS.Timeout | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly config: PairingConfig) {}
|
|
17
|
+
|
|
18
|
+
async connect(ip: string, port: number): Promise<void> {
|
|
19
|
+
log.debug(`Connecting to ${ip}:${port}`);
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const cancelTimeout = () => {
|
|
23
|
+
if (this.connectionTimeoutId) {
|
|
24
|
+
clearTimeout(this.connectionTimeoutId);
|
|
25
|
+
this.connectionTimeoutId = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
this.socket = new net.Socket();
|
|
30
|
+
this.socket.setTimeout(this.config.timeout);
|
|
31
|
+
|
|
32
|
+
this.socket.once('connect', () => {
|
|
33
|
+
log.debug('Connected successfully');
|
|
34
|
+
cancelTimeout();
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.socket.once('error', (error) => {
|
|
39
|
+
log.error('Connection error:', error);
|
|
40
|
+
cancelTimeout();
|
|
41
|
+
reject(new NetworkError(`Connection failed: ${error.message}`));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.socket.once('timeout', () => {
|
|
45
|
+
log.error('Socket timeout');
|
|
46
|
+
cancelTimeout();
|
|
47
|
+
reject(new NetworkError('Socket timeout'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.socket.once('close', () => {
|
|
51
|
+
cancelTimeout();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.connectionTimeoutId = setTimeout(() => {
|
|
55
|
+
log.error('Connection attempt timeout');
|
|
56
|
+
this.cleanup();
|
|
57
|
+
reject(
|
|
58
|
+
new NetworkError(`Connection timeout after ${this.config.timeout}ms`),
|
|
59
|
+
);
|
|
60
|
+
}, this.config.timeout);
|
|
61
|
+
|
|
62
|
+
this.socket.connect(port, ip);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async sendPacket(data: any): Promise<void> {
|
|
67
|
+
if (!this.socket) {
|
|
68
|
+
throw new NetworkError('Socket not connected');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const packet = this.createRPPairingPacket(data);
|
|
72
|
+
log.debug('Sending packet:', { size: packet.length });
|
|
73
|
+
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (!this.socket) {
|
|
76
|
+
reject(new NetworkError('Socket disconnected during send'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.socket.write(packet, (error) => {
|
|
81
|
+
if (error) {
|
|
82
|
+
log.error('Send packet error:', error);
|
|
83
|
+
reject(new NetworkError('Failed to send packet'));
|
|
84
|
+
} else {
|
|
85
|
+
resolve();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async receiveResponse(): Promise<any> {
|
|
92
|
+
if (!this.socket) {
|
|
93
|
+
throw new NetworkError('Socket not connected');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let buffer = Buffer.alloc(0);
|
|
98
|
+
let expectedLength: number | null = null;
|
|
99
|
+
let headerRead = false;
|
|
100
|
+
let timeoutId: NodeJS.Timeout | null = null;
|
|
101
|
+
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
if (timeoutId) {
|
|
104
|
+
clearTimeout(timeoutId);
|
|
105
|
+
timeoutId = null;
|
|
106
|
+
}
|
|
107
|
+
if (this.socket) {
|
|
108
|
+
this.socket.removeListener('data', onData);
|
|
109
|
+
this.socket.removeListener('error', onError);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const onData = (chunk: Buffer) => {
|
|
114
|
+
try {
|
|
115
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
116
|
+
|
|
117
|
+
if (!headerRead && buffer.length >= NETWORK_CONSTANTS.HEADER_LENGTH) {
|
|
118
|
+
const magic = buffer
|
|
119
|
+
.slice(0, NETWORK_CONSTANTS.MAGIC_LENGTH)
|
|
120
|
+
.toString('ascii');
|
|
121
|
+
if (magic !== NETWORK_CONSTANTS.MAGIC) {
|
|
122
|
+
throw new NetworkError(
|
|
123
|
+
`Invalid protocol magic: expected '${NETWORK_CONSTANTS.MAGIC}', got '${magic}'`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
expectedLength = buffer.readUInt16BE(
|
|
127
|
+
NETWORK_CONSTANTS.MAGIC_LENGTH,
|
|
128
|
+
);
|
|
129
|
+
headerRead = true;
|
|
130
|
+
log.debug(
|
|
131
|
+
`Response header parsed: expecting ${expectedLength} bytes`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
headerRead &&
|
|
137
|
+
expectedLength !== null &&
|
|
138
|
+
buffer.length >= NETWORK_CONSTANTS.HEADER_LENGTH + expectedLength
|
|
139
|
+
) {
|
|
140
|
+
const bodyBytes = buffer.slice(
|
|
141
|
+
NETWORK_CONSTANTS.HEADER_LENGTH,
|
|
142
|
+
NETWORK_CONSTANTS.HEADER_LENGTH + expectedLength,
|
|
143
|
+
);
|
|
144
|
+
const response = JSON.parse(bodyBytes.toString('utf8'));
|
|
145
|
+
log.debug('Response received and parsed successfully');
|
|
146
|
+
cleanup();
|
|
147
|
+
resolve(response);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
log.error('Parse response error:', error);
|
|
151
|
+
cleanup();
|
|
152
|
+
reject(
|
|
153
|
+
new NetworkError(
|
|
154
|
+
`Failed to parse response: ${(error as Error).message}`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const onError = (error: Error) => {
|
|
161
|
+
log.error('Socket error during receive:', error);
|
|
162
|
+
cleanup();
|
|
163
|
+
reject(new NetworkError(`Socket error: ${error.message}`));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const onClose = () => {
|
|
167
|
+
if (timeoutId) {
|
|
168
|
+
clearTimeout(timeoutId);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (this.socket) {
|
|
173
|
+
this.socket.once('data', onData);
|
|
174
|
+
this.socket.once('error', onError);
|
|
175
|
+
this.socket.once('close', onClose);
|
|
176
|
+
|
|
177
|
+
timeoutId = setTimeout(() => {
|
|
178
|
+
log.error(`Response timeout after ${this.config.timeout}ms`);
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(
|
|
181
|
+
new NetworkError(`Response timeout after ${this.config.timeout}ms`),
|
|
182
|
+
);
|
|
183
|
+
}, this.config.timeout);
|
|
184
|
+
} else {
|
|
185
|
+
reject(new NetworkError('Socket not available'));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
disconnect(): void {
|
|
191
|
+
this.cleanup();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private createRPPairingPacket(jsonData: any): Buffer {
|
|
195
|
+
const jsonString = JSON.stringify(jsonData);
|
|
196
|
+
const bodyBytes = Buffer.from(jsonString, 'utf8');
|
|
197
|
+
const magic = Buffer.from(NETWORK_CONSTANTS.MAGIC, 'ascii');
|
|
198
|
+
const length = Buffer.alloc(NETWORK_CONSTANTS.LENGTH_FIELD_SIZE);
|
|
199
|
+
length.writeUInt16BE(bodyBytes.length, 0);
|
|
200
|
+
return Buffer.concat([magic, length, bodyBytes]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private cleanup(): void {
|
|
204
|
+
if (this.connectionTimeoutId) {
|
|
205
|
+
clearTimeout(this.connectionTimeoutId);
|
|
206
|
+
this.connectionTimeoutId = null;
|
|
207
|
+
}
|
|
208
|
+
if (this.socket) {
|
|
209
|
+
this.socket.removeAllListeners();
|
|
210
|
+
this.socket.destroy();
|
|
211
|
+
this.socket = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { AppleTVDevice } from '../../bonjour/bonjour-discovery.js';
|
|
2
|
+
import { getLogger } from '../../logger.js';
|
|
3
|
+
import { DEFAULT_PAIRING_CONFIG } from '../constants.js';
|
|
4
|
+
import { DeviceDiscoveryService } from '../discovery/index.js';
|
|
5
|
+
import { PairingError } from '../errors.js';
|
|
6
|
+
import { NetworkClient } from '../network/index.js';
|
|
7
|
+
import { PairingProtocol } from '../pairing-protocol/index.js';
|
|
8
|
+
import type { UserInputInterface } from '../pairing-protocol/types.js';
|
|
9
|
+
import type { PairingConfig, PairingResult } from '../types.js';
|
|
10
|
+
|
|
11
|
+
const log = getLogger('AppleTVPairingService');
|
|
12
|
+
|
|
13
|
+
/** Main service orchestrating Apple TV device discovery and pairing */
|
|
14
|
+
export class AppleTVPairingService {
|
|
15
|
+
private readonly networkClient: NetworkClient;
|
|
16
|
+
private readonly discoveryService: DeviceDiscoveryService;
|
|
17
|
+
private readonly userInput: UserInputInterface;
|
|
18
|
+
private readonly pairingProtocol: PairingProtocol;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
userInput: UserInputInterface,
|
|
22
|
+
config: PairingConfig = DEFAULT_PAIRING_CONFIG,
|
|
23
|
+
) {
|
|
24
|
+
this.networkClient = new NetworkClient(config);
|
|
25
|
+
this.discoveryService = new DeviceDiscoveryService(config);
|
|
26
|
+
this.userInput = userInput;
|
|
27
|
+
this.pairingProtocol = new PairingProtocol(
|
|
28
|
+
this.networkClient,
|
|
29
|
+
this.userInput,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async discoverAndPair(deviceSelector?: string): Promise<PairingResult> {
|
|
34
|
+
try {
|
|
35
|
+
const devices = await this.discoveryService.discoverDevices();
|
|
36
|
+
|
|
37
|
+
if (devices.length === 0) {
|
|
38
|
+
const errorMessage =
|
|
39
|
+
'No Apple TV pairing devices found. Please ensure your Apple TV is on the same network and in pairing mode.';
|
|
40
|
+
log.error(errorMessage);
|
|
41
|
+
throw new PairingError(errorMessage, 'NO_DEVICES');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const device = await this.selectDevice(devices, deviceSelector);
|
|
45
|
+
const pairingFile = await this.pairWithDevice(device);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
deviceId: device.identifier,
|
|
50
|
+
pairingFile,
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
log.error('Pairing failed:', error);
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
deviceId: 'unknown',
|
|
57
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async pairWithDevice(device: AppleTVDevice): Promise<string> {
|
|
63
|
+
try {
|
|
64
|
+
// Use IP if available, otherwise fall back to hostname
|
|
65
|
+
const connectionTarget = device.ip ?? device.hostname;
|
|
66
|
+
|
|
67
|
+
if (!connectionTarget) {
|
|
68
|
+
throw new PairingError(
|
|
69
|
+
'Neither IP address nor hostname available for device',
|
|
70
|
+
'NO_CONNECTION_TARGET',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
log.info(
|
|
75
|
+
`Connecting to device ${device.name} at ${connectionTarget}:${device.port}`,
|
|
76
|
+
);
|
|
77
|
+
await this.networkClient.connect(connectionTarget, device.port);
|
|
78
|
+
return await this.pairingProtocol.executePairingFlow(device);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
log.error(`Pairing with device ${device.name} failed:`, error);
|
|
81
|
+
throw error;
|
|
82
|
+
} finally {
|
|
83
|
+
this.networkClient.disconnect();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async selectDevice(
|
|
88
|
+
devices: AppleTVDevice[],
|
|
89
|
+
deviceSelector?: string,
|
|
90
|
+
): Promise<AppleTVDevice> {
|
|
91
|
+
// If no selector provided, always prompt user to choose (even for single device)
|
|
92
|
+
if (!deviceSelector) {
|
|
93
|
+
log.info(`Found ${devices.length} device(s):`);
|
|
94
|
+
devices.forEach((device, index) => {
|
|
95
|
+
log.info(
|
|
96
|
+
` [${index}] ${device.name} (${device.identifier}) - ${device.model} v${device.version}`,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const prompt =
|
|
101
|
+
devices.length === 1
|
|
102
|
+
? 'Press Enter to select device [0], or enter index: '
|
|
103
|
+
: `Select device by index (0-${devices.length - 1}): `;
|
|
104
|
+
|
|
105
|
+
const indexStr = await this.userInput.promptForInput(prompt);
|
|
106
|
+
const trimmed = indexStr.trim();
|
|
107
|
+
|
|
108
|
+
// If user just presses Enter and there's only one device, select it
|
|
109
|
+
if (trimmed === '' && devices.length === 1) {
|
|
110
|
+
log.info(
|
|
111
|
+
`Selected device: ${devices[0].name} (${devices[0].identifier})`,
|
|
112
|
+
);
|
|
113
|
+
return devices[0];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const index = parseInt(trimmed, 10);
|
|
117
|
+
|
|
118
|
+
if (isNaN(index) || index < 0 || index >= devices.length) {
|
|
119
|
+
throw new PairingError(
|
|
120
|
+
`Invalid device index: ${trimmed}. Must be between 0 and ${devices.length - 1}`,
|
|
121
|
+
'INVALID_DEVICE_SELECTION',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
log.info(
|
|
126
|
+
`Selected device: ${devices[index].name} (${devices[index].identifier})`,
|
|
127
|
+
);
|
|
128
|
+
return devices[index];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Try to match by index first
|
|
132
|
+
const indexMatch = parseInt(deviceSelector, 10);
|
|
133
|
+
if (!isNaN(indexMatch) && indexMatch >= 0 && indexMatch < devices.length) {
|
|
134
|
+
log.info(
|
|
135
|
+
`Selected device by index ${indexMatch}: ${devices[indexMatch].name}`,
|
|
136
|
+
);
|
|
137
|
+
return devices[indexMatch];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Try to match by name (case-insensitive)
|
|
141
|
+
const nameMatch = devices.find(
|
|
142
|
+
(device) => device.name.toLowerCase() === deviceSelector.toLowerCase(),
|
|
143
|
+
);
|
|
144
|
+
if (nameMatch) {
|
|
145
|
+
log.info(
|
|
146
|
+
`Selected device by name: ${nameMatch.name} (${nameMatch.identifier})`,
|
|
147
|
+
);
|
|
148
|
+
return nameMatch;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Try to match by identifier (case-insensitive)
|
|
152
|
+
const identifierMatch = devices.find(
|
|
153
|
+
(device) =>
|
|
154
|
+
device.identifier.toLowerCase() === deviceSelector.toLowerCase(),
|
|
155
|
+
);
|
|
156
|
+
if (identifierMatch) {
|
|
157
|
+
log.info(
|
|
158
|
+
`Selected device by identifier: ${identifierMatch.name} (${identifierMatch.identifier})`,
|
|
159
|
+
);
|
|
160
|
+
return identifierMatch;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// No match found
|
|
164
|
+
const availableDevices = devices
|
|
165
|
+
.map(
|
|
166
|
+
(device, index) => ` [${index}] ${device.name} (${device.identifier})`,
|
|
167
|
+
)
|
|
168
|
+
.join('\n');
|
|
169
|
+
|
|
170
|
+
throw new PairingError(
|
|
171
|
+
`Device '${deviceSelector}' not found. Available devices:\n${availableDevices}`,
|
|
172
|
+
'DEVICE_NOT_FOUND',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { logger } from '@appium/support';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
|
|
4
|
+
import { PairingError } from '../errors.js';
|
|
5
|
+
import { NETWORK_CONSTANTS } from '../network/constants.js';
|
|
6
|
+
import type { UserInputInterface } from '../pairing-protocol/types.js';
|
|
7
|
+
|
|
8
|
+
/** Handles user interaction for PIN input during pairing */
|
|
9
|
+
export class UserInputService implements UserInputInterface {
|
|
10
|
+
private readonly log = logger.getLogger('UserInputService');
|
|
11
|
+
|
|
12
|
+
async promptForPIN(): Promise<string> {
|
|
13
|
+
const rl = createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
let timeoutId: NodeJS.Timeout | null = null;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const pin = await new Promise<string>((resolve, reject) => {
|
|
22
|
+
timeoutId = setTimeout(() => {
|
|
23
|
+
reject(new PairingError('PIN input timeout', 'INPUT_TIMEOUT'));
|
|
24
|
+
}, NETWORK_CONSTANTS.PIN_INPUT_TIMEOUT_MS);
|
|
25
|
+
|
|
26
|
+
rl.question('Enter PIN from Apple TV screen: ', (answer) => {
|
|
27
|
+
// Clear timeout since we got the PIN
|
|
28
|
+
if (timeoutId) {
|
|
29
|
+
clearTimeout(timeoutId);
|
|
30
|
+
timeoutId = null;
|
|
31
|
+
}
|
|
32
|
+
resolve(answer);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const cleanPin = pin.trim();
|
|
37
|
+
if (!/^\d+$/.test(cleanPin)) {
|
|
38
|
+
this.log.error('Invalid PIN format');
|
|
39
|
+
throw new PairingError('PIN must contain only digits', 'INVALID_PIN');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.log.debug('PIN received successfully');
|
|
43
|
+
return cleanPin;
|
|
44
|
+
} finally {
|
|
45
|
+
// Clean up timeout if error occurred before clearing
|
|
46
|
+
if (timeoutId) {
|
|
47
|
+
clearTimeout(timeoutId);
|
|
48
|
+
}
|
|
49
|
+
rl.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async promptForInput(prompt: string): Promise<string> {
|
|
54
|
+
const rl = createInterface({
|
|
55
|
+
input: process.stdin,
|
|
56
|
+
output: process.stdout,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const input = await new Promise<string>((resolve) => {
|
|
61
|
+
rl.question(prompt, (answer) => {
|
|
62
|
+
resolve(answer);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return input.trim();
|
|
67
|
+
} finally {
|
|
68
|
+
rl.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|