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.
Files changed (90) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/build/src/constants.d.ts +2 -0
  3. package/build/src/constants.d.ts.map +1 -0
  4. package/build/src/constants.js +3 -0
  5. package/build/src/index.d.ts +2 -1
  6. package/build/src/index.d.ts.map +1 -1
  7. package/build/src/index.js +2 -1
  8. package/build/src/lib/apple-tv/constants.d.ts +0 -1
  9. package/build/src/lib/apple-tv/constants.d.ts.map +1 -1
  10. package/build/src/lib/apple-tv/constants.js +0 -1
  11. package/build/src/lib/apple-tv/discovery/device-discovery.d.ts +10 -0
  12. package/build/src/lib/apple-tv/discovery/device-discovery.d.ts.map +1 -0
  13. package/build/src/lib/apple-tv/discovery/device-discovery.js +22 -0
  14. package/build/src/lib/apple-tv/discovery/index.d.ts +2 -0
  15. package/build/src/lib/apple-tv/discovery/index.d.ts.map +1 -0
  16. package/build/src/lib/apple-tv/discovery/index.js +1 -0
  17. package/build/src/lib/apple-tv/index.d.ts +5 -0
  18. package/build/src/lib/apple-tv/index.d.ts.map +1 -1
  19. package/build/src/lib/apple-tv/index.js +5 -0
  20. package/build/src/lib/apple-tv/network/constants.d.ts +10 -0
  21. package/build/src/lib/apple-tv/network/constants.d.ts.map +1 -0
  22. package/build/src/lib/apple-tv/network/constants.js +9 -0
  23. package/build/src/lib/apple-tv/network/index.d.ts +4 -0
  24. package/build/src/lib/apple-tv/network/index.d.ts.map +1 -0
  25. package/build/src/lib/apple-tv/network/index.js +2 -0
  26. package/build/src/lib/apple-tv/network/network-client.d.ts +16 -0
  27. package/build/src/lib/apple-tv/network/network-client.d.ts.map +1 -0
  28. package/build/src/lib/apple-tv/network/network-client.js +169 -0
  29. package/build/src/lib/apple-tv/network/types.d.ts +8 -0
  30. package/build/src/lib/apple-tv/network/types.d.ts.map +1 -0
  31. package/build/src/lib/apple-tv/network/types.js +1 -0
  32. package/build/src/lib/apple-tv/pairing/index.d.ts +3 -0
  33. package/build/src/lib/apple-tv/pairing/index.d.ts.map +1 -0
  34. package/build/src/lib/apple-tv/pairing/index.js +2 -0
  35. package/build/src/lib/apple-tv/pairing/pairing-service.d.ts +15 -0
  36. package/build/src/lib/apple-tv/pairing/pairing-service.d.ts.map +1 -0
  37. package/build/src/lib/apple-tv/pairing/pairing-service.js +112 -0
  38. package/build/src/lib/apple-tv/pairing/user-input-service.d.ts +8 -0
  39. package/build/src/lib/apple-tv/pairing/user-input-service.d.ts.map +1 -0
  40. package/build/src/lib/apple-tv/pairing/user-input-service.js +61 -0
  41. package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts +18 -0
  42. package/build/src/lib/apple-tv/pairing-protocol/constants.d.ts.map +1 -0
  43. package/build/src/lib/apple-tv/pairing-protocol/constants.js +17 -0
  44. package/build/src/lib/apple-tv/pairing-protocol/index.d.ts +4 -0
  45. package/build/src/lib/apple-tv/pairing-protocol/index.d.ts.map +1 -0
  46. package/build/src/lib/apple-tv/pairing-protocol/index.js +2 -0
  47. package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts +159 -0
  48. package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.d.ts.map +1 -0
  49. package/build/src/lib/apple-tv/pairing-protocol/pairing-protocol.js +494 -0
  50. package/build/src/lib/apple-tv/pairing-protocol/types.d.ts +57 -0
  51. package/build/src/lib/apple-tv/pairing-protocol/types.d.ts.map +1 -0
  52. package/build/src/lib/apple-tv/pairing-protocol/types.js +1 -0
  53. package/build/src/lib/apple-tv/storage/index.d.ts +3 -0
  54. package/build/src/lib/apple-tv/storage/index.d.ts.map +1 -0
  55. package/build/src/lib/apple-tv/storage/index.js +1 -0
  56. package/build/src/lib/apple-tv/storage/pairing-storage.d.ts +12 -0
  57. package/build/src/lib/apple-tv/storage/pairing-storage.d.ts.map +1 -0
  58. package/build/src/lib/apple-tv/storage/pairing-storage.js +36 -0
  59. package/build/src/lib/apple-tv/storage/types.d.ts +5 -0
  60. package/build/src/lib/apple-tv/storage/types.d.ts.map +1 -0
  61. package/build/src/lib/apple-tv/storage/types.js +1 -0
  62. package/build/src/lib/apple-tv/types.d.ts +0 -1
  63. package/build/src/lib/apple-tv/types.d.ts.map +1 -1
  64. package/build/src/lib/bonjour/bonjour-discovery.d.ts.map +1 -1
  65. package/build/src/lib/bonjour/bonjour-discovery.js +2 -0
  66. package/package.json +4 -2
  67. package/scripts/pair-appletv.ts +79 -0
  68. package/scripts/test-tunnel-creation.ts +1 -1
  69. package/src/constants.ts +4 -0
  70. package/src/index.ts +2 -0
  71. package/src/lib/apple-tv/constants.ts +0 -1
  72. package/src/lib/apple-tv/discovery/device-discovery.ts +34 -0
  73. package/src/lib/apple-tv/discovery/index.ts +1 -0
  74. package/src/lib/apple-tv/index.ts +5 -0
  75. package/src/lib/apple-tv/network/constants.ts +9 -0
  76. package/src/lib/apple-tv/network/index.ts +3 -0
  77. package/src/lib/apple-tv/network/network-client.ts +214 -0
  78. package/src/lib/apple-tv/network/types.ts +7 -0
  79. package/src/lib/apple-tv/pairing/index.ts +2 -0
  80. package/src/lib/apple-tv/pairing/pairing-service.ts +175 -0
  81. package/src/lib/apple-tv/pairing/user-input-service.ts +71 -0
  82. package/src/lib/apple-tv/pairing-protocol/constants.ts +19 -0
  83. package/src/lib/apple-tv/pairing-protocol/index.ts +8 -0
  84. package/src/lib/apple-tv/pairing-protocol/pairing-protocol.ts +636 -0
  85. package/src/lib/apple-tv/pairing-protocol/types.ts +60 -0
  86. package/src/lib/apple-tv/storage/index.ts +2 -0
  87. package/src/lib/apple-tv/storage/pairing-storage.ts +60 -0
  88. package/src/lib/apple-tv/storage/types.ts +9 -0
  89. package/src/lib/apple-tv/types.ts +0 -1
  90. package/src/lib/bonjour/bonjour-discovery.ts +2 -0
