@basmilius/apple-common 0.9.18 → 0.10.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/dist/index.d.mts +858 -9
- package/dist/index.mjs +1352 -121
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -61,6 +61,11 @@ function v4(options, buf, offset) {
|
|
|
61
61
|
|
|
62
62
|
//#endregion
|
|
63
63
|
//#region src/airplayFeatures.ts
|
|
64
|
+
/**
|
|
65
|
+
* AirPlay feature flags as a bitmask of bigint values. Each flag corresponds to a specific
|
|
66
|
+
* capability advertised by an AirPlay device in its mDNS TXT record "features" field.
|
|
67
|
+
* The bit positions match Apple's internal AirPlayFeatureFlags enum.
|
|
68
|
+
*/
|
|
64
69
|
const AirPlayFeatureFlags = {
|
|
65
70
|
SupportsAirPlayVideoV1: 1n << 0n,
|
|
66
71
|
SupportsAirPlayPhoto: 1n << 1n,
|
|
@@ -107,9 +112,21 @@ const AirPlayFeatureFlags = {
|
|
|
107
112
|
SupportsAudioMetadataControl: 1n << 60n,
|
|
108
113
|
SupportsRFC2198Redundancy: 1n << 61n
|
|
109
114
|
};
|
|
115
|
+
/** Bitmask in the "sf" (status flags) TXT field indicating password protection. */
|
|
110
116
|
const PASSWORD_BIT = 128n;
|
|
117
|
+
/** Bitmask in the "sf" TXT field indicating legacy pairing (PIN required). */
|
|
111
118
|
const LEGACY_PAIRING_BIT = 512n;
|
|
119
|
+
/** Bitmask in the "sf" TXT field indicating a PIN is required. */
|
|
112
120
|
const PIN_REQUIRED_BIT = 8n;
|
|
121
|
+
/**
|
|
122
|
+
* Parses an AirPlay features string into a single bigint bitmask.
|
|
123
|
+
* Features are advertised as either a single hex value or two comma-separated
|
|
124
|
+
* 32-bit hex values (low,high) which are combined into a 64-bit bitmask.
|
|
125
|
+
*
|
|
126
|
+
* @param features - The features string from the mDNS TXT record.
|
|
127
|
+
* @returns The combined feature flags as a bigint.
|
|
128
|
+
* @throws If the features string has an unexpected format.
|
|
129
|
+
*/
|
|
113
130
|
function parseFeatures(features) {
|
|
114
131
|
const parts = features.split(",").map((part) => part.trim());
|
|
115
132
|
if (parts.length === 1) return BigInt(parts[0]);
|
|
@@ -119,14 +136,36 @@ function parseFeatures(features) {
|
|
|
119
136
|
}
|
|
120
137
|
throw new Error(`Invalid features format: ${features}`);
|
|
121
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Checks whether a specific feature flag is set in a features bitmask.
|
|
141
|
+
*
|
|
142
|
+
* @param features - The combined feature flags bitmask.
|
|
143
|
+
* @param flag - The specific flag to check for.
|
|
144
|
+
* @returns True if the flag is set.
|
|
145
|
+
*/
|
|
122
146
|
function hasFeatureFlag(features, flag) {
|
|
123
147
|
return (features & flag) !== 0n;
|
|
124
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Returns the names of all feature flags that are set in the given bitmask.
|
|
151
|
+
* Useful for debugging and diagnostics output.
|
|
152
|
+
*
|
|
153
|
+
* @param features - The combined feature flags bitmask.
|
|
154
|
+
* @returns An array of feature flag names that are active.
|
|
155
|
+
*/
|
|
125
156
|
function describeFlags(features) {
|
|
126
157
|
const result = [];
|
|
127
158
|
for (const [name, flag] of Object.entries(AirPlayFeatureFlags)) if (hasFeatureFlag(features, flag)) result.push(name);
|
|
128
159
|
return result;
|
|
129
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Determines the AirPlay protocol version supported by a device based on its
|
|
163
|
+
* mDNS TXT record properties. AirPlay 2 is indicated by the presence of
|
|
164
|
+
* SupportsUnifiedMediaControl or SupportsCoreUtilsPairingAndEncryption flags.
|
|
165
|
+
*
|
|
166
|
+
* @param txt - The key-value properties from the device's mDNS TXT record.
|
|
167
|
+
* @returns 1 for legacy AirPlay, 2 for AirPlay 2.
|
|
168
|
+
*/
|
|
130
169
|
function getProtocolVersion(txt) {
|
|
131
170
|
const featuresStr = txt.features ?? txt.ft;
|
|
132
171
|
if (!featuresStr) return 1;
|
|
@@ -135,6 +174,14 @@ function getProtocolVersion(txt) {
|
|
|
135
174
|
if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsCoreUtilsPairingAndEncryption)) return 2;
|
|
136
175
|
return 1;
|
|
137
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Determines the pairing requirement for an AirPlay device based on its
|
|
179
|
+
* feature flags and status flags. The hierarchy is:
|
|
180
|
+
* HomeKit pairing > PIN required > Transient (system) pairing > Legacy PIN > None.
|
|
181
|
+
*
|
|
182
|
+
* @param txt - The key-value properties from the device's mDNS TXT record.
|
|
183
|
+
* @returns The pairing requirement type.
|
|
184
|
+
*/
|
|
138
185
|
function getPairingRequirement(txt) {
|
|
139
186
|
const featuresStr = txt.features ?? txt.ft;
|
|
140
187
|
if (!featuresStr) return "none";
|
|
@@ -146,10 +193,24 @@ function getPairingRequirement(txt) {
|
|
|
146
193
|
if ((sf & LEGACY_PAIRING_BIT) !== 0n) return "pin";
|
|
147
194
|
return "none";
|
|
148
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Checks whether the AirPlay device requires a password to connect.
|
|
198
|
+
* Determined by the "pw" TXT field or the password bit in the "sf" status flags.
|
|
199
|
+
*
|
|
200
|
+
* @param txt - The key-value properties from the device's mDNS TXT record.
|
|
201
|
+
* @returns True if a password is required.
|
|
202
|
+
*/
|
|
149
203
|
function isPasswordRequired(txt) {
|
|
150
204
|
if (txt.pw === "true") return true;
|
|
151
205
|
return ((txt.sf ? BigInt(txt.sf) : 0n) & PASSWORD_BIT) !== 0n;
|
|
152
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Checks whether the AirPlay device supports remote control (Hangdog protocol).
|
|
209
|
+
* Only devices with the SupportsHangdogRemoteControl flag can receive HID events.
|
|
210
|
+
*
|
|
211
|
+
* @param txt - The key-value properties from the device's mDNS TXT record.
|
|
212
|
+
* @returns True if remote control is supported (typically Apple TV only).
|
|
213
|
+
*/
|
|
153
214
|
function isRemoteControlSupported(txt) {
|
|
154
215
|
const featuresStr = txt.features ?? txt.ft;
|
|
155
216
|
if (!featuresStr) return false;
|
|
@@ -158,7 +219,20 @@ function isRemoteControlSupported(txt) {
|
|
|
158
219
|
|
|
159
220
|
//#endregion
|
|
160
221
|
//#region src/storage.ts
|
|
222
|
+
/**
|
|
223
|
+
* Builds the composite key for credential storage lookup.
|
|
224
|
+
*
|
|
225
|
+
* @param deviceId - The device identifier.
|
|
226
|
+
* @param protocol - The protocol type.
|
|
227
|
+
* @returns A composite key in the format "deviceId:protocol".
|
|
228
|
+
*/
|
|
161
229
|
const credentialKey = (deviceId, protocol) => `${deviceId}:${protocol}`;
|
|
230
|
+
/**
|
|
231
|
+
* Converts credential buffers to base64 strings for JSON serialization.
|
|
232
|
+
*
|
|
233
|
+
* @param credentials - The credentials to serialize.
|
|
234
|
+
* @returns A JSON-safe serialized form.
|
|
235
|
+
*/
|
|
162
236
|
const serializeCredentials = (credentials) => ({
|
|
163
237
|
accessoryIdentifier: credentials.accessoryIdentifier,
|
|
164
238
|
accessoryLongTermPublicKey: credentials.accessoryLongTermPublicKey.toString("base64"),
|
|
@@ -166,6 +240,12 @@ const serializeCredentials = (credentials) => ({
|
|
|
166
240
|
publicKey: credentials.publicKey.toString("base64"),
|
|
167
241
|
secretKey: credentials.secretKey.toString("base64")
|
|
168
242
|
});
|
|
243
|
+
/**
|
|
244
|
+
* Restores credential buffers from base64-encoded strings.
|
|
245
|
+
*
|
|
246
|
+
* @param stored - The serialized credentials from storage.
|
|
247
|
+
* @returns Fully hydrated credentials with Buffer fields.
|
|
248
|
+
*/
|
|
169
249
|
const deserializeCredentials = (stored) => ({
|
|
170
250
|
accessoryIdentifier: stored.accessoryIdentifier,
|
|
171
251
|
accessoryLongTermPublicKey: Buffer.from(stored.accessoryLongTermPublicKey, "base64"),
|
|
@@ -173,68 +253,150 @@ const deserializeCredentials = (stored) => ({
|
|
|
173
253
|
publicKey: Buffer.from(stored.publicKey, "base64"),
|
|
174
254
|
secretKey: Buffer.from(stored.secretKey, "base64")
|
|
175
255
|
});
|
|
256
|
+
/**
|
|
257
|
+
* Creates an empty storage data structure with version 1 schema.
|
|
258
|
+
*
|
|
259
|
+
* @returns A fresh empty storage data object.
|
|
260
|
+
*/
|
|
176
261
|
const createEmptyData = () => ({
|
|
177
262
|
version: 1,
|
|
178
263
|
devices: {},
|
|
179
264
|
credentials: {}
|
|
180
265
|
});
|
|
266
|
+
/**
|
|
267
|
+
* Abstract base class for persistent storage of device registrations and
|
|
268
|
+
* pairing credentials. Subclasses implement the actual load/save mechanism.
|
|
269
|
+
*
|
|
270
|
+
* Credentials are stored keyed by "deviceId:protocol" to support per-protocol
|
|
271
|
+
* pairing (a device can have separate AirPlay and Companion Link credentials).
|
|
272
|
+
*/
|
|
181
273
|
var Storage = class {
|
|
182
274
|
#data = createEmptyData();
|
|
275
|
+
/** The current storage data. */
|
|
183
276
|
get data() {
|
|
184
277
|
return this.#data;
|
|
185
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Replaces the internal data with the given storage data.
|
|
281
|
+
* Used by subclasses during load.
|
|
282
|
+
*
|
|
283
|
+
* @param data - The loaded storage data to set.
|
|
284
|
+
*/
|
|
186
285
|
setData(data) {
|
|
187
286
|
this.#data = data;
|
|
188
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Retrieves a stored device by its identifier.
|
|
290
|
+
*
|
|
291
|
+
* @param identifier - The device identifier.
|
|
292
|
+
* @returns The stored device, or undefined if not found.
|
|
293
|
+
*/
|
|
189
294
|
getDevice(identifier) {
|
|
190
295
|
return this.#data.devices[identifier];
|
|
191
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Stores or updates a device registration.
|
|
299
|
+
*
|
|
300
|
+
* @param identifier - The device identifier.
|
|
301
|
+
* @param device - The device data to store.
|
|
302
|
+
*/
|
|
192
303
|
setDevice(identifier, device) {
|
|
193
304
|
this.#data.devices[identifier] = device;
|
|
194
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Removes a device and all its associated credentials from storage.
|
|
308
|
+
*
|
|
309
|
+
* @param identifier - The device identifier to remove.
|
|
310
|
+
*/
|
|
195
311
|
removeDevice(identifier) {
|
|
196
312
|
delete this.#data.devices[identifier];
|
|
197
313
|
for (const key of Object.keys(this.#data.credentials)) if (key.startsWith(`${identifier}:`)) delete this.#data.credentials[key];
|
|
198
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Returns all stored devices.
|
|
317
|
+
*
|
|
318
|
+
* @returns An array of all stored device records.
|
|
319
|
+
*/
|
|
199
320
|
listDevices() {
|
|
200
321
|
return Object.values(this.#data.devices);
|
|
201
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Retrieves pairing credentials for a device and protocol combination.
|
|
325
|
+
*
|
|
326
|
+
* @param deviceId - The device identifier.
|
|
327
|
+
* @param protocol - The protocol type.
|
|
328
|
+
* @returns The deserialized credentials, or undefined if not found.
|
|
329
|
+
*/
|
|
202
330
|
getCredentials(deviceId, protocol) {
|
|
203
331
|
const stored = this.#data.credentials[credentialKey(deviceId, protocol)];
|
|
204
332
|
if (!stored) return;
|
|
205
333
|
return deserializeCredentials(stored);
|
|
206
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Stores pairing credentials for a device and protocol combination.
|
|
337
|
+
*
|
|
338
|
+
* @param deviceId - The device identifier.
|
|
339
|
+
* @param protocol - The protocol type.
|
|
340
|
+
* @param credentials - The credentials to store.
|
|
341
|
+
*/
|
|
207
342
|
setCredentials(deviceId, protocol, credentials) {
|
|
208
343
|
this.#data.credentials[credentialKey(deviceId, protocol)] = serializeCredentials(credentials);
|
|
209
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Removes pairing credentials for a device and protocol combination.
|
|
347
|
+
*
|
|
348
|
+
* @param deviceId - The device identifier.
|
|
349
|
+
* @param protocol - The protocol type.
|
|
350
|
+
*/
|
|
210
351
|
removeCredentials(deviceId, protocol) {
|
|
211
352
|
delete this.#data.credentials[credentialKey(deviceId, protocol)];
|
|
212
353
|
}
|
|
213
354
|
};
|
|
355
|
+
/**
|
|
356
|
+
* JSON file-based storage implementation. Persists data to a JSON file on disk,
|
|
357
|
+
* defaulting to `~/.config/apple-protocols/storage.json`.
|
|
358
|
+
*/
|
|
214
359
|
var JsonStorage = class extends Storage {
|
|
215
360
|
#path;
|
|
361
|
+
/**
|
|
362
|
+
* @param path - Optional custom file path. Defaults to `~/.config/apple-protocols/storage.json`.
|
|
363
|
+
*/
|
|
216
364
|
constructor(path) {
|
|
217
365
|
super();
|
|
218
366
|
this.#path = path ?? join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".config", "apple-protocols", "storage.json");
|
|
219
367
|
}
|
|
368
|
+
/** Loads storage data from the JSON file, if it exists. */
|
|
220
369
|
async load() {
|
|
221
370
|
if (!existsSync(this.#path)) return;
|
|
222
371
|
const raw = readFileSync(this.#path, "utf-8");
|
|
223
372
|
const json = JSON.parse(raw);
|
|
224
373
|
if (json.version === 1) this.setData(json);
|
|
225
374
|
}
|
|
375
|
+
/** Saves the current storage data to the JSON file, creating directories as needed. */
|
|
226
376
|
async save() {
|
|
227
377
|
mkdirSync(dirname(this.#path), { recursive: true });
|
|
228
378
|
writeFileSync(this.#path, JSON.stringify(this.data, null, 2), "utf-8");
|
|
229
379
|
}
|
|
230
380
|
};
|
|
381
|
+
/**
|
|
382
|
+
* In-memory storage implementation. Data is not persisted between sessions.
|
|
383
|
+
* Useful for testing or environments without filesystem access.
|
|
384
|
+
*/
|
|
231
385
|
var MemoryStorage = class extends Storage {
|
|
386
|
+
/** No-op: memory storage has nothing to load. */
|
|
232
387
|
async load() {}
|
|
388
|
+
/** No-op: memory storage has nothing to persist. */
|
|
233
389
|
async save() {}
|
|
234
390
|
};
|
|
235
391
|
|
|
236
392
|
//#endregion
|
|
237
393
|
//#region src/cli.ts
|
|
394
|
+
/**
|
|
395
|
+
* Prompts the user for input via stdin and returns their response.
|
|
396
|
+
*
|
|
397
|
+
* @param message - The message to display before the input cursor.
|
|
398
|
+
* @returns The user's input string.
|
|
399
|
+
*/
|
|
238
400
|
async function prompt(message) {
|
|
239
401
|
const cli = createInterface({
|
|
240
402
|
input: process.stdin,
|
|
@@ -244,25 +406,173 @@ async function prompt(message) {
|
|
|
244
406
|
cli.close();
|
|
245
407
|
return answer;
|
|
246
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Returns a promise that resolves after the specified delay.
|
|
411
|
+
* Commonly used for timing gaps in HID press/release sequences.
|
|
412
|
+
*
|
|
413
|
+
* @param ms - The delay in milliseconds.
|
|
414
|
+
*/
|
|
247
415
|
async function waitFor(ms) {
|
|
248
416
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
249
417
|
}
|
|
250
418
|
|
|
251
419
|
//#endregion
|
|
252
420
|
//#region src/const.ts
|
|
421
|
+
/** Default PIN used for transient (non-persistent) AirPlay pairing sessions. */
|
|
253
422
|
const AIRPLAY_TRANSIENT_PIN = "3939";
|
|
423
|
+
/** Timeout in milliseconds for HTTP requests during setup and control. */
|
|
254
424
|
const HTTP_TIMEOUT = 6e3;
|
|
425
|
+
/** Timeout in milliseconds for TCP socket connections during initial connect. */
|
|
255
426
|
const SOCKET_TIMEOUT = 1e4;
|
|
427
|
+
/** mDNS service type for AirPlay device discovery. */
|
|
256
428
|
const AIRPLAY_SERVICE = "_airplay._tcp.local";
|
|
429
|
+
/** mDNS service type for Companion Link (remote control protocol) device discovery. */
|
|
257
430
|
const COMPANION_LINK_SERVICE = "_companion-link._tcp.local";
|
|
431
|
+
/** mDNS service type for RAOP (Remote Audio Output Protocol) device discovery. */
|
|
258
432
|
const RAOP_SERVICE = "_raop._tcp.local";
|
|
259
433
|
|
|
434
|
+
//#endregion
|
|
435
|
+
//#region src/errors.ts
|
|
436
|
+
/**
|
|
437
|
+
* Base error class for all Apple protocol errors.
|
|
438
|
+
* All domain-specific errors in this library extend from this class,
|
|
439
|
+
* enabling consumers to catch any protocol error with a single handler.
|
|
440
|
+
*/
|
|
441
|
+
var AppleProtocolError = class extends Error {
|
|
442
|
+
/** @param message - Human-readable description of the error. */
|
|
443
|
+
constructor(message) {
|
|
444
|
+
super(message);
|
|
445
|
+
this.name = "AppleProtocolError";
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* Thrown when a TCP connection cannot be established or encounters a fatal error.
|
|
450
|
+
* Parent class for more specific connection errors.
|
|
451
|
+
*/
|
|
452
|
+
var ConnectionError = class extends AppleProtocolError {
|
|
453
|
+
/** @param message - Human-readable description of the connection failure. */
|
|
454
|
+
constructor(message) {
|
|
455
|
+
super(message);
|
|
456
|
+
this.name = "ConnectionError";
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
/** Thrown when a TCP connection attempt exceeds the configured socket timeout. */
|
|
460
|
+
var ConnectionTimeoutError = class extends ConnectionError {
|
|
461
|
+
/** @param message - Optional custom message; defaults to a standard timeout message. */
|
|
462
|
+
constructor(message = "Connection timed out.") {
|
|
463
|
+
super(message);
|
|
464
|
+
this.name = "ConnectionTimeoutError";
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
/** Thrown when a TCP connection is closed unexpectedly by the remote end or the OS. */
|
|
468
|
+
var ConnectionClosedError = class extends ConnectionError {
|
|
469
|
+
/** @param message - Optional custom message; defaults to a standard closed message. */
|
|
470
|
+
constructor(message = "Connection closed unexpectedly.") {
|
|
471
|
+
super(message);
|
|
472
|
+
this.name = "ConnectionClosedError";
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Thrown when a pairing operation fails during the HAP pair-setup or pair-verify flow.
|
|
477
|
+
* Parent class for authentication and credentials errors.
|
|
478
|
+
*/
|
|
479
|
+
var PairingError = class extends AppleProtocolError {
|
|
480
|
+
/** @param message - Human-readable description of the pairing failure. */
|
|
481
|
+
constructor(message) {
|
|
482
|
+
super(message);
|
|
483
|
+
this.name = "PairingError";
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
/**
|
|
487
|
+
* Thrown when the accessory's identity verification fails, such as an invalid
|
|
488
|
+
* Ed25519 signature or mismatched accessory identifier during pair-verify.
|
|
489
|
+
*/
|
|
490
|
+
var AuthenticationError = class extends PairingError {
|
|
491
|
+
/** @param message - Human-readable description of the authentication failure. */
|
|
492
|
+
constructor(message) {
|
|
493
|
+
super(message);
|
|
494
|
+
this.name = "AuthenticationError";
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* Thrown when stored credentials are invalid, missing, or incompatible
|
|
499
|
+
* with the accessory (e.g. after a factory reset).
|
|
500
|
+
*/
|
|
501
|
+
var CredentialsError = class extends PairingError {
|
|
502
|
+
/** @param message - Human-readable description of the credentials issue. */
|
|
503
|
+
constructor(message) {
|
|
504
|
+
super(message);
|
|
505
|
+
this.name = "CredentialsError";
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
/** Thrown when a protocol command fails to execute on the target device. */
|
|
509
|
+
var CommandError = class extends AppleProtocolError {
|
|
510
|
+
/** @param message - Human-readable description of the command failure. */
|
|
511
|
+
constructor(message) {
|
|
512
|
+
super(message);
|
|
513
|
+
this.name = "CommandError";
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
/** Thrown when a protocol setup step fails (e.g. RTSP SETUP, AirPlay stream setup). */
|
|
517
|
+
var SetupError = class extends AppleProtocolError {
|
|
518
|
+
/** @param message - Human-readable description of the setup failure. */
|
|
519
|
+
constructor(message) {
|
|
520
|
+
super(message);
|
|
521
|
+
this.name = "SetupError";
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
/** Thrown when mDNS device discovery fails or a device cannot be found after retries. */
|
|
525
|
+
var DiscoveryError = class extends AppleProtocolError {
|
|
526
|
+
/** @param message - Human-readable description of the discovery failure. */
|
|
527
|
+
constructor(message) {
|
|
528
|
+
super(message);
|
|
529
|
+
this.name = "DiscoveryError";
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
/** Thrown when an encryption or decryption operation fails (e.g. ChaCha20 auth tag mismatch). */
|
|
533
|
+
var EncryptionError = class extends AppleProtocolError {
|
|
534
|
+
/** @param message - Human-readable description of the encryption failure. */
|
|
535
|
+
constructor(message) {
|
|
536
|
+
super(message);
|
|
537
|
+
this.name = "EncryptionError";
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
/** Thrown when a response from the accessory is malformed or has an unexpected format. */
|
|
541
|
+
var InvalidResponseError = class extends AppleProtocolError {
|
|
542
|
+
/** @param message - Human-readable description of the invalid response. */
|
|
543
|
+
constructor(message) {
|
|
544
|
+
super(message);
|
|
545
|
+
this.name = "InvalidResponseError";
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
/** Thrown when a protocol operation exceeds its expected time limit. */
|
|
549
|
+
var TimeoutError = class extends AppleProtocolError {
|
|
550
|
+
/** @param message - Human-readable description of the timeout. */
|
|
551
|
+
constructor(message) {
|
|
552
|
+
super(message);
|
|
553
|
+
this.name = "TimeoutError";
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
/** Thrown when a media playback operation fails on the target device. */
|
|
557
|
+
var PlaybackError = class extends AppleProtocolError {
|
|
558
|
+
/** @param message - Human-readable description of the playback failure. */
|
|
559
|
+
constructor(message) {
|
|
560
|
+
super(message);
|
|
561
|
+
this.name = "PlaybackError";
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
260
565
|
//#endregion
|
|
261
566
|
//#region src/mdns.ts
|
|
567
|
+
/** IPv4 multicast address for mDNS (RFC 6762). */
|
|
262
568
|
const MDNS_ADDRESS = "224.0.0.251";
|
|
569
|
+
/** Standard mDNS port. */
|
|
263
570
|
const MDNS_PORT = 5353;
|
|
571
|
+
/** Query ID used in outgoing mDNS queries. */
|
|
264
572
|
const QUERY_ID = 13823;
|
|
573
|
+
/** Maximum number of service queries to pack into a single DNS message. */
|
|
265
574
|
const SERVICES_PER_MSG = 3;
|
|
575
|
+
/** DNS record type codes used in mDNS queries and responses. */
|
|
266
576
|
const QueryType = {
|
|
267
577
|
A: 1,
|
|
268
578
|
PTR: 12,
|
|
@@ -271,6 +581,12 @@ const QueryType = {
|
|
|
271
581
|
SRV: 33,
|
|
272
582
|
ANY: 255
|
|
273
583
|
};
|
|
584
|
+
/**
|
|
585
|
+
* Encodes a domain name into DNS wire format (length-prefixed labels terminated by 0x00).
|
|
586
|
+
*
|
|
587
|
+
* @param name - The domain name to encode (e.g. "_airplay._tcp.local").
|
|
588
|
+
* @returns A buffer containing the encoded QNAME.
|
|
589
|
+
*/
|
|
274
590
|
const encodeQName = (name) => {
|
|
275
591
|
const parts = [];
|
|
276
592
|
const labels = splitServiceName(name);
|
|
@@ -282,11 +598,25 @@ const encodeQName = (name) => {
|
|
|
282
598
|
parts.push(Buffer.from([0]));
|
|
283
599
|
return Buffer.concat(parts);
|
|
284
600
|
};
|
|
601
|
+
/**
|
|
602
|
+
* Splits a service name into DNS labels, handling instance names that may contain dots.
|
|
603
|
+
* For example, "Living Room._airplay._tcp.local" splits into
|
|
604
|
+
* ["Living Room", "_airplay", "_tcp", "local"].
|
|
605
|
+
*
|
|
606
|
+
* @param name - The full service name.
|
|
607
|
+
* @returns An array of DNS labels.
|
|
608
|
+
*/
|
|
285
609
|
const splitServiceName = (name) => {
|
|
286
610
|
const match = name.match(/\._[a-z]+\._(?:tcp|udp)\.local$/);
|
|
287
611
|
if (match) return [name.substring(0, match.index), ...match[0].substring(1).split(".")];
|
|
288
612
|
return name.split(".");
|
|
289
613
|
};
|
|
614
|
+
/**
|
|
615
|
+
* Encodes a DNS header into a 12-byte buffer.
|
|
616
|
+
*
|
|
617
|
+
* @param header - The header fields to encode.
|
|
618
|
+
* @returns A 12-byte buffer containing the encoded DNS header.
|
|
619
|
+
*/
|
|
290
620
|
const encodeDnsHeader = (header) => {
|
|
291
621
|
const buf = Buffer.allocUnsafe(12);
|
|
292
622
|
buf.writeUInt16BE(header.id, 0);
|
|
@@ -297,6 +627,14 @@ const encodeDnsHeader = (header) => {
|
|
|
297
627
|
buf.writeUInt16BE(header.arcount, 10);
|
|
298
628
|
return buf;
|
|
299
629
|
};
|
|
630
|
+
/**
|
|
631
|
+
* Encodes a single DNS question section entry.
|
|
632
|
+
*
|
|
633
|
+
* @param name - The domain name to query.
|
|
634
|
+
* @param qtype - The query type (e.g. PTR, SRV).
|
|
635
|
+
* @param unicastResponse - Whether to request a unicast response (QU bit).
|
|
636
|
+
* @returns A buffer containing the encoded question.
|
|
637
|
+
*/
|
|
300
638
|
const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
|
|
301
639
|
const qname = encodeQName(name);
|
|
302
640
|
const suffix = Buffer.allocUnsafe(4);
|
|
@@ -304,6 +642,15 @@ const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
|
|
|
304
642
|
suffix.writeUInt16BE(unicastResponse ? 32769 : 1, 2);
|
|
305
643
|
return Buffer.concat([qname, suffix]);
|
|
306
644
|
};
|
|
645
|
+
/**
|
|
646
|
+
* Creates one or more DNS query packets for the given service types.
|
|
647
|
+
* Services are batched into groups of {@link SERVICES_PER_MSG} per packet.
|
|
648
|
+
*
|
|
649
|
+
* @param services - The mDNS service types to query (e.g. '_airplay._tcp.local').
|
|
650
|
+
* @param qtype - The DNS query type. Defaults to PTR.
|
|
651
|
+
* @param unicastResponse - Whether to set the QU (unicast response) bit.
|
|
652
|
+
* @returns An array of encoded DNS query packets.
|
|
653
|
+
*/
|
|
307
654
|
function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = false) {
|
|
308
655
|
const packets = [];
|
|
309
656
|
for (let i = 0; i < services.length; i += SERVICES_PER_MSG) {
|
|
@@ -321,10 +668,20 @@ function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = f
|
|
|
321
668
|
}
|
|
322
669
|
return packets;
|
|
323
670
|
}
|
|
671
|
+
/** Maximum number of pointer jumps allowed during name decompression to prevent infinite loops. */
|
|
672
|
+
const MAX_POINTER_JUMPS = 128;
|
|
673
|
+
/**
|
|
674
|
+
* Decodes a DNS QNAME from a buffer, handling name compression pointers (RFC 1035 section 4.1.4).
|
|
675
|
+
*
|
|
676
|
+
* @param buf - The DNS packet buffer.
|
|
677
|
+
* @param offset - The byte offset where the QNAME starts.
|
|
678
|
+
* @returns A tuple of [decoded name string, next offset after the QNAME].
|
|
679
|
+
*/
|
|
324
680
|
const decodeQName = (buf, offset) => {
|
|
325
681
|
const labels = [];
|
|
326
682
|
let currentOffset = offset;
|
|
327
683
|
let jumped = false;
|
|
684
|
+
let jumps = 0;
|
|
328
685
|
let returnOffset = offset;
|
|
329
686
|
while (currentOffset < buf.byteLength) {
|
|
330
687
|
const length = buf[currentOffset];
|
|
@@ -333,6 +690,7 @@ const decodeQName = (buf, offset) => {
|
|
|
333
690
|
break;
|
|
334
691
|
}
|
|
335
692
|
if ((length & 192) === 192) {
|
|
693
|
+
if (++jumps > MAX_POINTER_JUMPS) break;
|
|
336
694
|
const pointer = (length & 63) << 8 | buf[currentOffset + 1];
|
|
337
695
|
if (!jumped) returnOffset = currentOffset + 2;
|
|
338
696
|
currentOffset = pointer;
|
|
@@ -340,12 +698,19 @@ const decodeQName = (buf, offset) => {
|
|
|
340
698
|
continue;
|
|
341
699
|
}
|
|
342
700
|
currentOffset++;
|
|
701
|
+
if (currentOffset + length > buf.byteLength) break;
|
|
343
702
|
labels.push(buf.toString("utf-8", currentOffset, currentOffset + length));
|
|
344
703
|
currentOffset += length;
|
|
345
704
|
if (!jumped) returnOffset = currentOffset;
|
|
346
705
|
}
|
|
347
706
|
return [labels.join("."), returnOffset];
|
|
348
707
|
};
|
|
708
|
+
/**
|
|
709
|
+
* Decodes a 12-byte DNS header from a buffer.
|
|
710
|
+
*
|
|
711
|
+
* @param buf - The DNS packet buffer (must be at least 12 bytes).
|
|
712
|
+
* @returns The parsed DNS header.
|
|
713
|
+
*/
|
|
349
714
|
const decodeDnsHeader = (buf) => ({
|
|
350
715
|
id: buf.readUInt16BE(0),
|
|
351
716
|
flags: buf.readUInt16BE(2),
|
|
@@ -354,6 +719,13 @@ const decodeDnsHeader = (buf) => ({
|
|
|
354
719
|
nscount: buf.readUInt16BE(8),
|
|
355
720
|
arcount: buf.readUInt16BE(10)
|
|
356
721
|
});
|
|
722
|
+
/**
|
|
723
|
+
* Decodes a DNS question section entry.
|
|
724
|
+
*
|
|
725
|
+
* @param buf - The DNS packet buffer.
|
|
726
|
+
* @param offset - The byte offset where the question starts.
|
|
727
|
+
* @returns A tuple of [parsed question, next offset].
|
|
728
|
+
*/
|
|
357
729
|
const decodeQuestion = (buf, offset) => {
|
|
358
730
|
const [qname, newOffset] = decodeQName(buf, offset);
|
|
359
731
|
return [{
|
|
@@ -362,6 +734,15 @@ const decodeQuestion = (buf, offset) => {
|
|
|
362
734
|
qclass: buf.readUInt16BE(newOffset + 2)
|
|
363
735
|
}, newOffset + 4];
|
|
364
736
|
};
|
|
737
|
+
/**
|
|
738
|
+
* Decodes a DNS TXT record into key-value pairs. Each entry is a length-prefixed
|
|
739
|
+
* UTF-8 string in "key=value" format.
|
|
740
|
+
*
|
|
741
|
+
* @param buf - The DNS packet buffer.
|
|
742
|
+
* @param offset - The byte offset where the TXT rdata starts.
|
|
743
|
+
* @param length - The total length of the TXT rdata.
|
|
744
|
+
* @returns A record of key-value pairs.
|
|
745
|
+
*/
|
|
365
746
|
const decodeTxtRecord = (buf, offset, length) => {
|
|
366
747
|
const properties = {};
|
|
367
748
|
let pos = offset;
|
|
@@ -378,6 +759,13 @@ const decodeTxtRecord = (buf, offset, length) => {
|
|
|
378
759
|
}
|
|
379
760
|
return properties;
|
|
380
761
|
};
|
|
762
|
+
/**
|
|
763
|
+
* Decodes a DNS SRV record containing service priority, weight, port, and target hostname.
|
|
764
|
+
*
|
|
765
|
+
* @param buf - The DNS packet buffer.
|
|
766
|
+
* @param offset - The byte offset where the SRV rdata starts.
|
|
767
|
+
* @returns The parsed SRV record.
|
|
768
|
+
*/
|
|
381
769
|
const decodeSrvRecord = (buf, offset) => {
|
|
382
770
|
const priority = buf.readUInt16BE(offset);
|
|
383
771
|
const weight = buf.readUInt16BE(offset + 2);
|
|
@@ -390,6 +778,14 @@ const decodeSrvRecord = (buf, offset) => {
|
|
|
390
778
|
target
|
|
391
779
|
};
|
|
392
780
|
};
|
|
781
|
+
/**
|
|
782
|
+
* Decodes a DNS resource record (answer, authority, or additional section).
|
|
783
|
+
* Dispatches to type-specific decoders for A, AAAA, PTR, SRV, and TXT records.
|
|
784
|
+
*
|
|
785
|
+
* @param buf - The DNS packet buffer.
|
|
786
|
+
* @param offset - The byte offset where the resource record starts.
|
|
787
|
+
* @returns A tuple of [parsed resource record, next offset].
|
|
788
|
+
*/
|
|
393
789
|
const decodeResource = (buf, offset) => {
|
|
394
790
|
const [qname, nameEnd] = decodeQName(buf, offset);
|
|
395
791
|
const qtype = buf.readUInt16BE(nameEnd);
|
|
@@ -429,6 +825,13 @@ const decodeResource = (buf, offset) => {
|
|
|
429
825
|
rdata
|
|
430
826
|
}, rdOffset + rdLength];
|
|
431
827
|
};
|
|
828
|
+
/**
|
|
829
|
+
* Decodes a complete DNS response packet into its header, answer records,
|
|
830
|
+
* and additional resource records. Skips question and authority sections.
|
|
831
|
+
*
|
|
832
|
+
* @param buf - The raw DNS response packet.
|
|
833
|
+
* @returns The parsed header, answer records, and additional resource records.
|
|
834
|
+
*/
|
|
432
835
|
const decodeDnsResponse = (buf) => {
|
|
433
836
|
const header = decodeDnsHeader(buf);
|
|
434
837
|
let offset = 12;
|
|
@@ -458,11 +861,27 @@ const decodeDnsResponse = (buf) => {
|
|
|
458
861
|
resources
|
|
459
862
|
};
|
|
460
863
|
};
|
|
864
|
+
/**
|
|
865
|
+
* Aggregates DNS records from multiple mDNS responses and resolves them
|
|
866
|
+
* into complete service descriptions. Correlates PTR, SRV, TXT, and A records
|
|
867
|
+
* to produce fully-resolved {@link MdnsService} instances.
|
|
868
|
+
*/
|
|
461
869
|
var ServiceCollector = class {
|
|
870
|
+
/** Maps service types to sets of instance QNAMEs (from PTR records). */
|
|
462
871
|
#ptrMap = /* @__PURE__ */ new Map();
|
|
872
|
+
/** Maps instance QNAMEs to their SRV records (port and target host). */
|
|
463
873
|
#srvMap = /* @__PURE__ */ new Map();
|
|
874
|
+
/** Maps instance QNAMEs to their TXT record properties. */
|
|
464
875
|
#txtMap = /* @__PURE__ */ new Map();
|
|
876
|
+
/** Maps hostnames to IPv4 addresses (from A records). */
|
|
465
877
|
#addressMap = /* @__PURE__ */ new Map();
|
|
878
|
+
/**
|
|
879
|
+
* Ingests DNS answer and additional resource records, categorizing them
|
|
880
|
+
* by type into the internal maps.
|
|
881
|
+
*
|
|
882
|
+
* @param answers - Answer section records.
|
|
883
|
+
* @param resources - Additional section records.
|
|
884
|
+
*/
|
|
466
885
|
addRecords(answers, resources) {
|
|
467
886
|
for (const record of [...answers, ...resources]) switch (record.qtype) {
|
|
468
887
|
case QueryType.PTR: {
|
|
@@ -482,6 +901,10 @@ var ServiceCollector = class {
|
|
|
482
901
|
break;
|
|
483
902
|
}
|
|
484
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* Resolves all collected records into complete service descriptions.
|
|
906
|
+
* Only returns services where all required data (PTR, SRV with non-zero port, A record) is present.
|
|
907
|
+
*/
|
|
485
908
|
get services() {
|
|
486
909
|
const results = [];
|
|
487
910
|
for (const [serviceType, instanceNames] of this.#ptrMap) for (const instanceQName of instanceNames) {
|
|
@@ -503,12 +926,20 @@ var ServiceCollector = class {
|
|
|
503
926
|
return results;
|
|
504
927
|
}
|
|
505
928
|
};
|
|
929
|
+
/** Well-known ports used to wake sleeping Apple devices via TCP SYN. */
|
|
506
930
|
const WAKE_PORTS$1 = [
|
|
507
931
|
7e3,
|
|
508
932
|
3689,
|
|
509
933
|
49152,
|
|
510
934
|
32498
|
|
511
935
|
];
|
|
936
|
+
/**
|
|
937
|
+
* Sends TCP connection attempts ("knocks") to well-known Apple service ports
|
|
938
|
+
* to wake a sleeping device before querying it.
|
|
939
|
+
*
|
|
940
|
+
* @param address - The IP address of the device to wake.
|
|
941
|
+
* @returns A promise that resolves when all knock attempts complete (success or failure).
|
|
942
|
+
*/
|
|
512
943
|
const knock = (address) => {
|
|
513
944
|
const promises = WAKE_PORTS$1.map((port) => new Promise((resolve) => {
|
|
514
945
|
const socket = createConnection({
|
|
@@ -531,6 +962,16 @@ const knock = (address) => {
|
|
|
531
962
|
}));
|
|
532
963
|
return Promise.all(promises).then(() => {});
|
|
533
964
|
};
|
|
965
|
+
/**
|
|
966
|
+
* Performs unicast DNS-SD queries to specific hosts. First wakes the devices
|
|
967
|
+
* via TCP knocking, then repeatedly sends DNS queries via UDP to port 5353 on
|
|
968
|
+
* each host, collecting responses for the specified duration.
|
|
969
|
+
*
|
|
970
|
+
* @param hosts - IP addresses of the specific devices to query.
|
|
971
|
+
* @param services - mDNS service types to discover.
|
|
972
|
+
* @param timeout - Discovery duration in seconds. Defaults to 4.
|
|
973
|
+
* @returns An array of resolved mDNS services.
|
|
974
|
+
*/
|
|
534
975
|
function unicast(hosts, services, timeout = 4) {
|
|
535
976
|
return new Promise((resolve) => {
|
|
536
977
|
const queries = createQueryPackets(services);
|
|
@@ -568,6 +1009,19 @@ function unicast(hosts, services, timeout = 4) {
|
|
|
568
1009
|
});
|
|
569
1010
|
});
|
|
570
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Performs multicast DNS-SD discovery on the local network. Creates UDP sockets
|
|
1014
|
+
* on all network interfaces, joins the mDNS multicast group (224.0.0.251),
|
|
1015
|
+
* and sends periodic queries for the specified duration.
|
|
1016
|
+
*
|
|
1017
|
+
* Creates two types of sockets:
|
|
1018
|
+
* - One on 0.0.0.0:5353 to receive multicast responses (may fail on some platforms)
|
|
1019
|
+
* - One per local network interface on a random port with multicast membership
|
|
1020
|
+
*
|
|
1021
|
+
* @param services - mDNS service types to discover.
|
|
1022
|
+
* @param timeout - Discovery duration in seconds. Defaults to 4.
|
|
1023
|
+
* @returns An array of resolved mDNS services found on the network.
|
|
1024
|
+
*/
|
|
571
1025
|
function multicast(services, timeout = 4) {
|
|
572
1026
|
return new Promise((resolve) => {
|
|
573
1027
|
const collector = new ServiceCollector();
|
|
@@ -644,15 +1098,195 @@ function multicast(services, timeout = 4) {
|
|
|
644
1098
|
});
|
|
645
1099
|
}
|
|
646
1100
|
|
|
1101
|
+
//#endregion
|
|
1102
|
+
//#region src/reporter.ts
|
|
1103
|
+
/**
|
|
1104
|
+
* Scoped logger instance tagged with a device or component identifier.
|
|
1105
|
+
* All log output is gated by the global {@link reporter} singleton — messages
|
|
1106
|
+
* are only printed when the corresponding debug group is enabled.
|
|
1107
|
+
*/
|
|
1108
|
+
var Logger = class {
|
|
1109
|
+
/** The identifier this logger is scoped to. */
|
|
1110
|
+
get id() {
|
|
1111
|
+
return this.#id;
|
|
1112
|
+
}
|
|
1113
|
+
/** ANSI-colored label prefix used in log output. */
|
|
1114
|
+
get label() {
|
|
1115
|
+
return this.#label;
|
|
1116
|
+
}
|
|
1117
|
+
#id;
|
|
1118
|
+
#label;
|
|
1119
|
+
/**
|
|
1120
|
+
* @param id - Identifier used as a prefix in log output (typically a device ID or component name).
|
|
1121
|
+
*/
|
|
1122
|
+
constructor(id) {
|
|
1123
|
+
this.#id = id;
|
|
1124
|
+
this.#label = `\u001b[36m[${id}]\u001b[39m`;
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Logs a debug-level message (cyan). Only printed when the 'debug' group is enabled.
|
|
1128
|
+
*
|
|
1129
|
+
* @param data - Values to log.
|
|
1130
|
+
*/
|
|
1131
|
+
debug(...data) {
|
|
1132
|
+
debug(this.#label, ...data);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Logs an error-level message (red). Only printed when the 'error' group is enabled.
|
|
1136
|
+
*
|
|
1137
|
+
* @param data - Values to log.
|
|
1138
|
+
*/
|
|
1139
|
+
error(...data) {
|
|
1140
|
+
error(this.#label, ...data);
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Logs an info-level message (green). Only printed when the 'info' group is enabled.
|
|
1144
|
+
*
|
|
1145
|
+
* @param data - Values to log.
|
|
1146
|
+
*/
|
|
1147
|
+
info(...data) {
|
|
1148
|
+
info(this.#label, ...data);
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Logs a network-level message (yellow). Only printed when the 'net' group is enabled.
|
|
1152
|
+
*
|
|
1153
|
+
* @param data - Values to log.
|
|
1154
|
+
*/
|
|
1155
|
+
net(...data) {
|
|
1156
|
+
net(this.#label, ...data);
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Logs a raw data message (blue). Only printed when the 'raw' group is enabled.
|
|
1160
|
+
* Typically used for hex dumps and binary protocol data.
|
|
1161
|
+
*
|
|
1162
|
+
* @param data - Values to log.
|
|
1163
|
+
*/
|
|
1164
|
+
raw(...data) {
|
|
1165
|
+
raw(this.#label, ...data);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Logs a warning-level message (yellow). Only printed when the 'warn' group is enabled.
|
|
1169
|
+
*
|
|
1170
|
+
* @param data - Values to log.
|
|
1171
|
+
*/
|
|
1172
|
+
warn(...data) {
|
|
1173
|
+
warn(this.#label, ...data);
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
/**
|
|
1177
|
+
* Global log output controller that manages which debug groups are active.
|
|
1178
|
+
* All {@link Logger} instances check the singleton {@link reporter} before printing.
|
|
1179
|
+
*/
|
|
1180
|
+
var Reporter = class {
|
|
1181
|
+
#enabled = [];
|
|
1182
|
+
/** Enables all debug groups (except 'raw' which is very verbose). */
|
|
1183
|
+
all() {
|
|
1184
|
+
this.#enabled = [
|
|
1185
|
+
"debug",
|
|
1186
|
+
"error",
|
|
1187
|
+
"info",
|
|
1188
|
+
"net",
|
|
1189
|
+
"warn"
|
|
1190
|
+
];
|
|
1191
|
+
}
|
|
1192
|
+
/** Disables all debug groups, silencing all log output. */
|
|
1193
|
+
none() {
|
|
1194
|
+
this.#enabled = [];
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Disables a specific debug group.
|
|
1198
|
+
*
|
|
1199
|
+
* @param group - The debug group to disable.
|
|
1200
|
+
*/
|
|
1201
|
+
disable(group) {
|
|
1202
|
+
if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Enables a specific debug group.
|
|
1206
|
+
*
|
|
1207
|
+
* @param group - The debug group to enable.
|
|
1208
|
+
*/
|
|
1209
|
+
enable(group) {
|
|
1210
|
+
if (!this.#enabled.includes(group)) this.#enabled.push(group);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Checks whether a specific debug group is currently enabled.
|
|
1214
|
+
*
|
|
1215
|
+
* @param group - The debug group to check.
|
|
1216
|
+
* @returns True if the group is enabled.
|
|
1217
|
+
*/
|
|
1218
|
+
isEnabled(group) {
|
|
1219
|
+
return this.#enabled.includes(group);
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
/**
|
|
1223
|
+
* Logs a debug-level message if the 'debug' group is enabled.
|
|
1224
|
+
*
|
|
1225
|
+
* @param data - Values to log.
|
|
1226
|
+
*/
|
|
1227
|
+
function debug(...data) {
|
|
1228
|
+
reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Logs an error-level message if the 'error' group is enabled.
|
|
1232
|
+
*
|
|
1233
|
+
* @param data - Values to log.
|
|
1234
|
+
*/
|
|
1235
|
+
function error(...data) {
|
|
1236
|
+
reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Logs an info-level message if the 'info' group is enabled.
|
|
1240
|
+
*
|
|
1241
|
+
* @param data - Values to log.
|
|
1242
|
+
*/
|
|
1243
|
+
function info(...data) {
|
|
1244
|
+
reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Logs a network-level message if the 'net' group is enabled.
|
|
1248
|
+
*
|
|
1249
|
+
* @param data - Values to log.
|
|
1250
|
+
*/
|
|
1251
|
+
function net(...data) {
|
|
1252
|
+
reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Logs a raw data message if the 'raw' group is enabled.
|
|
1256
|
+
*
|
|
1257
|
+
* @param data - Values to log.
|
|
1258
|
+
*/
|
|
1259
|
+
function raw(...data) {
|
|
1260
|
+
reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Logs a warning-level message if the 'warn' group is enabled.
|
|
1264
|
+
*
|
|
1265
|
+
* @param data - Values to log.
|
|
1266
|
+
*/
|
|
1267
|
+
function warn(...data) {
|
|
1268
|
+
reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
|
|
1269
|
+
}
|
|
1270
|
+
/** Global reporter singleton controlling which debug groups produce output. */
|
|
1271
|
+
const reporter = new Reporter();
|
|
1272
|
+
|
|
647
1273
|
//#endregion
|
|
648
1274
|
//#region src/discovery.ts
|
|
1275
|
+
/** Cache time-to-live in milliseconds. */
|
|
649
1276
|
const CACHE_TTL = 3e4;
|
|
1277
|
+
/** Ports used for wake-on-network "knocking" to wake sleeping Apple devices. */
|
|
650
1278
|
const WAKE_PORTS = [
|
|
651
1279
|
7e3,
|
|
652
1280
|
3689,
|
|
653
1281
|
49152,
|
|
654
1282
|
32498
|
|
655
1283
|
];
|
|
1284
|
+
/**
|
|
1285
|
+
* Converts a raw mDNS service record into a {@link DiscoveryResult}.
|
|
1286
|
+
*
|
|
1287
|
+
* @param service - The mDNS service record.
|
|
1288
|
+
* @returns A normalized discovery result.
|
|
1289
|
+
*/
|
|
656
1290
|
const toDiscoveryResult = (service) => {
|
|
657
1291
|
const txt = service.properties;
|
|
658
1292
|
const featuresStr = txt.features ?? txt.ft;
|
|
@@ -675,6 +1309,12 @@ const toDiscoveryResult = (service) => {
|
|
|
675
1309
|
packet: null
|
|
676
1310
|
};
|
|
677
1311
|
};
|
|
1312
|
+
/**
|
|
1313
|
+
* Safely parses a features string, returning undefined on failure.
|
|
1314
|
+
*
|
|
1315
|
+
* @param features - The features string to parse.
|
|
1316
|
+
* @returns The parsed feature bitmask, or undefined if parsing fails.
|
|
1317
|
+
*/
|
|
678
1318
|
const tryParseFeatures = (features) => {
|
|
679
1319
|
try {
|
|
680
1320
|
return parseFeatures(features);
|
|
@@ -682,12 +1322,32 @@ const tryParseFeatures = (features) => {
|
|
|
682
1322
|
return;
|
|
683
1323
|
}
|
|
684
1324
|
};
|
|
1325
|
+
const logger = new Logger("discovery");
|
|
1326
|
+
/**
|
|
1327
|
+
* mDNS service discovery for Apple devices on the local network.
|
|
1328
|
+
*
|
|
1329
|
+
* Supports discovering AirPlay, Companion Link, and RAOP services via multicast DNS.
|
|
1330
|
+
* Results are cached for 30 seconds to avoid excessive network traffic.
|
|
1331
|
+
* Use the static factory methods {@link Discovery.airplay}, {@link Discovery.companionLink},
|
|
1332
|
+
* and {@link Discovery.raop} for convenience.
|
|
1333
|
+
*/
|
|
685
1334
|
var Discovery = class Discovery {
|
|
1335
|
+
/** Shared cache of discovery results, keyed by service type. */
|
|
686
1336
|
static #cache = /* @__PURE__ */ new Map();
|
|
687
1337
|
#service;
|
|
1338
|
+
/**
|
|
1339
|
+
* @param service - The mDNS service type to discover (e.g. '_airplay._tcp.local').
|
|
1340
|
+
*/
|
|
688
1341
|
constructor(service) {
|
|
689
1342
|
this.#service = service;
|
|
690
1343
|
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Discovers devices advertising this service type via mDNS multicast.
|
|
1346
|
+
* Returns cached results if available and not expired.
|
|
1347
|
+
*
|
|
1348
|
+
* @param useCache - Whether to use cached results. Defaults to true.
|
|
1349
|
+
* @returns An array of discovered devices.
|
|
1350
|
+
*/
|
|
691
1351
|
async find(useCache = true) {
|
|
692
1352
|
if (useCache) {
|
|
693
1353
|
const cached = Discovery.#cache.get(this.#service);
|
|
@@ -702,22 +1362,37 @@ var Discovery = class Discovery {
|
|
|
702
1362
|
});
|
|
703
1363
|
return mapped;
|
|
704
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Repeatedly searches for a specific device by ID until found or retries are exhausted.
|
|
1367
|
+
* Does not use the cache to ensure fresh results on each attempt.
|
|
1368
|
+
*
|
|
1369
|
+
* @param id - The device ID to search for.
|
|
1370
|
+
* @param tries - Maximum number of discovery attempts. Defaults to 10.
|
|
1371
|
+
* @param timeout - Delay in milliseconds between attempts. Defaults to 1000.
|
|
1372
|
+
* @returns The discovered device.
|
|
1373
|
+
* @throws {DiscoveryError} If the device is not found after all attempts.
|
|
1374
|
+
*/
|
|
705
1375
|
async findUntil(id, tries = 10, timeout = 1e3) {
|
|
706
1376
|
while (tries > 0) {
|
|
707
1377
|
const devices = await this.find(false);
|
|
708
1378
|
const device = devices.find((device) => device.id === id);
|
|
709
1379
|
if (device) return device;
|
|
710
|
-
|
|
711
|
-
console.log(`Device not found, retrying in ${timeout}ms...`);
|
|
712
|
-
console.log(devices.map((d) => ` ● ${d.id} (${d.fqdn})`).join("\n"));
|
|
1380
|
+
logger.debug(`Device '${id}' not found, retrying in ${timeout}ms...`, devices.map((d) => d.id));
|
|
713
1381
|
tries--;
|
|
714
1382
|
await waitFor(timeout);
|
|
715
1383
|
}
|
|
716
|
-
throw new
|
|
1384
|
+
throw new DiscoveryError(`Device '${id}' not found after several tries, aborting.`);
|
|
717
1385
|
}
|
|
1386
|
+
/** Clears all cached discovery results across all service types. */
|
|
718
1387
|
static clearCache() {
|
|
719
1388
|
Discovery.#cache.clear();
|
|
720
1389
|
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Attempts to wake a sleeping Apple device by knocking on well-known ports.
|
|
1392
|
+
* Sends TCP connection attempts to ports 7000, 3689, 49152, and 32498.
|
|
1393
|
+
*
|
|
1394
|
+
* @param address - The IP address of the device to wake.
|
|
1395
|
+
*/
|
|
721
1396
|
static async wake(address) {
|
|
722
1397
|
const promises = WAKE_PORTS.map((port) => new Promise((resolve) => {
|
|
723
1398
|
const socket = createConnection({
|
|
@@ -740,6 +1415,13 @@ var Discovery = class Discovery {
|
|
|
740
1415
|
}));
|
|
741
1416
|
await Promise.all(promises);
|
|
742
1417
|
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Discovers all Apple devices on the network across all supported service types
|
|
1420
|
+
* (AirPlay, Companion Link, RAOP) and merges them by device ID.
|
|
1421
|
+
*
|
|
1422
|
+
* @returns An array of combined results, each representing a single physical device
|
|
1423
|
+
* with its available service endpoints.
|
|
1424
|
+
*/
|
|
743
1425
|
static async discoverAll() {
|
|
744
1426
|
const allServices = await multicast([
|
|
745
1427
|
AIRPLAY_SERVICE,
|
|
@@ -765,12 +1447,27 @@ var Discovery = class Discovery {
|
|
|
765
1447
|
}
|
|
766
1448
|
return [...devices.values()];
|
|
767
1449
|
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Creates a Discovery instance for AirPlay services.
|
|
1452
|
+
*
|
|
1453
|
+
* @returns A new Discovery instance targeting `_airplay._tcp.local`.
|
|
1454
|
+
*/
|
|
768
1455
|
static airplay() {
|
|
769
1456
|
return new Discovery(AIRPLAY_SERVICE);
|
|
770
1457
|
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Creates a Discovery instance for Companion Link services.
|
|
1460
|
+
*
|
|
1461
|
+
* @returns A new Discovery instance targeting `_companion-link._tcp.local`.
|
|
1462
|
+
*/
|
|
771
1463
|
static companionLink() {
|
|
772
1464
|
return new Discovery(COMPANION_LINK_SERVICE);
|
|
773
1465
|
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Creates a Discovery instance for RAOP services.
|
|
1468
|
+
*
|
|
1469
|
+
* @returns A new Discovery instance targeting `_raop._tcp.local`.
|
|
1470
|
+
*/
|
|
774
1471
|
static raop() {
|
|
775
1472
|
return new Discovery(RAOP_SERVICE);
|
|
776
1473
|
}
|
|
@@ -778,23 +1475,42 @@ var Discovery = class Discovery {
|
|
|
778
1475
|
|
|
779
1476
|
//#endregion
|
|
780
1477
|
//#region src/connection.ts
|
|
1478
|
+
/** No-op promise handler used as a fallback when no connect promise is active. */
|
|
781
1479
|
const NOOP_PROMISE_HANDLER = {
|
|
782
1480
|
resolve: () => {},
|
|
783
1481
|
reject: (_) => {}
|
|
784
1482
|
};
|
|
1483
|
+
/**
|
|
1484
|
+
* TCP socket connection wrapper with built-in retry logic and typed events.
|
|
1485
|
+
*
|
|
1486
|
+
* Manages a single TCP socket to an Apple device, providing automatic reconnection
|
|
1487
|
+
* on failure (configurable attempts and interval), keep-alive, and no-delay settings.
|
|
1488
|
+
* Subclasses can extend the event map with protocol-specific events.
|
|
1489
|
+
*
|
|
1490
|
+
* Default retry behavior: 3 attempts with 3-second intervals between retries.
|
|
1491
|
+
*/
|
|
785
1492
|
var Connection = class extends EventEmitter {
|
|
1493
|
+
/** The remote IP address this connection targets. */
|
|
786
1494
|
get address() {
|
|
787
1495
|
return this.#address;
|
|
788
1496
|
}
|
|
1497
|
+
/** The shared context carrying device identity and logger. */
|
|
789
1498
|
get context() {
|
|
790
1499
|
return this.#context;
|
|
791
1500
|
}
|
|
1501
|
+
/** The remote port this connection targets. */
|
|
792
1502
|
get port() {
|
|
793
1503
|
return this.#port;
|
|
794
1504
|
}
|
|
1505
|
+
/** Whether the connection is currently established and open. */
|
|
795
1506
|
get isConnected() {
|
|
796
1507
|
return this.#state === "connected";
|
|
797
1508
|
}
|
|
1509
|
+
/** The local IP address of the socket, or '0.0.0.0' if not connected. */
|
|
1510
|
+
get localAddress() {
|
|
1511
|
+
return this.#socket?.localAddress ?? "0.0.0.0";
|
|
1512
|
+
}
|
|
1513
|
+
/** The current connection state, derived from both internal state and socket readyState. */
|
|
798
1514
|
get state() {
|
|
799
1515
|
if (this.#state === "closing" || this.#state === "failed") return this.#state;
|
|
800
1516
|
if (!this.#socket) return "disconnected";
|
|
@@ -807,7 +1523,6 @@ var Connection = class extends EventEmitter {
|
|
|
807
1523
|
#address;
|
|
808
1524
|
#port;
|
|
809
1525
|
#context;
|
|
810
|
-
#emitInternal = (event, ...args) => this.emit(event, ...args);
|
|
811
1526
|
#debug = false;
|
|
812
1527
|
#retryAttempt = 0;
|
|
813
1528
|
#retryAttempts = 3;
|
|
@@ -817,23 +1532,45 @@ var Connection = class extends EventEmitter {
|
|
|
817
1532
|
#socket;
|
|
818
1533
|
#state;
|
|
819
1534
|
#connectPromise;
|
|
1535
|
+
/**
|
|
1536
|
+
* @param context - Shared context with device identity and logger.
|
|
1537
|
+
* @param address - The remote IP address to connect to.
|
|
1538
|
+
* @param port - The remote port to connect to.
|
|
1539
|
+
*/
|
|
820
1540
|
constructor(context, address, port) {
|
|
821
1541
|
super();
|
|
822
1542
|
this.#address = address;
|
|
823
1543
|
this.#port = port;
|
|
824
1544
|
this.#context = context;
|
|
825
1545
|
this.#state = "disconnected";
|
|
1546
|
+
this.onClose = this.onClose.bind(this);
|
|
1547
|
+
this.onConnect = this.onConnect.bind(this);
|
|
1548
|
+
this.onData = this.onData.bind(this);
|
|
1549
|
+
this.onEnd = this.onEnd.bind(this);
|
|
1550
|
+
this.onError = this.onError.bind(this);
|
|
1551
|
+
this.onTimeout = this.onTimeout.bind(this);
|
|
826
1552
|
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Establishes a TCP connection to the remote address. If already connected,
|
|
1555
|
+
* returns immediately. Enables retry logic for the duration of this connection.
|
|
1556
|
+
*
|
|
1557
|
+
* @throws {ConnectionError} If already connecting or all retry attempts are exhausted.
|
|
1558
|
+
*/
|
|
827
1559
|
async connect() {
|
|
828
1560
|
if (this.#state === "connected") return;
|
|
829
|
-
if (this.#state === "connecting") throw new
|
|
1561
|
+
if (this.#state === "connecting") throw new ConnectionError("A connection is already being established.");
|
|
830
1562
|
this.#retryEnabled = true;
|
|
831
1563
|
this.#retryAttempt = 0;
|
|
832
1564
|
return this.#attemptConnect();
|
|
833
1565
|
}
|
|
1566
|
+
/** Immediately destroys the underlying socket without graceful shutdown. */
|
|
834
1567
|
destroy() {
|
|
835
1568
|
this.#socket?.destroy();
|
|
836
1569
|
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Gracefully disconnects by ending the socket and waiting for the 'close' event.
|
|
1572
|
+
* Disables retry logic so the connection does not automatically reconnect.
|
|
1573
|
+
*/
|
|
837
1574
|
async disconnect() {
|
|
838
1575
|
if (this.#retryTimeout) {
|
|
839
1576
|
clearTimeout(this.#retryTimeout);
|
|
@@ -841,6 +1578,10 @@ var Connection = class extends EventEmitter {
|
|
|
841
1578
|
}
|
|
842
1579
|
this.#retryEnabled = false;
|
|
843
1580
|
if (!this.#socket || this.#state === "disconnected") return;
|
|
1581
|
+
if (this.#socket.destroyed || this.#socket.readyState === "closed") {
|
|
1582
|
+
this.#cleanup();
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
844
1585
|
return new Promise((resolve) => {
|
|
845
1586
|
this.#state = "closing";
|
|
846
1587
|
this.#socket.once("close", () => {
|
|
@@ -850,18 +1591,37 @@ var Connection = class extends EventEmitter {
|
|
|
850
1591
|
this.#socket.end();
|
|
851
1592
|
});
|
|
852
1593
|
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Enables or disables debug logging for incoming data (hex + ASCII dumps).
|
|
1596
|
+
*
|
|
1597
|
+
* @param enabled - Whether to enable debug output.
|
|
1598
|
+
* @returns This connection instance for chaining.
|
|
1599
|
+
*/
|
|
853
1600
|
debug(enabled) {
|
|
854
1601
|
this.#debug = enabled;
|
|
855
1602
|
return this;
|
|
856
1603
|
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Configures the retry behavior for connection attempts.
|
|
1606
|
+
*
|
|
1607
|
+
* @param attempts - Maximum number of retry attempts.
|
|
1608
|
+
* @param interval - Delay in milliseconds between retry attempts.
|
|
1609
|
+
* @returns This connection instance for chaining.
|
|
1610
|
+
*/
|
|
857
1611
|
retry(attempts, interval = 3e3) {
|
|
858
1612
|
this.#retryAttempts = attempts;
|
|
859
1613
|
this.#retryInterval = interval;
|
|
860
1614
|
return this;
|
|
861
1615
|
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Writes data to the underlying TCP socket.
|
|
1618
|
+
* Emits an error event if the socket is not writable.
|
|
1619
|
+
*
|
|
1620
|
+
* @param data - The data to send.
|
|
1621
|
+
*/
|
|
862
1622
|
write(data) {
|
|
863
1623
|
if (!this.#socket || this.state !== "connected" || !this.#socket.writable) {
|
|
864
|
-
this.#emitInternal("error",
|
|
1624
|
+
this.#emitInternal("error", new ConnectionClosedError("Cannot write to a disconnected connection."));
|
|
865
1625
|
return;
|
|
866
1626
|
}
|
|
867
1627
|
this.#socket.write(data, (err) => {
|
|
@@ -870,6 +1630,10 @@ var Connection = class extends EventEmitter {
|
|
|
870
1630
|
this.#emitInternal("error", err);
|
|
871
1631
|
});
|
|
872
1632
|
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Creates a new socket and attempts to connect. The returned promise resolves
|
|
1635
|
+
* on successful connect or rejects after all retries are exhausted.
|
|
1636
|
+
*/
|
|
873
1637
|
async #attemptConnect() {
|
|
874
1638
|
return new Promise((resolve, reject) => {
|
|
875
1639
|
this.#state = "connecting";
|
|
@@ -878,16 +1642,17 @@ var Connection = class extends EventEmitter {
|
|
|
878
1642
|
reject
|
|
879
1643
|
};
|
|
880
1644
|
this.#socket?.removeAllListeners();
|
|
1645
|
+
this.#socket?.destroy();
|
|
881
1646
|
this.#socket = void 0;
|
|
882
1647
|
this.#socket = new Socket();
|
|
883
1648
|
this.#socket.setNoDelay(true);
|
|
884
1649
|
this.#socket.setTimeout(SOCKET_TIMEOUT);
|
|
885
|
-
this.#socket.on("close", this
|
|
886
|
-
this.#socket.on("connect", this
|
|
887
|
-
this.#socket.on("data", this
|
|
888
|
-
this.#socket.on("end", this
|
|
889
|
-
this.#socket.on("error", this
|
|
890
|
-
this.#socket.on("timeout", this
|
|
1650
|
+
this.#socket.on("close", this.onClose);
|
|
1651
|
+
this.#socket.on("connect", this.onConnect);
|
|
1652
|
+
this.#socket.on("data", this.onData);
|
|
1653
|
+
this.#socket.on("end", this.onEnd);
|
|
1654
|
+
this.#socket.on("error", this.onError);
|
|
1655
|
+
this.#socket.on("timeout", this.onTimeout);
|
|
891
1656
|
this.#context.logger.net(`Connecting to ${this.#address}:${this.#port}...`);
|
|
892
1657
|
this.#socket.connect({
|
|
893
1658
|
host: this.#address,
|
|
@@ -895,6 +1660,7 @@ var Connection = class extends EventEmitter {
|
|
|
895
1660
|
});
|
|
896
1661
|
});
|
|
897
1662
|
}
|
|
1663
|
+
/** Removes all socket listeners, destroys the socket, and resets connection state. */
|
|
898
1664
|
#cleanup() {
|
|
899
1665
|
if (this.#retryTimeout) {
|
|
900
1666
|
clearTimeout(this.#retryTimeout);
|
|
@@ -908,6 +1674,12 @@ var Connection = class extends EventEmitter {
|
|
|
908
1674
|
this.#state = "disconnected";
|
|
909
1675
|
this.#connectPromise = void 0;
|
|
910
1676
|
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Schedules a retry attempt after a failed connection. If all attempts are exhausted,
|
|
1679
|
+
* transitions to 'failed' state and rejects the original connect promise.
|
|
1680
|
+
*
|
|
1681
|
+
* @param err - The error that triggered the retry.
|
|
1682
|
+
*/
|
|
911
1683
|
#scheduleRetry(err) {
|
|
912
1684
|
if (!this.#retryEnabled || this.#retryAttempt >= this.#retryAttempts) {
|
|
913
1685
|
this.#state = "failed";
|
|
@@ -932,19 +1704,31 @@ var Connection = class extends EventEmitter {
|
|
|
932
1704
|
};
|
|
933
1705
|
await this.#attemptConnect();
|
|
934
1706
|
resolve();
|
|
935
|
-
} catch (retryErr) {
|
|
1707
|
+
} catch (retryErr) {
|
|
1708
|
+
reject(retryErr instanceof Error ? retryErr : new ConnectionError(String(retryErr)));
|
|
1709
|
+
}
|
|
936
1710
|
}, this.#retryInterval);
|
|
937
1711
|
}
|
|
938
|
-
|
|
1712
|
+
/**
|
|
1713
|
+
* Handles the socket 'close' event. If the connection was active and closed
|
|
1714
|
+
* unexpectedly with an error, triggers a retry.
|
|
1715
|
+
*
|
|
1716
|
+
* @param hadError - Whether the close was caused by an error.
|
|
1717
|
+
*/
|
|
1718
|
+
onClose(hadError) {
|
|
939
1719
|
const wasConnected = this.#state === "connected";
|
|
940
1720
|
if (this.#state !== "closing") {
|
|
941
1721
|
this.#state = "disconnected";
|
|
942
1722
|
this.#context.logger.net(`Connection closed (${hadError ? "with error" : "normally"}).`);
|
|
943
1723
|
}
|
|
944
1724
|
this.#emitInternal("close", hadError);
|
|
945
|
-
if (wasConnected && this.#retryEnabled && hadError) this.#scheduleRetry(
|
|
1725
|
+
if (wasConnected && this.#retryEnabled && hadError) this.#scheduleRetry(new ConnectionClosedError());
|
|
946
1726
|
}
|
|
947
|
-
|
|
1727
|
+
/**
|
|
1728
|
+
* Handles successful TCP connection. Enables keep-alive (10s interval),
|
|
1729
|
+
* disables the connection timeout, resets retry counter, and resolves the connect promise.
|
|
1730
|
+
*/
|
|
1731
|
+
onConnect() {
|
|
948
1732
|
this.#state = "connected";
|
|
949
1733
|
this.#retryAttempt = 0;
|
|
950
1734
|
this.#socket.setKeepAlive(true, 1e4);
|
|
@@ -953,7 +1737,13 @@ var Connection = class extends EventEmitter {
|
|
|
953
1737
|
this.#connectPromise?.resolve();
|
|
954
1738
|
this.#connectPromise = void 0;
|
|
955
1739
|
}
|
|
956
|
-
|
|
1740
|
+
/**
|
|
1741
|
+
* Handles incoming data from the socket. When debug mode is enabled,
|
|
1742
|
+
* logs a hex and ASCII dump of the first 64 bytes.
|
|
1743
|
+
*
|
|
1744
|
+
* @param data - The received data buffer.
|
|
1745
|
+
*/
|
|
1746
|
+
onData(data) {
|
|
957
1747
|
if (this.#debug) {
|
|
958
1748
|
const cutoff = Math.min(data.byteLength, 64);
|
|
959
1749
|
this.#context.logger.debug(`Received ${data.byteLength} bytes of data.`);
|
|
@@ -962,19 +1752,29 @@ var Connection = class extends EventEmitter {
|
|
|
962
1752
|
}
|
|
963
1753
|
this.#emitInternal("data", data);
|
|
964
1754
|
}
|
|
965
|
-
|
|
1755
|
+
/** Handles the socket 'end' event (remote end sent FIN). */
|
|
1756
|
+
onEnd() {
|
|
966
1757
|
this.#emitInternal("end");
|
|
967
1758
|
}
|
|
968
|
-
|
|
1759
|
+
/**
|
|
1760
|
+
* Handles socket errors. If connecting, schedules a retry; otherwise marks
|
|
1761
|
+
* the connection as failed. Warns if no error listener is registered.
|
|
1762
|
+
*
|
|
1763
|
+
* @param err - The socket error.
|
|
1764
|
+
*/
|
|
1765
|
+
onError(err) {
|
|
969
1766
|
this.#context.logger.error(`Connection error: ${err.message}`);
|
|
970
1767
|
if (this.listenerCount("error") > 0) this.#emitInternal("error", err);
|
|
971
|
-
else this.#context.logger.warn("No error handler registered. This is likely a bug.", this.constructor.name, "
|
|
1768
|
+
else this.#context.logger.warn("No error handler registered. This is likely a bug.", this.constructor.name, "onError");
|
|
972
1769
|
if (this.#state === "connecting") this.#scheduleRetry(err);
|
|
973
|
-
else this.#state = "failed";
|
|
974
1770
|
}
|
|
975
|
-
|
|
1771
|
+
/**
|
|
1772
|
+
* Handles socket timeout. If connecting, schedules a retry;
|
|
1773
|
+
* otherwise destroys the socket and marks the connection as failed.
|
|
1774
|
+
*/
|
|
1775
|
+
onTimeout() {
|
|
976
1776
|
this.#context.logger.error("Connection timed out.");
|
|
977
|
-
const err =
|
|
1777
|
+
const err = new ConnectionTimeoutError();
|
|
978
1778
|
this.#emitInternal("timeout");
|
|
979
1779
|
if (this.#state === "connecting") this.#scheduleRetry(err);
|
|
980
1780
|
else {
|
|
@@ -982,21 +1782,60 @@ var Connection = class extends EventEmitter {
|
|
|
982
1782
|
this.#socket?.destroy();
|
|
983
1783
|
}
|
|
984
1784
|
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Type-safe internal emit that narrows the event to ConnectionEventMap keys.
|
|
1787
|
+
* Needed because the merged TEventMap makes the standard emit signature too broad.
|
|
1788
|
+
*
|
|
1789
|
+
* @param event - The event name to emit.
|
|
1790
|
+
* @param args - The event arguments.
|
|
1791
|
+
* @returns Whether any listeners were called.
|
|
1792
|
+
*/
|
|
1793
|
+
#emitInternal(event, ...args) {
|
|
1794
|
+
return this.emit(event, ...args);
|
|
1795
|
+
}
|
|
985
1796
|
};
|
|
1797
|
+
/**
|
|
1798
|
+
* Connection subclass that adds optional ChaCha20-Poly1305 encryption.
|
|
1799
|
+
* Once encryption is enabled, subclasses use the {@link EncryptionState}
|
|
1800
|
+
* to encrypt outgoing data and decrypt incoming data with per-message nonce counters.
|
|
1801
|
+
*/
|
|
986
1802
|
var EncryptionAwareConnection = class extends Connection {
|
|
1803
|
+
/** Whether encryption has been enabled on this connection. */
|
|
987
1804
|
get isEncrypted() {
|
|
988
1805
|
return !!this._encryption;
|
|
989
1806
|
}
|
|
1807
|
+
/** The current encryption state, or undefined if encryption is not enabled. */
|
|
990
1808
|
_encryption;
|
|
1809
|
+
/**
|
|
1810
|
+
* Enables ChaCha20-Poly1305 encryption for this connection.
|
|
1811
|
+
* After calling this, all subsequent data must be encrypted/decrypted
|
|
1812
|
+
* using the provided keys.
|
|
1813
|
+
*
|
|
1814
|
+
* @param readKey - The 32-byte key for decrypting incoming data.
|
|
1815
|
+
* @param writeKey - The 32-byte key for encrypting outgoing data.
|
|
1816
|
+
*/
|
|
991
1817
|
enableEncryption(readKey, writeKey) {
|
|
992
1818
|
this._encryption = new EncryptionState(readKey, writeKey);
|
|
993
1819
|
}
|
|
994
1820
|
};
|
|
1821
|
+
/**
|
|
1822
|
+
* Holds the symmetric encryption keys and nonce counters for a single
|
|
1823
|
+
* encrypted connection. Each message increments the corresponding counter
|
|
1824
|
+
* to ensure unique nonces for ChaCha20-Poly1305.
|
|
1825
|
+
*/
|
|
995
1826
|
var EncryptionState = class {
|
|
1827
|
+
/** The 32-byte key used to decrypt incoming data. */
|
|
996
1828
|
readKey;
|
|
1829
|
+
/** Monotonically increasing counter used as part of the read nonce. */
|
|
997
1830
|
readCount;
|
|
1831
|
+
/** The 32-byte key used to encrypt outgoing data. */
|
|
998
1832
|
writeKey;
|
|
1833
|
+
/** Monotonically increasing counter used as part of the write nonce. */
|
|
999
1834
|
writeCount;
|
|
1835
|
+
/**
|
|
1836
|
+
* @param readKey - The 32-byte decryption key.
|
|
1837
|
+
* @param writeKey - The 32-byte encryption key.
|
|
1838
|
+
*/
|
|
1000
1839
|
constructor(readKey, writeKey) {
|
|
1001
1840
|
this.readCount = 0;
|
|
1002
1841
|
this.readKey = readKey;
|
|
@@ -1005,98 +1844,50 @@ var EncryptionState = class {
|
|
|
1005
1844
|
}
|
|
1006
1845
|
};
|
|
1007
1846
|
|
|
1008
|
-
//#endregion
|
|
1009
|
-
//#region src/reporter.ts
|
|
1010
|
-
var Logger = class {
|
|
1011
|
-
get id() {
|
|
1012
|
-
return this.#id;
|
|
1013
|
-
}
|
|
1014
|
-
get label() {
|
|
1015
|
-
return this.#label;
|
|
1016
|
-
}
|
|
1017
|
-
#id;
|
|
1018
|
-
#label;
|
|
1019
|
-
constructor(id) {
|
|
1020
|
-
this.#id = id;
|
|
1021
|
-
this.#label = `\u001b[36m[${id}]\u001b[39m`;
|
|
1022
|
-
}
|
|
1023
|
-
debug(...data) {
|
|
1024
|
-
debug(this.#label, ...data);
|
|
1025
|
-
}
|
|
1026
|
-
error(...data) {
|
|
1027
|
-
error(this.#label, ...data);
|
|
1028
|
-
}
|
|
1029
|
-
info(...data) {
|
|
1030
|
-
info(this.#label, ...data);
|
|
1031
|
-
}
|
|
1032
|
-
net(...data) {
|
|
1033
|
-
net(this.#label, ...data);
|
|
1034
|
-
}
|
|
1035
|
-
raw(...data) {
|
|
1036
|
-
raw(this.#label, ...data);
|
|
1037
|
-
}
|
|
1038
|
-
warn(...data) {
|
|
1039
|
-
warn(this.#label, ...data);
|
|
1040
|
-
}
|
|
1041
|
-
};
|
|
1042
|
-
var Reporter = class {
|
|
1043
|
-
#enabled = [];
|
|
1044
|
-
all() {
|
|
1045
|
-
this.#enabled = [
|
|
1046
|
-
"debug",
|
|
1047
|
-
"error",
|
|
1048
|
-
"info",
|
|
1049
|
-
"net",
|
|
1050
|
-
"raw",
|
|
1051
|
-
"warn"
|
|
1052
|
-
];
|
|
1053
|
-
}
|
|
1054
|
-
none() {
|
|
1055
|
-
this.#enabled = [];
|
|
1056
|
-
}
|
|
1057
|
-
disable(group) {
|
|
1058
|
-
if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
|
|
1059
|
-
}
|
|
1060
|
-
enable(group) {
|
|
1061
|
-
if (!this.#enabled.includes(group)) this.#enabled.push(group);
|
|
1062
|
-
}
|
|
1063
|
-
isEnabled(group) {
|
|
1064
|
-
return this.#enabled.includes(group);
|
|
1065
|
-
}
|
|
1066
|
-
};
|
|
1067
|
-
function debug(...data) {
|
|
1068
|
-
reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
|
|
1069
|
-
}
|
|
1070
|
-
function error(...data) {
|
|
1071
|
-
reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
|
|
1072
|
-
}
|
|
1073
|
-
function info(...data) {
|
|
1074
|
-
reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
|
|
1075
|
-
}
|
|
1076
|
-
function net(...data) {
|
|
1077
|
-
reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
|
|
1078
|
-
}
|
|
1079
|
-
function raw(...data) {
|
|
1080
|
-
reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
|
|
1081
|
-
}
|
|
1082
|
-
function warn(...data) {
|
|
1083
|
-
reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
|
|
1084
|
-
}
|
|
1085
|
-
const reporter = new Reporter();
|
|
1086
|
-
|
|
1087
1847
|
//#endregion
|
|
1088
1848
|
//#region src/context.ts
|
|
1849
|
+
/** Default identity mimicking an iPhone running the Apple TV Remote app. */
|
|
1850
|
+
const DEFAULT_IDENTITY = {
|
|
1851
|
+
name: "apple-protocols",
|
|
1852
|
+
model: "iPhone17,3",
|
|
1853
|
+
osName: "iPhone OS",
|
|
1854
|
+
osVersion: "26.3",
|
|
1855
|
+
osBuildVersion: "25D63",
|
|
1856
|
+
sourceVersion: "935.7.1",
|
|
1857
|
+
applicationBundleIdentifier: "com.apple.TVRemote",
|
|
1858
|
+
applicationBundleVersion: "700"
|
|
1859
|
+
};
|
|
1860
|
+
/**
|
|
1861
|
+
* Shared context for a device connection, carrying the device identifier,
|
|
1862
|
+
* the client identity presented to the accessory, and a scoped logger.
|
|
1863
|
+
* Passed through the entire protocol stack for consistent identification and logging.
|
|
1864
|
+
*/
|
|
1089
1865
|
var Context = class {
|
|
1866
|
+
/** Unique identifier of the target device (typically its mDNS hostname). */
|
|
1090
1867
|
get deviceId() {
|
|
1091
1868
|
return this.#deviceId;
|
|
1092
1869
|
}
|
|
1870
|
+
/** The identity this client presents to the Apple device. */
|
|
1871
|
+
get identity() {
|
|
1872
|
+
return this.#identity;
|
|
1873
|
+
}
|
|
1874
|
+
/** Scoped logger instance tagged with the device identifier. */
|
|
1093
1875
|
get logger() {
|
|
1094
1876
|
return this.#logger;
|
|
1095
1877
|
}
|
|
1096
1878
|
#deviceId;
|
|
1879
|
+
#identity;
|
|
1097
1880
|
#logger;
|
|
1098
|
-
|
|
1881
|
+
/**
|
|
1882
|
+
* @param deviceId - Unique identifier of the target device.
|
|
1883
|
+
* @param identity - Optional partial override of the default device identity.
|
|
1884
|
+
*/
|
|
1885
|
+
constructor(deviceId, identity) {
|
|
1099
1886
|
this.#deviceId = deviceId;
|
|
1887
|
+
this.#identity = {
|
|
1888
|
+
...DEFAULT_IDENTITY,
|
|
1889
|
+
...identity
|
|
1890
|
+
};
|
|
1100
1891
|
this.#logger = new Logger(deviceId);
|
|
1101
1892
|
}
|
|
1102
1893
|
};
|
|
@@ -3125,14 +3916,30 @@ var require_srp = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
3125
3916
|
//#endregion
|
|
3126
3917
|
//#region src/pairing.ts
|
|
3127
3918
|
var import_srp = require_srp();
|
|
3919
|
+
/**
|
|
3920
|
+
* Abstract base class for HAP pairing operations. Provides shared TLV8
|
|
3921
|
+
* decoding with automatic error checking.
|
|
3922
|
+
*/
|
|
3128
3923
|
var BasePairing = class {
|
|
3924
|
+
/** The shared context carrying device identity and logger. */
|
|
3129
3925
|
get context() {
|
|
3130
3926
|
return this.#context;
|
|
3131
3927
|
}
|
|
3132
3928
|
#context;
|
|
3929
|
+
/**
|
|
3930
|
+
* @param context - Shared context for logging and device identity.
|
|
3931
|
+
*/
|
|
3133
3932
|
constructor(context) {
|
|
3134
3933
|
this.#context = context;
|
|
3135
3934
|
}
|
|
3935
|
+
/**
|
|
3936
|
+
* Decodes a TLV8 buffer and checks for error codes. If the TLV contains
|
|
3937
|
+
* an error value, it throws via TLV8.bail.
|
|
3938
|
+
*
|
|
3939
|
+
* @param buffer - The raw TLV8-encoded response.
|
|
3940
|
+
* @returns A map of TLV type numbers to their buffer values.
|
|
3941
|
+
* @throws If the TLV contains an error value.
|
|
3942
|
+
*/
|
|
3136
3943
|
tlv(buffer) {
|
|
3137
3944
|
const data = TLV8.decode(buffer);
|
|
3138
3945
|
if (data.has(TLV8.Value.Error)) TLV8.bail(data);
|
|
@@ -3152,23 +3959,43 @@ var BasePairing = class {
|
|
|
3152
3959
|
* For transient pairing (HomePod), only M1-M4 are needed — no long-term credentials are stored.
|
|
3153
3960
|
*/
|
|
3154
3961
|
var AccessoryPair = class extends BasePairing {
|
|
3962
|
+
/** Human-readable name sent to the accessory during pairing. */
|
|
3155
3963
|
#name;
|
|
3964
|
+
/** Unique pairing identifier (UUID) for this controller. */
|
|
3156
3965
|
#pairingId;
|
|
3966
|
+
/** Protocol-specific callback for sending TLV8-encoded pair-setup requests. */
|
|
3157
3967
|
#requestHandler;
|
|
3968
|
+
/** Ed25519 public key for long-term identity. */
|
|
3158
3969
|
#publicKey;
|
|
3970
|
+
/** Ed25519 secret key for signing identity proofs. */
|
|
3159
3971
|
#secretKey;
|
|
3972
|
+
/** SRP client instance used for PIN-based authentication. */
|
|
3160
3973
|
#srp;
|
|
3974
|
+
/**
|
|
3975
|
+
* @param context - Shared context for logging and device identity.
|
|
3976
|
+
* @param requestHandler - Callback that sends TLV8-encoded data and returns the response.
|
|
3977
|
+
*/
|
|
3161
3978
|
constructor(context, requestHandler) {
|
|
3162
3979
|
super(context);
|
|
3163
3980
|
this.#name = "basmilius/apple-protocols";
|
|
3164
3981
|
this.#pairingId = Buffer.from(v4().toUpperCase());
|
|
3165
3982
|
this.#requestHandler = requestHandler;
|
|
3166
3983
|
}
|
|
3984
|
+
/** Generates a new Ed25519 key pair for long-term identity. Must be called before pin() or transient(). */
|
|
3167
3985
|
async start() {
|
|
3168
3986
|
const keyPair = Ed25519.generateKeyPair();
|
|
3169
3987
|
this.#publicKey = Buffer.from(keyPair.publicKey);
|
|
3170
3988
|
this.#secretKey = Buffer.from(keyPair.secretKey);
|
|
3171
3989
|
}
|
|
3990
|
+
/**
|
|
3991
|
+
* Performs the full PIN-based pair-setup flow (M1 through M6).
|
|
3992
|
+
* Prompts for a PIN via the provided callback, then completes SRP authentication
|
|
3993
|
+
* and Ed25519 credential exchange with the accessory.
|
|
3994
|
+
*
|
|
3995
|
+
* @param askPin - Async callback that returns the user-entered PIN.
|
|
3996
|
+
* @returns Long-term credentials for future pair-verify sessions.
|
|
3997
|
+
* @throws {PairingError} If any step fails.
|
|
3998
|
+
*/
|
|
3172
3999
|
async pin(askPin) {
|
|
3173
4000
|
const m1 = await this.m1();
|
|
3174
4001
|
const m2 = await this.m2(m1, await askPin());
|
|
@@ -3176,9 +4003,18 @@ var AccessoryPair = class extends BasePairing {
|
|
|
3176
4003
|
const m4 = await this.m4(m3);
|
|
3177
4004
|
const m5 = await this.m5(m4);
|
|
3178
4005
|
const m6 = await this.m6(m4, m5);
|
|
3179
|
-
if (!m6) throw new
|
|
4006
|
+
if (!m6) throw new PairingError("Pairing failed, could not get accessory keys.");
|
|
3180
4007
|
return m6;
|
|
3181
4008
|
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Performs the transient (non-persistent) pair-setup flow (M1 through M4 only).
|
|
4011
|
+
* Uses the default transient PIN ('3939'). No long-term credentials are stored;
|
|
4012
|
+
* session keys are derived directly from the SRP shared secret.
|
|
4013
|
+
*
|
|
4014
|
+
* Primarily used for HomePod connections where persistent pairing is not required.
|
|
4015
|
+
*
|
|
4016
|
+
* @returns Session keys for the current connection (not reusable across sessions).
|
|
4017
|
+
*/
|
|
3182
4018
|
async transient() {
|
|
3183
4019
|
const m1 = await this.m1([[TLV8.Value.Flags, TLV8.Flags.TransientPairing]]);
|
|
3184
4020
|
const m2 = await this.m2(m1);
|
|
@@ -3337,7 +4173,7 @@ var AccessoryPair = class extends BasePairing {
|
|
|
3337
4173
|
accessoryIdentifier,
|
|
3338
4174
|
accessoryLongTermPublicKey
|
|
3339
4175
|
]);
|
|
3340
|
-
if (!Ed25519.verify(accessoryInfo, accessorySignature, accessoryLongTermPublicKey)) throw new
|
|
4176
|
+
if (!Ed25519.verify(accessoryInfo, accessorySignature, accessoryLongTermPublicKey)) throw new AuthenticationError("Invalid accessory signature.");
|
|
3341
4177
|
return {
|
|
3342
4178
|
accessoryIdentifier: accessoryIdentifier.toString(),
|
|
3343
4179
|
accessoryLongTermPublicKey,
|
|
@@ -3357,13 +4193,28 @@ var AccessoryPair = class extends BasePairing {
|
|
|
3357
4193
|
* → M3 (controller proof) → M4 (session key derivation)
|
|
3358
4194
|
*/
|
|
3359
4195
|
var AccessoryVerify = class extends BasePairing {
|
|
4196
|
+
/** Ephemeral Curve25519 key pair for forward-secrecy ECDH exchange. */
|
|
3360
4197
|
#ephemeralKeyPair;
|
|
4198
|
+
/** Protocol-specific callback for sending TLV8-encoded pair-verify requests. */
|
|
3361
4199
|
#requestHandler;
|
|
4200
|
+
/**
|
|
4201
|
+
* @param context - Shared context for logging and device identity.
|
|
4202
|
+
* @param requestHandler - Callback that sends TLV8-encoded data and returns the response.
|
|
4203
|
+
*/
|
|
3362
4204
|
constructor(context, requestHandler) {
|
|
3363
4205
|
super(context);
|
|
3364
4206
|
this.#ephemeralKeyPair = Curve25519.generateKeyPair();
|
|
3365
4207
|
this.#requestHandler = requestHandler;
|
|
3366
4208
|
}
|
|
4209
|
+
/**
|
|
4210
|
+
* Performs the full pair-verify flow (M1 through M4) using stored credentials
|
|
4211
|
+
* from a previous pair-setup. Establishes a new session with forward secrecy
|
|
4212
|
+
* via ephemeral Curve25519 key exchange.
|
|
4213
|
+
*
|
|
4214
|
+
* @param credentials - Long-term credentials from a previous pair-setup.
|
|
4215
|
+
* @returns Session keys derived from the ECDH shared secret.
|
|
4216
|
+
* @throws {AuthenticationError} If the accessory's identity cannot be verified.
|
|
4217
|
+
*/
|
|
3367
4218
|
async start(credentials) {
|
|
3368
4219
|
const m1 = await this.#m1();
|
|
3369
4220
|
const m2 = await this.#m2(credentials.accessoryIdentifier, credentials.accessoryLongTermPublicKey, m1);
|
|
@@ -3410,13 +4261,13 @@ var AccessoryVerify = class extends BasePairing {
|
|
|
3410
4261
|
const tlv = TLV8.decode(data);
|
|
3411
4262
|
const accessoryIdentifier = tlv.get(TLV8.Value.Identifier);
|
|
3412
4263
|
const accessorySignature = tlv.get(TLV8.Value.Signature);
|
|
3413
|
-
if (accessoryIdentifier.toString() !== localAccessoryIdentifier) throw new
|
|
4264
|
+
if (accessoryIdentifier.toString() !== localAccessoryIdentifier) throw new AuthenticationError(`Invalid accessory identifier. Expected ${accessoryIdentifier.toString()} to be ${localAccessoryIdentifier}.`);
|
|
3414
4265
|
const accessoryInfo = Buffer.concat([
|
|
3415
4266
|
m1.serverPublicKey,
|
|
3416
4267
|
accessoryIdentifier,
|
|
3417
4268
|
this.#ephemeralKeyPair.publicKey
|
|
3418
4269
|
]);
|
|
3419
|
-
if (!Ed25519.verify(accessoryInfo, accessorySignature, longTermPublicKey)) throw new
|
|
4270
|
+
if (!Ed25519.verify(accessoryInfo, accessorySignature, longTermPublicKey)) throw new AuthenticationError("Invalid accessory signature.");
|
|
3420
4271
|
return {
|
|
3421
4272
|
serverEphemeralPublicKey: m1.serverPublicKey,
|
|
3422
4273
|
sessionKey,
|
|
@@ -3461,11 +4312,173 @@ var AccessoryVerify = class extends BasePairing {
|
|
|
3461
4312
|
|
|
3462
4313
|
//#endregion
|
|
3463
4314
|
//#region src/symbols.ts
|
|
4315
|
+
/**
|
|
4316
|
+
* Unique symbol used as a key for accessing encryption state on connection instances.
|
|
4317
|
+
* Provides type-safe access to internal encryption fields without exposing them publicly.
|
|
4318
|
+
*/
|
|
3464
4319
|
const ENCRYPTION = Symbol();
|
|
3465
4320
|
|
|
4321
|
+
//#endregion
|
|
4322
|
+
//#region src/recovery.ts
|
|
4323
|
+
/**
|
|
4324
|
+
* Manages automatic connection recovery with exponential backoff.
|
|
4325
|
+
*
|
|
4326
|
+
* When an unexpected disconnect occurs, this class schedules retry attempts
|
|
4327
|
+
* with configurable delay (exponential backoff by default: base=1s, max=30s).
|
|
4328
|
+
* Emits 'recovering', 'recovered', and 'failed' events for monitoring.
|
|
4329
|
+
*
|
|
4330
|
+
* Optionally supports a periodic reconnect interval for proactive
|
|
4331
|
+
* connection health checks (failures are silent; unexpected disconnects
|
|
4332
|
+
* trigger the full recovery flow).
|
|
4333
|
+
*/
|
|
4334
|
+
var ConnectionRecovery = class extends EventEmitter {
|
|
4335
|
+
#options;
|
|
4336
|
+
#attempt = 0;
|
|
4337
|
+
#errors = [];
|
|
4338
|
+
#isRecovering = false;
|
|
4339
|
+
#isScheduledReconnecting = false;
|
|
4340
|
+
#retryTimeout;
|
|
4341
|
+
#reconnectInterval;
|
|
4342
|
+
#disposed = false;
|
|
4343
|
+
/**
|
|
4344
|
+
* @param options - Recovery configuration including the reconnect callback.
|
|
4345
|
+
*/
|
|
4346
|
+
constructor(options) {
|
|
4347
|
+
super();
|
|
4348
|
+
this.#options = {
|
|
4349
|
+
maxAttempts: 3,
|
|
4350
|
+
baseDelay: 1e3,
|
|
4351
|
+
maxDelay: 3e4,
|
|
4352
|
+
useExponentialBackoff: true,
|
|
4353
|
+
reconnectInterval: 0,
|
|
4354
|
+
...options
|
|
4355
|
+
};
|
|
4356
|
+
if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
|
|
4357
|
+
}
|
|
4358
|
+
/** Whether a recovery attempt is currently in progress. */
|
|
4359
|
+
get isRecovering() {
|
|
4360
|
+
return this.#isRecovering;
|
|
4361
|
+
}
|
|
4362
|
+
/** The current retry attempt number (0 when not recovering). */
|
|
4363
|
+
get attempt() {
|
|
4364
|
+
return this.#attempt;
|
|
4365
|
+
}
|
|
4366
|
+
/**
|
|
4367
|
+
* Called when a disconnect is detected. Only triggers recovery for
|
|
4368
|
+
* unexpected disconnects; intentional disconnects are ignored.
|
|
4369
|
+
*
|
|
4370
|
+
* @param unexpected - Whether the disconnect was unexpected (e.g. socket error).
|
|
4371
|
+
*/
|
|
4372
|
+
handleDisconnect(unexpected) {
|
|
4373
|
+
if (this.#disposed || !unexpected) return;
|
|
4374
|
+
this.#stopReconnectInterval();
|
|
4375
|
+
this.#recover();
|
|
4376
|
+
}
|
|
4377
|
+
/** Resets the recovery state and restarts the periodic reconnect interval if configured. */
|
|
4378
|
+
reset() {
|
|
4379
|
+
this.#attempt = 0;
|
|
4380
|
+
this.#errors = [];
|
|
4381
|
+
this.#isRecovering = false;
|
|
4382
|
+
if (this.#retryTimeout) {
|
|
4383
|
+
clearTimeout(this.#retryTimeout);
|
|
4384
|
+
this.#retryTimeout = void 0;
|
|
4385
|
+
}
|
|
4386
|
+
if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
|
|
4387
|
+
}
|
|
4388
|
+
/** Permanently disposes this recovery instance, cancelling all timers and removing listeners. */
|
|
4389
|
+
dispose() {
|
|
4390
|
+
this.#disposed = true;
|
|
4391
|
+
this.#isRecovering = false;
|
|
4392
|
+
this.#isScheduledReconnecting = false;
|
|
4393
|
+
if (this.#retryTimeout) {
|
|
4394
|
+
clearTimeout(this.#retryTimeout);
|
|
4395
|
+
this.#retryTimeout = void 0;
|
|
4396
|
+
}
|
|
4397
|
+
this.#stopReconnectInterval();
|
|
4398
|
+
this.removeAllListeners();
|
|
4399
|
+
}
|
|
4400
|
+
/**
|
|
4401
|
+
* Initiates a recovery attempt. If max attempts are reached, emits 'failed'
|
|
4402
|
+
* with all collected errors. Otherwise, schedules a delayed reconnect.
|
|
4403
|
+
*/
|
|
4404
|
+
#recover() {
|
|
4405
|
+
if (this.#isRecovering || this.#disposed) return;
|
|
4406
|
+
if (this.#attempt >= this.#options.maxAttempts) {
|
|
4407
|
+
this.emit("failed", this.#errors);
|
|
4408
|
+
return;
|
|
4409
|
+
}
|
|
4410
|
+
this.#isRecovering = true;
|
|
4411
|
+
this.#attempt++;
|
|
4412
|
+
this.emit("recovering", this.#attempt);
|
|
4413
|
+
const delay = this.#calculateDelay();
|
|
4414
|
+
this.#retryTimeout = setTimeout(async () => {
|
|
4415
|
+
this.#retryTimeout = void 0;
|
|
4416
|
+
try {
|
|
4417
|
+
await this.#options.onReconnect();
|
|
4418
|
+
this.#isRecovering = false;
|
|
4419
|
+
this.#attempt = 0;
|
|
4420
|
+
this.#errors = [];
|
|
4421
|
+
this.emit("recovered");
|
|
4422
|
+
if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
|
|
4423
|
+
} catch (err) {
|
|
4424
|
+
this.#isRecovering = false;
|
|
4425
|
+
this.#errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
4426
|
+
this.#recover();
|
|
4427
|
+
}
|
|
4428
|
+
}, delay);
|
|
4429
|
+
}
|
|
4430
|
+
/**
|
|
4431
|
+
* Calculates the delay before the next retry attempt.
|
|
4432
|
+
* Uses exponential backoff (base * 2^(attempt-1)) capped at maxDelay,
|
|
4433
|
+
* or a flat baseDelay if exponential backoff is disabled.
|
|
4434
|
+
*
|
|
4435
|
+
* @returns The delay in milliseconds.
|
|
4436
|
+
*/
|
|
4437
|
+
#calculateDelay() {
|
|
4438
|
+
if (!this.#options.useExponentialBackoff) return this.#options.baseDelay;
|
|
4439
|
+
const delay = this.#options.baseDelay * Math.pow(2, this.#attempt - 1);
|
|
4440
|
+
return Math.min(delay, this.#options.maxDelay);
|
|
4441
|
+
}
|
|
4442
|
+
/**
|
|
4443
|
+
* Starts the periodic reconnect interval. Silently calls onReconnect at the
|
|
4444
|
+
* configured interval; failures do not trigger recovery (that happens via handleDisconnect).
|
|
4445
|
+
*/
|
|
4446
|
+
#startReconnectInterval() {
|
|
4447
|
+
this.#stopReconnectInterval();
|
|
4448
|
+
this.#reconnectInterval = setInterval(async () => {
|
|
4449
|
+
if (this.#isRecovering || this.#disposed || this.#isScheduledReconnecting) return;
|
|
4450
|
+
this.#isScheduledReconnecting = true;
|
|
4451
|
+
try {
|
|
4452
|
+
await this.#options.onReconnect();
|
|
4453
|
+
} catch (_) {} finally {
|
|
4454
|
+
this.#isScheduledReconnecting = false;
|
|
4455
|
+
}
|
|
4456
|
+
}, this.#options.reconnectInterval);
|
|
4457
|
+
}
|
|
4458
|
+
/** Stops the periodic reconnect interval timer. */
|
|
4459
|
+
#stopReconnectInterval() {
|
|
4460
|
+
if (this.#reconnectInterval) {
|
|
4461
|
+
clearInterval(this.#reconnectInterval);
|
|
4462
|
+
this.#reconnectInterval = void 0;
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
};
|
|
4466
|
+
|
|
3466
4467
|
//#endregion
|
|
3467
4468
|
//#region src/timing.ts
|
|
4469
|
+
/**
|
|
4470
|
+
* NTP timing server for AirPlay audio synchronization.
|
|
4471
|
+
*
|
|
4472
|
+
* Apple devices send NTP timing requests to synchronize their clocks with the
|
|
4473
|
+
* controller during audio streaming. This server responds with the current
|
|
4474
|
+
* wall-clock time using NTP packet format, enabling the device to calculate
|
|
4475
|
+
* the correct playback offset for synchronized multi-room audio.
|
|
4476
|
+
*
|
|
4477
|
+
* Must use wall-clock time (Date.now), not monotonic time (process.hrtime),
|
|
4478
|
+
* because NTP timestamps are anchored to the Unix epoch.
|
|
4479
|
+
*/
|
|
3468
4480
|
var TimingServer = class {
|
|
4481
|
+
/** The UDP port the timing server is listening on, or 0 if not yet bound. */
|
|
3469
4482
|
get port() {
|
|
3470
4483
|
return this.#port;
|
|
3471
4484
|
}
|
|
@@ -3475,37 +4488,71 @@ var TimingServer = class {
|
|
|
3475
4488
|
constructor() {
|
|
3476
4489
|
this.#logger = new Logger("timing-server");
|
|
3477
4490
|
this.#socket = createSocket("udp4");
|
|
3478
|
-
this
|
|
3479
|
-
this
|
|
3480
|
-
this
|
|
3481
|
-
|
|
4491
|
+
this.onConnect = this.onConnect.bind(this);
|
|
4492
|
+
this.onError = this.onError.bind(this);
|
|
4493
|
+
this.onMessage = this.onMessage.bind(this);
|
|
4494
|
+
this.#socket.on("connect", this.onConnect);
|
|
4495
|
+
this.#socket.on("error", this.onError);
|
|
4496
|
+
this.#socket.on("message", this.onMessage);
|
|
4497
|
+
}
|
|
4498
|
+
/** Closes the UDP socket and resets the port. */
|
|
3482
4499
|
close() {
|
|
3483
4500
|
this.#socket.close();
|
|
3484
4501
|
this.#port = 0;
|
|
3485
4502
|
}
|
|
4503
|
+
/**
|
|
4504
|
+
* Binds the UDP socket to a random available port and starts listening
|
|
4505
|
+
* for NTP timing requests.
|
|
4506
|
+
*
|
|
4507
|
+
* @throws If the socket fails to bind.
|
|
4508
|
+
*/
|
|
3486
4509
|
listen() {
|
|
3487
4510
|
return new Promise((resolve, reject) => {
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
4511
|
+
const onError = (err) => {
|
|
4512
|
+
this.#socket.removeListener("listening", onListening);
|
|
4513
|
+
reject(err);
|
|
4514
|
+
};
|
|
4515
|
+
const onListening = () => {
|
|
4516
|
+
this.#socket.removeListener("error", onError);
|
|
3491
4517
|
this.#onListening();
|
|
3492
4518
|
resolve();
|
|
3493
|
-
}
|
|
3494
|
-
this.#socket.
|
|
4519
|
+
};
|
|
4520
|
+
this.#socket.once("error", onError);
|
|
4521
|
+
this.#socket.once("listening", onListening);
|
|
4522
|
+
this.#socket.bind(0);
|
|
3495
4523
|
});
|
|
3496
4524
|
}
|
|
3497
|
-
|
|
4525
|
+
/**
|
|
4526
|
+
* Handles the socket 'connect' event by configuring buffer sizes.
|
|
4527
|
+
*/
|
|
4528
|
+
onConnect() {
|
|
3498
4529
|
this.#socket.setRecvBufferSize(16384);
|
|
3499
4530
|
this.#socket.setSendBufferSize(16384);
|
|
3500
4531
|
}
|
|
3501
|
-
|
|
4532
|
+
/**
|
|
4533
|
+
* Handles socket errors by logging them.
|
|
4534
|
+
*
|
|
4535
|
+
* @param err - The error that occurred.
|
|
4536
|
+
*/
|
|
4537
|
+
onError(err) {
|
|
3502
4538
|
this.#logger.error("Timing server error", err);
|
|
3503
4539
|
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Records the bound port after the socket starts listening.
|
|
4542
|
+
*/
|
|
3504
4543
|
#onListening() {
|
|
3505
4544
|
const { port } = this.#socket.address();
|
|
3506
4545
|
this.#port = port;
|
|
3507
4546
|
}
|
|
3508
|
-
|
|
4547
|
+
/**
|
|
4548
|
+
* Handles incoming NTP timing requests from Apple devices.
|
|
4549
|
+
* Decodes the request, captures the current NTP timestamp, and sends back
|
|
4550
|
+
* a response with reference, receive, and send timestamps populated.
|
|
4551
|
+
*
|
|
4552
|
+
* @param data - The raw UDP packet data.
|
|
4553
|
+
* @param info - Remote address information of the sender.
|
|
4554
|
+
*/
|
|
4555
|
+
onMessage(data, info) {
|
|
3509
4556
|
try {
|
|
3510
4557
|
const request = NTP.decode(data);
|
|
3511
4558
|
const ntp = NTP.now();
|
|
@@ -3535,15 +4582,36 @@ var TimingServer = class {
|
|
|
3535
4582
|
|
|
3536
4583
|
//#endregion
|
|
3537
4584
|
//#region src/utils.ts
|
|
4585
|
+
/**
|
|
4586
|
+
* Generates a random Active-Remote identifier for DACP (Digital Audio Control Protocol).
|
|
4587
|
+
* Used in RTSP headers to identify the remote controller.
|
|
4588
|
+
*
|
|
4589
|
+
* @returns A random 32-bit unsigned integer as a decimal string.
|
|
4590
|
+
*/
|
|
3538
4591
|
function generateActiveRemoteId() {
|
|
3539
4592
|
return Math.floor(Math.random() * 2 ** 32).toString(10);
|
|
3540
4593
|
}
|
|
4594
|
+
/**
|
|
4595
|
+
* Generates a random DACP-ID for identifying this controller in DACP sessions.
|
|
4596
|
+
*
|
|
4597
|
+
* @returns A random 64-bit integer as an uppercase hexadecimal string.
|
|
4598
|
+
*/
|
|
3541
4599
|
function generateDacpId() {
|
|
3542
4600
|
return Math.floor(Math.random() * 2 ** 64).toString(16).toUpperCase();
|
|
3543
4601
|
}
|
|
4602
|
+
/**
|
|
4603
|
+
* Generates a random session identifier for RTSP and AirPlay sessions.
|
|
4604
|
+
*
|
|
4605
|
+
* @returns A random 32-bit unsigned integer as a decimal string.
|
|
4606
|
+
*/
|
|
3544
4607
|
function generateSessionId() {
|
|
3545
4608
|
return Math.floor(Math.random() * 2 ** 32).toString(10);
|
|
3546
4609
|
}
|
|
4610
|
+
/**
|
|
4611
|
+
* Finds the first non-internal IPv4 address on this machine.
|
|
4612
|
+
*
|
|
4613
|
+
* @returns The local IPv4 address, or null if none is found.
|
|
4614
|
+
*/
|
|
3547
4615
|
function getLocalIP() {
|
|
3548
4616
|
const interfaces = networkInterfaces();
|
|
3549
4617
|
for (const iface of Object.values(interfaces)) {
|
|
@@ -3555,6 +4623,11 @@ function getLocalIP() {
|
|
|
3555
4623
|
}
|
|
3556
4624
|
return null;
|
|
3557
4625
|
}
|
|
4626
|
+
/**
|
|
4627
|
+
* Finds the first non-internal MAC address on this machine.
|
|
4628
|
+
*
|
|
4629
|
+
* @returns The MAC address in uppercase colon-separated format, or '00:00:00:00:00:00' if none is found.
|
|
4630
|
+
*/
|
|
3558
4631
|
function getMacAddress() {
|
|
3559
4632
|
const interfaces = networkInterfaces();
|
|
3560
4633
|
for (const iface of Object.values(interfaces)) {
|
|
@@ -3566,17 +4639,41 @@ function getMacAddress() {
|
|
|
3566
4639
|
}
|
|
3567
4640
|
return "00:00:00:00:00:00";
|
|
3568
4641
|
}
|
|
4642
|
+
/**
|
|
4643
|
+
* Generates a cryptographically random 32-bit unsigned integer.
|
|
4644
|
+
*
|
|
4645
|
+
* @returns A random unsigned 32-bit integer.
|
|
4646
|
+
*/
|
|
3569
4647
|
function randomInt32() {
|
|
3570
4648
|
return randomBytes(4).readUInt32BE(0);
|
|
3571
4649
|
}
|
|
4650
|
+
/**
|
|
4651
|
+
* Generates a cryptographically random 64-bit unsigned integer.
|
|
4652
|
+
*
|
|
4653
|
+
* @returns A random unsigned 64-bit bigint.
|
|
4654
|
+
*/
|
|
3572
4655
|
function randomInt64() {
|
|
3573
4656
|
return randomBytes(8).readBigUint64LE(0);
|
|
3574
4657
|
}
|
|
4658
|
+
/**
|
|
4659
|
+
* Encodes a 16-bit unsigned integer into a big-endian buffer.
|
|
4660
|
+
*
|
|
4661
|
+
* @param value - The 16-bit unsigned integer to encode.
|
|
4662
|
+
* @returns A 2-byte buffer containing the value in big-endian byte order.
|
|
4663
|
+
*/
|
|
3575
4664
|
function uint16ToBE(value) {
|
|
3576
4665
|
const buffer = Buffer.allocUnsafe(2);
|
|
3577
4666
|
buffer.writeUInt16BE(value, 0);
|
|
3578
4667
|
return buffer;
|
|
3579
4668
|
}
|
|
4669
|
+
/**
|
|
4670
|
+
* Encodes a 53-bit unsigned integer into an 8-byte little-endian buffer.
|
|
4671
|
+
* Useful for encoding JavaScript-safe integers into 64-bit wire formats.
|
|
4672
|
+
*
|
|
4673
|
+
* @param value - The unsigned integer to encode (must be in range [0, 2^53-1]).
|
|
4674
|
+
* @returns An 8-byte buffer containing the value in little-endian byte order.
|
|
4675
|
+
* @throws If the value is out of range or not an integer.
|
|
4676
|
+
*/
|
|
3580
4677
|
function uint53ToLE(value) {
|
|
3581
4678
|
const [upper, lower] = splitUInt53(value);
|
|
3582
4679
|
const buffer = Buffer.allocUnsafe(8);
|
|
@@ -3584,6 +4681,13 @@ function uint53ToLE(value) {
|
|
|
3584
4681
|
buffer.writeUInt32LE(upper, 4);
|
|
3585
4682
|
return buffer;
|
|
3586
4683
|
}
|
|
4684
|
+
/**
|
|
4685
|
+
* Splits a 53-bit unsigned integer into upper and lower 32-bit halves.
|
|
4686
|
+
*
|
|
4687
|
+
* @param number - The integer to split (must be in range [0, 2^53-1]).
|
|
4688
|
+
* @returns A tuple of [upper32, lower32].
|
|
4689
|
+
* @throws If the number is out of range or not an integer.
|
|
4690
|
+
*/
|
|
3587
4691
|
function splitUInt53(number) {
|
|
3588
4692
|
const MAX_UINT32 = 4294967295;
|
|
3589
4693
|
if (number <= -1 || number > 9007199254740991) throw new Error("Number out of range.");
|
|
@@ -3596,4 +4700,131 @@ function splitUInt53(number) {
|
|
|
3596
4700
|
}
|
|
3597
4701
|
|
|
3598
4702
|
//#endregion
|
|
3599
|
-
|
|
4703
|
+
//#region src/deviceModel.ts
|
|
4704
|
+
/** Known Apple device models that can be discovered and controlled via AirPlay/Companion Link. */
|
|
4705
|
+
let DeviceModel = /* @__PURE__ */ function(DeviceModel) {
|
|
4706
|
+
DeviceModel[DeviceModel["Unknown"] = 0] = "Unknown";
|
|
4707
|
+
DeviceModel[DeviceModel["AppleTVGen2"] = 1] = "AppleTVGen2";
|
|
4708
|
+
DeviceModel[DeviceModel["AppleTVGen3"] = 2] = "AppleTVGen3";
|
|
4709
|
+
DeviceModel[DeviceModel["AppleTVHD"] = 3] = "AppleTVHD";
|
|
4710
|
+
DeviceModel[DeviceModel["AppleTV4K"] = 4] = "AppleTV4K";
|
|
4711
|
+
DeviceModel[DeviceModel["AppleTV4KGen2"] = 5] = "AppleTV4KGen2";
|
|
4712
|
+
DeviceModel[DeviceModel["AppleTV4KGen3"] = 6] = "AppleTV4KGen3";
|
|
4713
|
+
DeviceModel[DeviceModel["HomePod"] = 10] = "HomePod";
|
|
4714
|
+
DeviceModel[DeviceModel["HomePodMini"] = 11] = "HomePodMini";
|
|
4715
|
+
DeviceModel[DeviceModel["HomePodGen2"] = 12] = "HomePodGen2";
|
|
4716
|
+
DeviceModel[DeviceModel["AirPortExpress"] = 20] = "AirPortExpress";
|
|
4717
|
+
DeviceModel[DeviceModel["AirPortExpressGen2"] = 21] = "AirPortExpressGen2";
|
|
4718
|
+
return DeviceModel;
|
|
4719
|
+
}({});
|
|
4720
|
+
/** High-level device categories derived from the specific device model. */
|
|
4721
|
+
let DeviceType = /* @__PURE__ */ function(DeviceType) {
|
|
4722
|
+
DeviceType[DeviceType["Unknown"] = 0] = "Unknown";
|
|
4723
|
+
DeviceType[DeviceType["AppleTV"] = 1] = "AppleTV";
|
|
4724
|
+
DeviceType[DeviceType["HomePod"] = 2] = "HomePod";
|
|
4725
|
+
DeviceType[DeviceType["AirPort"] = 3] = "AirPort";
|
|
4726
|
+
return DeviceType;
|
|
4727
|
+
}({});
|
|
4728
|
+
/** Maps Apple model identifiers (e.g. "AppleTV6,2") to known device models. */
|
|
4729
|
+
const MODEL_IDENTIFIERS = {
|
|
4730
|
+
"AppleTV2,1": DeviceModel.AppleTVGen2,
|
|
4731
|
+
"AppleTV3,1": DeviceModel.AppleTVGen3,
|
|
4732
|
+
"AppleTV3,2": DeviceModel.AppleTVGen3,
|
|
4733
|
+
"AppleTV5,3": DeviceModel.AppleTVHD,
|
|
4734
|
+
"AppleTV6,2": DeviceModel.AppleTV4K,
|
|
4735
|
+
"AppleTV11,1": DeviceModel.AppleTV4KGen2,
|
|
4736
|
+
"AppleTV14,1": DeviceModel.AppleTV4KGen3,
|
|
4737
|
+
"AudioAccessory1,1": DeviceModel.HomePod,
|
|
4738
|
+
"AudioAccessory1,2": DeviceModel.HomePod,
|
|
4739
|
+
"AudioAccessory5,1": DeviceModel.HomePodMini,
|
|
4740
|
+
"AudioAccessorySingle5,1": DeviceModel.HomePodMini,
|
|
4741
|
+
"AudioAccessory6,1": DeviceModel.HomePodGen2,
|
|
4742
|
+
"AirPort4,107": DeviceModel.AirPortExpress,
|
|
4743
|
+
"AirPort10,115": DeviceModel.AirPortExpressGen2
|
|
4744
|
+
};
|
|
4745
|
+
/** Maps Apple internal code names (e.g. "J305AP") to known device models. */
|
|
4746
|
+
const INTERNAL_NAMES = {
|
|
4747
|
+
"K66AP": DeviceModel.AppleTVGen2,
|
|
4748
|
+
"J33AP": DeviceModel.AppleTVGen3,
|
|
4749
|
+
"J33IAP": DeviceModel.AppleTVGen3,
|
|
4750
|
+
"J42dAP": DeviceModel.AppleTVHD,
|
|
4751
|
+
"J105aAP": DeviceModel.AppleTV4K,
|
|
4752
|
+
"J305AP": DeviceModel.AppleTV4KGen2,
|
|
4753
|
+
"J255AP": DeviceModel.AppleTV4KGen3,
|
|
4754
|
+
"B520AP": DeviceModel.HomePodMini
|
|
4755
|
+
};
|
|
4756
|
+
/** Human-readable display names for each device model. */
|
|
4757
|
+
const MODEL_NAMES = {
|
|
4758
|
+
[DeviceModel.Unknown]: "Unknown",
|
|
4759
|
+
[DeviceModel.AppleTVGen2]: "Apple TV (2nd generation)",
|
|
4760
|
+
[DeviceModel.AppleTVGen3]: "Apple TV (3rd generation)",
|
|
4761
|
+
[DeviceModel.AppleTVHD]: "Apple TV HD",
|
|
4762
|
+
[DeviceModel.AppleTV4K]: "Apple TV 4K (1st generation)",
|
|
4763
|
+
[DeviceModel.AppleTV4KGen2]: "Apple TV 4K (2nd generation)",
|
|
4764
|
+
[DeviceModel.AppleTV4KGen3]: "Apple TV 4K (3rd generation)",
|
|
4765
|
+
[DeviceModel.HomePod]: "HomePod",
|
|
4766
|
+
[DeviceModel.HomePodMini]: "HomePod mini",
|
|
4767
|
+
[DeviceModel.HomePodGen2]: "HomePod (2nd generation)",
|
|
4768
|
+
[DeviceModel.AirPortExpress]: "AirPort Express",
|
|
4769
|
+
[DeviceModel.AirPortExpressGen2]: "AirPort Express (2nd generation)"
|
|
4770
|
+
};
|
|
4771
|
+
/** Maps each device model to its high-level device type category. */
|
|
4772
|
+
const MODEL_TYPES = {
|
|
4773
|
+
[DeviceModel.Unknown]: DeviceType.Unknown,
|
|
4774
|
+
[DeviceModel.AppleTVGen2]: DeviceType.AppleTV,
|
|
4775
|
+
[DeviceModel.AppleTVGen3]: DeviceType.AppleTV,
|
|
4776
|
+
[DeviceModel.AppleTVHD]: DeviceType.AppleTV,
|
|
4777
|
+
[DeviceModel.AppleTV4K]: DeviceType.AppleTV,
|
|
4778
|
+
[DeviceModel.AppleTV4KGen2]: DeviceType.AppleTV,
|
|
4779
|
+
[DeviceModel.AppleTV4KGen3]: DeviceType.AppleTV,
|
|
4780
|
+
[DeviceModel.HomePod]: DeviceType.HomePod,
|
|
4781
|
+
[DeviceModel.HomePodMini]: DeviceType.HomePod,
|
|
4782
|
+
[DeviceModel.HomePodGen2]: DeviceType.HomePod,
|
|
4783
|
+
[DeviceModel.AirPortExpress]: DeviceType.AirPort,
|
|
4784
|
+
[DeviceModel.AirPortExpressGen2]: DeviceType.AirPort
|
|
4785
|
+
};
|
|
4786
|
+
/**
|
|
4787
|
+
* Resolves an Apple model identifier or internal code name to a known device model.
|
|
4788
|
+
*
|
|
4789
|
+
* @param identifier - Model identifier (e.g. "AppleTV6,2") or internal name (e.g. "J305AP").
|
|
4790
|
+
* @returns The matching device model, or {@link DeviceModel.Unknown} if unrecognized.
|
|
4791
|
+
*/
|
|
4792
|
+
const lookupDeviceModel = (identifier) => MODEL_IDENTIFIERS[identifier] ?? INTERNAL_NAMES[identifier] ?? DeviceModel.Unknown;
|
|
4793
|
+
/**
|
|
4794
|
+
* Returns the human-readable display name for a device model.
|
|
4795
|
+
*
|
|
4796
|
+
* @param model - The device model to look up.
|
|
4797
|
+
* @returns A display name like "Apple TV 4K (2nd generation)", or "Unknown".
|
|
4798
|
+
*/
|
|
4799
|
+
const getDeviceModelName = (model) => MODEL_NAMES[model] ?? "Unknown";
|
|
4800
|
+
/**
|
|
4801
|
+
* Returns the high-level device type category for a device model.
|
|
4802
|
+
*
|
|
4803
|
+
* @param model - The device model to categorize.
|
|
4804
|
+
* @returns The device type (AppleTV, HomePod, AirPort, or Unknown).
|
|
4805
|
+
*/
|
|
4806
|
+
const getDeviceType = (model) => MODEL_TYPES[model] ?? DeviceType.Unknown;
|
|
4807
|
+
/**
|
|
4808
|
+
* Checks whether the given model is an Apple TV.
|
|
4809
|
+
*
|
|
4810
|
+
* @param model - The device model to check.
|
|
4811
|
+
* @returns True if the model is any Apple TV generation.
|
|
4812
|
+
*/
|
|
4813
|
+
const isAppleTV = (model) => getDeviceType(model) === DeviceType.AppleTV;
|
|
4814
|
+
/**
|
|
4815
|
+
* Checks whether the given model is a HomePod.
|
|
4816
|
+
*
|
|
4817
|
+
* @param model - The device model to check.
|
|
4818
|
+
* @returns True if the model is any HomePod variant.
|
|
4819
|
+
*/
|
|
4820
|
+
const isHomePod = (model) => getDeviceType(model) === DeviceType.HomePod;
|
|
4821
|
+
/**
|
|
4822
|
+
* Checks whether the given model is an AirPort Express.
|
|
4823
|
+
*
|
|
4824
|
+
* @param model - The device model to check.
|
|
4825
|
+
* @returns True if the model is any AirPort Express generation.
|
|
4826
|
+
*/
|
|
4827
|
+
const isAirPort = (model) => getDeviceType(model) === DeviceType.AirPort;
|
|
4828
|
+
|
|
4829
|
+
//#endregion
|
|
4830
|
+
export { AIRPLAY_SERVICE, AIRPLAY_TRANSIENT_PIN, AccessoryPair, AccessoryVerify, AirPlayFeatureFlags, AppleProtocolError, AuthenticationError, COMPANION_LINK_SERVICE, CommandError, Connection, ConnectionClosedError, ConnectionError, ConnectionRecovery, ConnectionTimeoutError, Context, CredentialsError, DeviceModel, DeviceType, Discovery, DiscoveryError, ENCRYPTION, EncryptionAwareConnection, EncryptionError, EncryptionState, HTTP_TIMEOUT, InvalidResponseError, JsonStorage, MemoryStorage, PairingError, PlaybackError, RAOP_SERVICE, SetupError, Storage, TimeoutError, TimingServer, describeFlags, generateActiveRemoteId, generateDacpId, generateSessionId, getDeviceModelName, getDeviceType, getLocalIP, getMacAddress, getPairingRequirement, getProtocolVersion, hasFeatureFlag, isAirPort, isAppleTV, isHomePod, isPasswordRequired, isRemoteControlSupported, lookupDeviceModel, multicast as mdnsMulticast, unicast as mdnsUnicast, parseFeatures, prompt, randomInt32, randomInt64, reporter, uint16ToBE, uint53ToLE, v4 as uuid, waitFor };
|