@@ -360,6 +360,8 @@ export class BonjourDiscovery extends EventEmitter {
360
360
  }
361
361
  finally {
362
362
  this.stopBrowsing();
363
+ // Small delay to ensure cleanup logs are flushed before returning
364
+ await delay(100);
363
365
  }
364
366
  }
365
367
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-remotexpc",
3
- "version": "0.12.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
+ });
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env tsx
2
2
  /**
3
3
  * Test script for creating lockdown service, starting CoreDeviceProxy, and creating tunnel
4
4
  * This script demonstrates the tunnel creation workflow for all connected devices
@@ -0,0 +1,4 @@
1
+ // Global constants used across the appium-ios-remotexpc package
2
+
3
+ // Strongbox container name for storing credentials and configuration
4
+ export const STRONGBOX_CONTAINER_NAME = 'appium-ios-remotexpc';
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,
@@ -3,7 +3,6 @@ export const DEFAULT_PAIRING_CONFIG = {
3
3
  timeout: 30000,
4
4
  discoveryTimeout: 5000,
5
5
  maxRetries: 3,
6
- pairingDirectory: '.pairing',
7
6
  } as const;
8
7
 
9
8
  // TLV8 component type identifiers used in pairing data exchange
@@ -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,9 @@
1
+ /** Constants for network protocol */
2
+ export const NETWORK_CONSTANTS = {
3
+ MAGIC: 'RPPairing',
4
+ MAGIC_LENGTH: 9,
5
+ HEADER_LENGTH: 11,
6
+ LENGTH_FIELD_SIZE: 2,
7
+ MAX_TLV_FRAGMENT_SIZE: 255,
8
+ PIN_INPUT_TIMEOUT_MS: 30000,
9
+ } as const;
@@ -0,0 +1,3 @@
1
+ export { NetworkClient } from './network-client.js';
2
+ export { NETWORK_CONSTANTS } from './constants.js';
3
+ export type { NetworkClientInterface } from './types.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,7 @@
1
+ /** Interface for network communication with Apple TV devices */
2
+ export interface NetworkClientInterface {
3
+ connect(ip: string, port: number): Promise<void>;
4
+ sendPacket(data: any): Promise<void>;
5
+ receiveResponse(): Promise<any>;
6
+ disconnect(): void;
7
+ }
@@ -0,0 +1,2 @@
1
+ export { UserInputService } from './user-input-service.js';
2
+ export { AppleTVPairingService } from './pairing-service.js';
@@ -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
+ }