@basmilius/apple-common 0.9.19 → 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 +767 -14
- package/dist/index.mjs +1051 -106
- 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,100 +406,156 @@ 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
|
|
|
260
434
|
//#endregion
|
|
261
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
|
+
*/
|
|
262
441
|
var AppleProtocolError = class extends Error {
|
|
442
|
+
/** @param message - Human-readable description of the error. */
|
|
263
443
|
constructor(message) {
|
|
264
444
|
super(message);
|
|
265
445
|
this.name = "AppleProtocolError";
|
|
266
446
|
}
|
|
267
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
|
+
*/
|
|
268
452
|
var ConnectionError = class extends AppleProtocolError {
|
|
453
|
+
/** @param message - Human-readable description of the connection failure. */
|
|
269
454
|
constructor(message) {
|
|
270
455
|
super(message);
|
|
271
456
|
this.name = "ConnectionError";
|
|
272
457
|
}
|
|
273
458
|
};
|
|
459
|
+
/** Thrown when a TCP connection attempt exceeds the configured socket timeout. */
|
|
274
460
|
var ConnectionTimeoutError = class extends ConnectionError {
|
|
461
|
+
/** @param message - Optional custom message; defaults to a standard timeout message. */
|
|
275
462
|
constructor(message = "Connection timed out.") {
|
|
276
463
|
super(message);
|
|
277
464
|
this.name = "ConnectionTimeoutError";
|
|
278
465
|
}
|
|
279
466
|
};
|
|
467
|
+
/** Thrown when a TCP connection is closed unexpectedly by the remote end or the OS. */
|
|
280
468
|
var ConnectionClosedError = class extends ConnectionError {
|
|
469
|
+
/** @param message - Optional custom message; defaults to a standard closed message. */
|
|
281
470
|
constructor(message = "Connection closed unexpectedly.") {
|
|
282
471
|
super(message);
|
|
283
472
|
this.name = "ConnectionClosedError";
|
|
284
473
|
}
|
|
285
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
|
+
*/
|
|
286
479
|
var PairingError = class extends AppleProtocolError {
|
|
480
|
+
/** @param message - Human-readable description of the pairing failure. */
|
|
287
481
|
constructor(message) {
|
|
288
482
|
super(message);
|
|
289
483
|
this.name = "PairingError";
|
|
290
484
|
}
|
|
291
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
|
+
*/
|
|
292
490
|
var AuthenticationError = class extends PairingError {
|
|
491
|
+
/** @param message - Human-readable description of the authentication failure. */
|
|
293
492
|
constructor(message) {
|
|
294
493
|
super(message);
|
|
295
494
|
this.name = "AuthenticationError";
|
|
296
495
|
}
|
|
297
496
|
};
|
|
497
|
+
/**
|
|
498
|
+
* Thrown when stored credentials are invalid, missing, or incompatible
|
|
499
|
+
* with the accessory (e.g. after a factory reset).
|
|
500
|
+
*/
|
|
298
501
|
var CredentialsError = class extends PairingError {
|
|
502
|
+
/** @param message - Human-readable description of the credentials issue. */
|
|
299
503
|
constructor(message) {
|
|
300
504
|
super(message);
|
|
301
505
|
this.name = "CredentialsError";
|
|
302
506
|
}
|
|
303
507
|
};
|
|
508
|
+
/** Thrown when a protocol command fails to execute on the target device. */
|
|
304
509
|
var CommandError = class extends AppleProtocolError {
|
|
510
|
+
/** @param message - Human-readable description of the command failure. */
|
|
305
511
|
constructor(message) {
|
|
306
512
|
super(message);
|
|
307
513
|
this.name = "CommandError";
|
|
308
514
|
}
|
|
309
515
|
};
|
|
516
|
+
/** Thrown when a protocol setup step fails (e.g. RTSP SETUP, AirPlay stream setup). */
|
|
310
517
|
var SetupError = class extends AppleProtocolError {
|
|
518
|
+
/** @param message - Human-readable description of the setup failure. */
|
|
311
519
|
constructor(message) {
|
|
312
520
|
super(message);
|
|
313
521
|
this.name = "SetupError";
|
|
314
522
|
}
|
|
315
523
|
};
|
|
524
|
+
/** Thrown when mDNS device discovery fails or a device cannot be found after retries. */
|
|
316
525
|
var DiscoveryError = class extends AppleProtocolError {
|
|
526
|
+
/** @param message - Human-readable description of the discovery failure. */
|
|
317
527
|
constructor(message) {
|
|
318
528
|
super(message);
|
|
319
529
|
this.name = "DiscoveryError";
|
|
320
530
|
}
|
|
321
531
|
};
|
|
532
|
+
/** Thrown when an encryption or decryption operation fails (e.g. ChaCha20 auth tag mismatch). */
|
|
322
533
|
var EncryptionError = class extends AppleProtocolError {
|
|
534
|
+
/** @param message - Human-readable description of the encryption failure. */
|
|
323
535
|
constructor(message) {
|
|
324
536
|
super(message);
|
|
325
537
|
this.name = "EncryptionError";
|
|
326
538
|
}
|
|
327
539
|
};
|
|
540
|
+
/** Thrown when a response from the accessory is malformed or has an unexpected format. */
|
|
328
541
|
var InvalidResponseError = class extends AppleProtocolError {
|
|
542
|
+
/** @param message - Human-readable description of the invalid response. */
|
|
329
543
|
constructor(message) {
|
|
330
544
|
super(message);
|
|
331
545
|
this.name = "InvalidResponseError";
|
|
332
546
|
}
|
|
333
547
|
};
|
|
548
|
+
/** Thrown when a protocol operation exceeds its expected time limit. */
|
|
334
549
|
var TimeoutError = class extends AppleProtocolError {
|
|
550
|
+
/** @param message - Human-readable description of the timeout. */
|
|
335
551
|
constructor(message) {
|
|
336
552
|
super(message);
|
|
337
553
|
this.name = "TimeoutError";
|
|
338
554
|
}
|
|
339
555
|
};
|
|
556
|
+
/** Thrown when a media playback operation fails on the target device. */
|
|
340
557
|
var PlaybackError = class extends AppleProtocolError {
|
|
558
|
+
/** @param message - Human-readable description of the playback failure. */
|
|
341
559
|
constructor(message) {
|
|
342
560
|
super(message);
|
|
343
561
|
this.name = "PlaybackError";
|
|
@@ -346,10 +564,15 @@ var PlaybackError = class extends AppleProtocolError {
|
|
|
346
564
|
|
|
347
565
|
//#endregion
|
|
348
566
|
//#region src/mdns.ts
|
|
567
|
+
/** IPv4 multicast address for mDNS (RFC 6762). */
|
|
349
568
|
const MDNS_ADDRESS = "224.0.0.251";
|
|
569
|
+
/** Standard mDNS port. */
|
|
350
570
|
const MDNS_PORT = 5353;
|
|
571
|
+
/** Query ID used in outgoing mDNS queries. */
|
|
351
572
|
const QUERY_ID = 13823;
|
|
573
|
+
/** Maximum number of service queries to pack into a single DNS message. */
|
|
352
574
|
const SERVICES_PER_MSG = 3;
|
|
575
|
+
/** DNS record type codes used in mDNS queries and responses. */
|
|
353
576
|
const QueryType = {
|
|
354
577
|
A: 1,
|
|
355
578
|
PTR: 12,
|
|
@@ -358,6 +581,12 @@ const QueryType = {
|
|
|
358
581
|
SRV: 33,
|
|
359
582
|
ANY: 255
|
|
360
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
|
+
*/
|
|
361
590
|
const encodeQName = (name) => {
|
|
362
591
|
const parts = [];
|
|
363
592
|
const labels = splitServiceName(name);
|
|
@@ -369,11 +598,25 @@ const encodeQName = (name) => {
|
|
|
369
598
|
parts.push(Buffer.from([0]));
|
|
370
599
|
return Buffer.concat(parts);
|
|
371
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
|
+
*/
|
|
372
609
|
const splitServiceName = (name) => {
|
|
373
610
|
const match = name.match(/\._[a-z]+\._(?:tcp|udp)\.local$/);
|
|
374
611
|
if (match) return [name.substring(0, match.index), ...match[0].substring(1).split(".")];
|
|
375
612
|
return name.split(".");
|
|
376
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
|
+
*/
|
|
377
620
|
const encodeDnsHeader = (header) => {
|
|
378
621
|
const buf = Buffer.allocUnsafe(12);
|
|
379
622
|
buf.writeUInt16BE(header.id, 0);
|
|
@@ -384,6 +627,14 @@ const encodeDnsHeader = (header) => {
|
|
|
384
627
|
buf.writeUInt16BE(header.arcount, 10);
|
|
385
628
|
return buf;
|
|
386
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
|
+
*/
|
|
387
638
|
const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
|
|
388
639
|
const qname = encodeQName(name);
|
|
389
640
|
const suffix = Buffer.allocUnsafe(4);
|
|
@@ -391,6 +642,15 @@ const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
|
|
|
391
642
|
suffix.writeUInt16BE(unicastResponse ? 32769 : 1, 2);
|
|
392
643
|
return Buffer.concat([qname, suffix]);
|
|
393
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
|
+
*/
|
|
394
654
|
function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = false) {
|
|
395
655
|
const packets = [];
|
|
396
656
|
for (let i = 0; i < services.length; i += SERVICES_PER_MSG) {
|
|
@@ -408,7 +668,15 @@ function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = f
|
|
|
408
668
|
}
|
|
409
669
|
return packets;
|
|
410
670
|
}
|
|
671
|
+
/** Maximum number of pointer jumps allowed during name decompression to prevent infinite loops. */
|
|
411
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
|
+
*/
|
|
412
680
|
const decodeQName = (buf, offset) => {
|
|
413
681
|
const labels = [];
|
|
414
682
|
let currentOffset = offset;
|
|
@@ -430,12 +698,19 @@ const decodeQName = (buf, offset) => {
|
|
|
430
698
|
continue;
|
|
431
699
|
}
|
|
432
700
|
currentOffset++;
|
|
701
|
+
if (currentOffset + length > buf.byteLength) break;
|
|
433
702
|
labels.push(buf.toString("utf-8", currentOffset, currentOffset + length));
|
|
434
703
|
currentOffset += length;
|
|
435
704
|
if (!jumped) returnOffset = currentOffset;
|
|
436
705
|
}
|
|
437
706
|
return [labels.join("."), returnOffset];
|
|
438
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
|
+
*/
|
|
439
714
|
const decodeDnsHeader = (buf) => ({
|
|
440
715
|
id: buf.readUInt16BE(0),
|
|
441
716
|
flags: buf.readUInt16BE(2),
|
|
@@ -444,6 +719,13 @@ const decodeDnsHeader = (buf) => ({
|
|
|
444
719
|
nscount: buf.readUInt16BE(8),
|
|
445
720
|
arcount: buf.readUInt16BE(10)
|
|
446
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
|
+
*/
|
|
447
729
|
const decodeQuestion = (buf, offset) => {
|
|
448
730
|
const [qname, newOffset] = decodeQName(buf, offset);
|
|
449
731
|
return [{
|
|
@@ -452,6 +734,15 @@ const decodeQuestion = (buf, offset) => {
|
|
|
452
734
|
qclass: buf.readUInt16BE(newOffset + 2)
|
|
453
735
|
}, newOffset + 4];
|
|
454
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
|
+
*/
|
|
455
746
|
const decodeTxtRecord = (buf, offset, length) => {
|
|
456
747
|
const properties = {};
|
|
457
748
|
let pos = offset;
|
|
@@ -468,6 +759,13 @@ const decodeTxtRecord = (buf, offset, length) => {
|
|
|
468
759
|
}
|
|
469
760
|
return properties;
|
|
470
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
|
+
*/
|
|
471
769
|
const decodeSrvRecord = (buf, offset) => {
|
|
472
770
|
const priority = buf.readUInt16BE(offset);
|
|
473
771
|
const weight = buf.readUInt16BE(offset + 2);
|
|
@@ -480,6 +778,14 @@ const decodeSrvRecord = (buf, offset) => {
|
|
|
480
778
|
target
|
|
481
779
|
};
|
|
482
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
|
+
*/
|
|
483
789
|
const decodeResource = (buf, offset) => {
|
|
484
790
|
const [qname, nameEnd] = decodeQName(buf, offset);
|
|
485
791
|
const qtype = buf.readUInt16BE(nameEnd);
|
|
@@ -519,6 +825,13 @@ const decodeResource = (buf, offset) => {
|
|
|
519
825
|
rdata
|
|
520
826
|
}, rdOffset + rdLength];
|
|
521
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
|
+
*/
|
|
522
835
|
const decodeDnsResponse = (buf) => {
|
|
523
836
|
const header = decodeDnsHeader(buf);
|
|
524
837
|
let offset = 12;
|
|
@@ -548,11 +861,27 @@ const decodeDnsResponse = (buf) => {
|
|
|
548
861
|
resources
|
|
549
862
|
};
|
|
550
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
|
+
*/
|
|
551
869
|
var ServiceCollector = class {
|
|
870
|
+
/** Maps service types to sets of instance QNAMEs (from PTR records). */
|
|
552
871
|
#ptrMap = /* @__PURE__ */ new Map();
|
|
872
|
+
/** Maps instance QNAMEs to their SRV records (port and target host). */
|
|
553
873
|
#srvMap = /* @__PURE__ */ new Map();
|
|
874
|
+
/** Maps instance QNAMEs to their TXT record properties. */
|
|
554
875
|
#txtMap = /* @__PURE__ */ new Map();
|
|
876
|
+
/** Maps hostnames to IPv4 addresses (from A records). */
|
|
555
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
|
+
*/
|
|
556
885
|
addRecords(answers, resources) {
|
|
557
886
|
for (const record of [...answers, ...resources]) switch (record.qtype) {
|
|
558
887
|
case QueryType.PTR: {
|
|
@@ -572,6 +901,10 @@ var ServiceCollector = class {
|
|
|
572
901
|
break;
|
|
573
902
|
}
|
|
574
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
|
+
*/
|
|
575
908
|
get services() {
|
|
576
909
|
const results = [];
|
|
577
910
|
for (const [serviceType, instanceNames] of this.#ptrMap) for (const instanceQName of instanceNames) {
|
|
@@ -593,12 +926,20 @@ var ServiceCollector = class {
|
|
|
593
926
|
return results;
|
|
594
927
|
}
|
|
595
928
|
};
|
|
929
|
+
/** Well-known ports used to wake sleeping Apple devices via TCP SYN. */
|
|
596
930
|
const WAKE_PORTS$1 = [
|
|
597
931
|
7e3,
|
|
598
932
|
3689,
|
|
599
933
|
49152,
|
|
600
934
|
32498
|
|
601
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
|
+
*/
|
|
602
943
|
const knock = (address) => {
|
|
603
944
|
const promises = WAKE_PORTS$1.map((port) => new Promise((resolve) => {
|
|
604
945
|
const socket = createConnection({
|
|
@@ -621,6 +962,16 @@ const knock = (address) => {
|
|
|
621
962
|
}));
|
|
622
963
|
return Promise.all(promises).then(() => {});
|
|
623
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
|
+
*/
|
|
624
975
|
function unicast(hosts, services, timeout = 4) {
|
|
625
976
|
return new Promise((resolve) => {
|
|
626
977
|
const queries = createQueryPackets(services);
|
|
@@ -658,6 +1009,19 @@ function unicast(hosts, services, timeout = 4) {
|
|
|
658
1009
|
});
|
|
659
1010
|
});
|
|
660
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
|
+
*/
|
|
661
1025
|
function multicast(services, timeout = 4) {
|
|
662
1026
|
return new Promise((resolve) => {
|
|
663
1027
|
const collector = new ServiceCollector();
|
|
@@ -734,15 +1098,195 @@ function multicast(services, timeout = 4) {
|
|
|
734
1098
|
});
|
|
735
1099
|
}
|
|
736
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
|
+
|
|
737
1273
|
//#endregion
|
|
738
1274
|
//#region src/discovery.ts
|
|
1275
|
+
/** Cache time-to-live in milliseconds. */
|
|
739
1276
|
const CACHE_TTL = 3e4;
|
|
1277
|
+
/** Ports used for wake-on-network "knocking" to wake sleeping Apple devices. */
|
|
740
1278
|
const WAKE_PORTS = [
|
|
741
1279
|
7e3,
|
|
742
1280
|
3689,
|
|
743
1281
|
49152,
|
|
744
1282
|
32498
|
|
745
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
|
+
*/
|
|
746
1290
|
const toDiscoveryResult = (service) => {
|
|
747
1291
|
const txt = service.properties;
|
|
748
1292
|
const featuresStr = txt.features ?? txt.ft;
|
|
@@ -765,6 +1309,12 @@ const toDiscoveryResult = (service) => {
|
|
|
765
1309
|
packet: null
|
|
766
1310
|
};
|
|
767
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
|
+
*/
|
|
768
1318
|
const tryParseFeatures = (features) => {
|
|
769
1319
|
try {
|
|
770
1320
|
return parseFeatures(features);
|
|
@@ -772,12 +1322,32 @@ const tryParseFeatures = (features) => {
|
|
|
772
1322
|
return;
|
|
773
1323
|
}
|
|
774
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
|
+
*/
|
|
775
1334
|
var Discovery = class Discovery {
|
|
1335
|
+
/** Shared cache of discovery results, keyed by service type. */
|
|
776
1336
|
static #cache = /* @__PURE__ */ new Map();
|
|
777
1337
|
#service;
|
|
1338
|
+
/**
|
|
1339
|
+
* @param service - The mDNS service type to discover (e.g. '_airplay._tcp.local').
|
|
1340
|
+
*/
|
|
778
1341
|
constructor(service) {
|
|
779
1342
|
this.#service = service;
|
|
780
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
|
+
*/
|
|
781
1351
|
async find(useCache = true) {
|
|
782
1352
|
if (useCache) {
|
|
783
1353
|
const cached = Discovery.#cache.get(this.#service);
|
|
@@ -792,22 +1362,37 @@ var Discovery = class Discovery {
|
|
|
792
1362
|
});
|
|
793
1363
|
return mapped;
|
|
794
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
|
+
*/
|
|
795
1375
|
async findUntil(id, tries = 10, timeout = 1e3) {
|
|
796
1376
|
while (tries > 0) {
|
|
797
1377
|
const devices = await this.find(false);
|
|
798
1378
|
const device = devices.find((device) => device.id === id);
|
|
799
1379
|
if (device) return device;
|
|
800
|
-
|
|
801
|
-
console.log(`Device not found, retrying in ${timeout}ms...`);
|
|
802
|
-
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));
|
|
803
1381
|
tries--;
|
|
804
1382
|
await waitFor(timeout);
|
|
805
1383
|
}
|
|
806
1384
|
throw new DiscoveryError(`Device '${id}' not found after several tries, aborting.`);
|
|
807
1385
|
}
|
|
1386
|
+
/** Clears all cached discovery results across all service types. */
|
|
808
1387
|
static clearCache() {
|
|
809
1388
|
Discovery.#cache.clear();
|
|
810
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
|
+
*/
|
|
811
1396
|
static async wake(address) {
|
|
812
1397
|
const promises = WAKE_PORTS.map((port) => new Promise((resolve) => {
|
|
813
1398
|
const socket = createConnection({
|
|
@@ -830,6 +1415,13 @@ var Discovery = class Discovery {
|
|
|
830
1415
|
}));
|
|
831
1416
|
await Promise.all(promises);
|
|
832
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
|
+
*/
|
|
833
1425
|
static async discoverAll() {
|
|
834
1426
|
const allServices = await multicast([
|
|
835
1427
|
AIRPLAY_SERVICE,
|
|
@@ -855,12 +1447,27 @@ var Discovery = class Discovery {
|
|
|
855
1447
|
}
|
|
856
1448
|
return [...devices.values()];
|
|
857
1449
|
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Creates a Discovery instance for AirPlay services.
|
|
1452
|
+
*
|
|
1453
|
+
* @returns A new Discovery instance targeting `_airplay._tcp.local`.
|
|
1454
|
+
*/
|
|
858
1455
|
static airplay() {
|
|
859
1456
|
return new Discovery(AIRPLAY_SERVICE);
|
|
860
1457
|
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Creates a Discovery instance for Companion Link services.
|
|
1460
|
+
*
|
|
1461
|
+
* @returns A new Discovery instance targeting `_companion-link._tcp.local`.
|
|
1462
|
+
*/
|
|
861
1463
|
static companionLink() {
|
|
862
1464
|
return new Discovery(COMPANION_LINK_SERVICE);
|
|
863
1465
|
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Creates a Discovery instance for RAOP services.
|
|
1468
|
+
*
|
|
1469
|
+
* @returns A new Discovery instance targeting `_raop._tcp.local`.
|
|
1470
|
+
*/
|
|
864
1471
|
static raop() {
|
|
865
1472
|
return new Discovery(RAOP_SERVICE);
|
|
866
1473
|
}
|
|
@@ -868,23 +1475,42 @@ var Discovery = class Discovery {
|
|
|
868
1475
|
|
|
869
1476
|
//#endregion
|
|
870
1477
|
//#region src/connection.ts
|
|
1478
|
+
/** No-op promise handler used as a fallback when no connect promise is active. */
|
|
871
1479
|
const NOOP_PROMISE_HANDLER = {
|
|
872
1480
|
resolve: () => {},
|
|
873
1481
|
reject: (_) => {}
|
|
874
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
|
+
*/
|
|
875
1492
|
var Connection = class extends EventEmitter {
|
|
1493
|
+
/** The remote IP address this connection targets. */
|
|
876
1494
|
get address() {
|
|
877
1495
|
return this.#address;
|
|
878
1496
|
}
|
|
1497
|
+
/** The shared context carrying device identity and logger. */
|
|
879
1498
|
get context() {
|
|
880
1499
|
return this.#context;
|
|
881
1500
|
}
|
|
1501
|
+
/** The remote port this connection targets. */
|
|
882
1502
|
get port() {
|
|
883
1503
|
return this.#port;
|
|
884
1504
|
}
|
|
1505
|
+
/** Whether the connection is currently established and open. */
|
|
885
1506
|
get isConnected() {
|
|
886
1507
|
return this.#state === "connected";
|
|
887
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. */
|
|
888
1514
|
get state() {
|
|
889
1515
|
if (this.#state === "closing" || this.#state === "failed") return this.#state;
|
|
890
1516
|
if (!this.#socket) return "disconnected";
|
|
@@ -897,7 +1523,6 @@ var Connection = class extends EventEmitter {
|
|
|
897
1523
|
#address;
|
|
898
1524
|
#port;
|
|
899
1525
|
#context;
|
|
900
|
-
#emitInternal = (event, ...args) => this.emit(event, ...args);
|
|
901
1526
|
#debug = false;
|
|
902
1527
|
#retryAttempt = 0;
|
|
903
1528
|
#retryAttempts = 3;
|
|
@@ -907,13 +1532,30 @@ var Connection = class extends EventEmitter {
|
|
|
907
1532
|
#socket;
|
|
908
1533
|
#state;
|
|
909
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
|
+
*/
|
|
910
1540
|
constructor(context, address, port) {
|
|
911
1541
|
super();
|
|
912
1542
|
this.#address = address;
|
|
913
1543
|
this.#port = port;
|
|
914
1544
|
this.#context = context;
|
|
915
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);
|
|
916
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
|
+
*/
|
|
917
1559
|
async connect() {
|
|
918
1560
|
if (this.#state === "connected") return;
|
|
919
1561
|
if (this.#state === "connecting") throw new ConnectionError("A connection is already being established.");
|
|
@@ -921,9 +1563,14 @@ var Connection = class extends EventEmitter {
|
|
|
921
1563
|
this.#retryAttempt = 0;
|
|
922
1564
|
return this.#attemptConnect();
|
|
923
1565
|
}
|
|
1566
|
+
/** Immediately destroys the underlying socket without graceful shutdown. */
|
|
924
1567
|
destroy() {
|
|
925
1568
|
this.#socket?.destroy();
|
|
926
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
|
+
*/
|
|
927
1574
|
async disconnect() {
|
|
928
1575
|
if (this.#retryTimeout) {
|
|
929
1576
|
clearTimeout(this.#retryTimeout);
|
|
@@ -931,6 +1578,10 @@ var Connection = class extends EventEmitter {
|
|
|
931
1578
|
}
|
|
932
1579
|
this.#retryEnabled = false;
|
|
933
1580
|
if (!this.#socket || this.#state === "disconnected") return;
|
|
1581
|
+
if (this.#socket.destroyed || this.#socket.readyState === "closed") {
|
|
1582
|
+
this.#cleanup();
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
934
1585
|
return new Promise((resolve) => {
|
|
935
1586
|
this.#state = "closing";
|
|
936
1587
|
this.#socket.once("close", () => {
|
|
@@ -940,15 +1591,34 @@ var Connection = class extends EventEmitter {
|
|
|
940
1591
|
this.#socket.end();
|
|
941
1592
|
});
|
|
942
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
|
+
*/
|
|
943
1600
|
debug(enabled) {
|
|
944
1601
|
this.#debug = enabled;
|
|
945
1602
|
return this;
|
|
946
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
|
+
*/
|
|
947
1611
|
retry(attempts, interval = 3e3) {
|
|
948
1612
|
this.#retryAttempts = attempts;
|
|
949
1613
|
this.#retryInterval = interval;
|
|
950
1614
|
return this;
|
|
951
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
|
+
*/
|
|
952
1622
|
write(data) {
|
|
953
1623
|
if (!this.#socket || this.state !== "connected" || !this.#socket.writable) {
|
|
954
1624
|
this.#emitInternal("error", new ConnectionClosedError("Cannot write to a disconnected connection."));
|
|
@@ -960,6 +1630,10 @@ var Connection = class extends EventEmitter {
|
|
|
960
1630
|
this.#emitInternal("error", err);
|
|
961
1631
|
});
|
|
962
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
|
+
*/
|
|
963
1637
|
async #attemptConnect() {
|
|
964
1638
|
return new Promise((resolve, reject) => {
|
|
965
1639
|
this.#state = "connecting";
|
|
@@ -968,16 +1642,17 @@ var Connection = class extends EventEmitter {
|
|
|
968
1642
|
reject
|
|
969
1643
|
};
|
|
970
1644
|
this.#socket?.removeAllListeners();
|
|
1645
|
+
this.#socket?.destroy();
|
|
971
1646
|
this.#socket = void 0;
|
|
972
1647
|
this.#socket = new Socket();
|
|
973
1648
|
this.#socket.setNoDelay(true);
|
|
974
1649
|
this.#socket.setTimeout(SOCKET_TIMEOUT);
|
|
975
|
-
this.#socket.on("close", this
|
|
976
|
-
this.#socket.on("connect", this
|
|
977
|
-
this.#socket.on("data", this
|
|
978
|
-
this.#socket.on("end", this
|
|
979
|
-
this.#socket.on("error", this
|
|
980
|
-
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);
|
|
981
1656
|
this.#context.logger.net(`Connecting to ${this.#address}:${this.#port}...`);
|
|
982
1657
|
this.#socket.connect({
|
|
983
1658
|
host: this.#address,
|
|
@@ -985,6 +1660,7 @@ var Connection = class extends EventEmitter {
|
|
|
985
1660
|
});
|
|
986
1661
|
});
|
|
987
1662
|
}
|
|
1663
|
+
/** Removes all socket listeners, destroys the socket, and resets connection state. */
|
|
988
1664
|
#cleanup() {
|
|
989
1665
|
if (this.#retryTimeout) {
|
|
990
1666
|
clearTimeout(this.#retryTimeout);
|
|
@@ -998,6 +1674,12 @@ var Connection = class extends EventEmitter {
|
|
|
998
1674
|
this.#state = "disconnected";
|
|
999
1675
|
this.#connectPromise = void 0;
|
|
1000
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
|
+
*/
|
|
1001
1683
|
#scheduleRetry(err) {
|
|
1002
1684
|
if (!this.#retryEnabled || this.#retryAttempt >= this.#retryAttempts) {
|
|
1003
1685
|
this.#state = "failed";
|
|
@@ -1022,10 +1704,18 @@ var Connection = class extends EventEmitter {
|
|
|
1022
1704
|
};
|
|
1023
1705
|
await this.#attemptConnect();
|
|
1024
1706
|
resolve();
|
|
1025
|
-
} catch (retryErr) {
|
|
1707
|
+
} catch (retryErr) {
|
|
1708
|
+
reject(retryErr instanceof Error ? retryErr : new ConnectionError(String(retryErr)));
|
|
1709
|
+
}
|
|
1026
1710
|
}, this.#retryInterval);
|
|
1027
1711
|
}
|
|
1028
|
-
|
|
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) {
|
|
1029
1719
|
const wasConnected = this.#state === "connected";
|
|
1030
1720
|
if (this.#state !== "closing") {
|
|
1031
1721
|
this.#state = "disconnected";
|
|
@@ -1034,7 +1724,11 @@ var Connection = class extends EventEmitter {
|
|
|
1034
1724
|
this.#emitInternal("close", hadError);
|
|
1035
1725
|
if (wasConnected && this.#retryEnabled && hadError) this.#scheduleRetry(new ConnectionClosedError());
|
|
1036
1726
|
}
|
|
1037
|
-
|
|
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() {
|
|
1038
1732
|
this.#state = "connected";
|
|
1039
1733
|
this.#retryAttempt = 0;
|
|
1040
1734
|
this.#socket.setKeepAlive(true, 1e4);
|
|
@@ -1043,7 +1737,13 @@ var Connection = class extends EventEmitter {
|
|
|
1043
1737
|
this.#connectPromise?.resolve();
|
|
1044
1738
|
this.#connectPromise = void 0;
|
|
1045
1739
|
}
|
|
1046
|
-
|
|
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) {
|
|
1047
1747
|
if (this.#debug) {
|
|
1048
1748
|
const cutoff = Math.min(data.byteLength, 64);
|
|
1049
1749
|
this.#context.logger.debug(`Received ${data.byteLength} bytes of data.`);
|
|
@@ -1052,17 +1752,27 @@ var Connection = class extends EventEmitter {
|
|
|
1052
1752
|
}
|
|
1053
1753
|
this.#emitInternal("data", data);
|
|
1054
1754
|
}
|
|
1055
|
-
|
|
1755
|
+
/** Handles the socket 'end' event (remote end sent FIN). */
|
|
1756
|
+
onEnd() {
|
|
1056
1757
|
this.#emitInternal("end");
|
|
1057
1758
|
}
|
|
1058
|
-
|
|
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) {
|
|
1059
1766
|
this.#context.logger.error(`Connection error: ${err.message}`);
|
|
1060
1767
|
if (this.listenerCount("error") > 0) this.#emitInternal("error", err);
|
|
1061
|
-
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");
|
|
1062
1769
|
if (this.#state === "connecting") this.#scheduleRetry(err);
|
|
1063
|
-
else this.#state = "failed";
|
|
1064
1770
|
}
|
|
1065
|
-
|
|
1771
|
+
/**
|
|
1772
|
+
* Handles socket timeout. If connecting, schedules a retry;
|
|
1773
|
+
* otherwise destroys the socket and marks the connection as failed.
|
|
1774
|
+
*/
|
|
1775
|
+
onTimeout() {
|
|
1066
1776
|
this.#context.logger.error("Connection timed out.");
|
|
1067
1777
|
const err = new ConnectionTimeoutError();
|
|
1068
1778
|
this.#emitInternal("timeout");
|
|
@@ -1072,21 +1782,60 @@ var Connection = class extends EventEmitter {
|
|
|
1072
1782
|
this.#socket?.destroy();
|
|
1073
1783
|
}
|
|
1074
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
|
+
}
|
|
1075
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
|
+
*/
|
|
1076
1802
|
var EncryptionAwareConnection = class extends Connection {
|
|
1803
|
+
/** Whether encryption has been enabled on this connection. */
|
|
1077
1804
|
get isEncrypted() {
|
|
1078
1805
|
return !!this._encryption;
|
|
1079
1806
|
}
|
|
1807
|
+
/** The current encryption state, or undefined if encryption is not enabled. */
|
|
1080
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
|
+
*/
|
|
1081
1817
|
enableEncryption(readKey, writeKey) {
|
|
1082
1818
|
this._encryption = new EncryptionState(readKey, writeKey);
|
|
1083
1819
|
}
|
|
1084
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
|
+
*/
|
|
1085
1826
|
var EncryptionState = class {
|
|
1827
|
+
/** The 32-byte key used to decrypt incoming data. */
|
|
1086
1828
|
readKey;
|
|
1829
|
+
/** Monotonically increasing counter used as part of the read nonce. */
|
|
1087
1830
|
readCount;
|
|
1831
|
+
/** The 32-byte key used to encrypt outgoing data. */
|
|
1088
1832
|
writeKey;
|
|
1833
|
+
/** Monotonically increasing counter used as part of the write nonce. */
|
|
1089
1834
|
writeCount;
|
|
1835
|
+
/**
|
|
1836
|
+
* @param readKey - The 32-byte decryption key.
|
|
1837
|
+
* @param writeKey - The 32-byte encryption key.
|
|
1838
|
+
*/
|
|
1090
1839
|
constructor(readKey, writeKey) {
|
|
1091
1840
|
this.readCount = 0;
|
|
1092
1841
|
this.readKey = readKey;
|
|
@@ -1095,98 +1844,50 @@ var EncryptionState = class {
|
|
|
1095
1844
|
}
|
|
1096
1845
|
};
|
|
1097
1846
|
|
|
1098
|
-
//#endregion
|
|
1099
|
-
//#region src/reporter.ts
|
|
1100
|
-
var Logger = class {
|
|
1101
|
-
get id() {
|
|
1102
|
-
return this.#id;
|
|
1103
|
-
}
|
|
1104
|
-
get label() {
|
|
1105
|
-
return this.#label;
|
|
1106
|
-
}
|
|
1107
|
-
#id;
|
|
1108
|
-
#label;
|
|
1109
|
-
constructor(id) {
|
|
1110
|
-
this.#id = id;
|
|
1111
|
-
this.#label = `\u001b[36m[${id}]\u001b[39m`;
|
|
1112
|
-
}
|
|
1113
|
-
debug(...data) {
|
|
1114
|
-
debug(this.#label, ...data);
|
|
1115
|
-
}
|
|
1116
|
-
error(...data) {
|
|
1117
|
-
error(this.#label, ...data);
|
|
1118
|
-
}
|
|
1119
|
-
info(...data) {
|
|
1120
|
-
info(this.#label, ...data);
|
|
1121
|
-
}
|
|
1122
|
-
net(...data) {
|
|
1123
|
-
net(this.#label, ...data);
|
|
1124
|
-
}
|
|
1125
|
-
raw(...data) {
|
|
1126
|
-
raw(this.#label, ...data);
|
|
1127
|
-
}
|
|
1128
|
-
warn(...data) {
|
|
1129
|
-
warn(this.#label, ...data);
|
|
1130
|
-
}
|
|
1131
|
-
};
|
|
1132
|
-
var Reporter = class {
|
|
1133
|
-
#enabled = [];
|
|
1134
|
-
all() {
|
|
1135
|
-
this.#enabled = [
|
|
1136
|
-
"debug",
|
|
1137
|
-
"error",
|
|
1138
|
-
"info",
|
|
1139
|
-
"net",
|
|
1140
|
-
"raw",
|
|
1141
|
-
"warn"
|
|
1142
|
-
];
|
|
1143
|
-
}
|
|
1144
|
-
none() {
|
|
1145
|
-
this.#enabled = [];
|
|
1146
|
-
}
|
|
1147
|
-
disable(group) {
|
|
1148
|
-
if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
|
|
1149
|
-
}
|
|
1150
|
-
enable(group) {
|
|
1151
|
-
if (!this.#enabled.includes(group)) this.#enabled.push(group);
|
|
1152
|
-
}
|
|
1153
|
-
isEnabled(group) {
|
|
1154
|
-
return this.#enabled.includes(group);
|
|
1155
|
-
}
|
|
1156
|
-
};
|
|
1157
|
-
function debug(...data) {
|
|
1158
|
-
reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
|
|
1159
|
-
}
|
|
1160
|
-
function error(...data) {
|
|
1161
|
-
reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
|
|
1162
|
-
}
|
|
1163
|
-
function info(...data) {
|
|
1164
|
-
reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
|
|
1165
|
-
}
|
|
1166
|
-
function net(...data) {
|
|
1167
|
-
reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
|
|
1168
|
-
}
|
|
1169
|
-
function raw(...data) {
|
|
1170
|
-
reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
|
|
1171
|
-
}
|
|
1172
|
-
function warn(...data) {
|
|
1173
|
-
reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
|
|
1174
|
-
}
|
|
1175
|
-
const reporter = new Reporter();
|
|
1176
|
-
|
|
1177
1847
|
//#endregion
|
|
1178
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
|
+
*/
|
|
1179
1865
|
var Context = class {
|
|
1866
|
+
/** Unique identifier of the target device (typically its mDNS hostname). */
|
|
1180
1867
|
get deviceId() {
|
|
1181
1868
|
return this.#deviceId;
|
|
1182
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. */
|
|
1183
1875
|
get logger() {
|
|
1184
1876
|
return this.#logger;
|
|
1185
1877
|
}
|
|
1186
1878
|
#deviceId;
|
|
1879
|
+
#identity;
|
|
1187
1880
|
#logger;
|
|
1188
|
-
|
|
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) {
|
|
1189
1886
|
this.#deviceId = deviceId;
|
|
1887
|
+
this.#identity = {
|
|
1888
|
+
...DEFAULT_IDENTITY,
|
|
1889
|
+
...identity
|
|
1890
|
+
};
|
|
1190
1891
|
this.#logger = new Logger(deviceId);
|
|
1191
1892
|
}
|
|
1192
1893
|
};
|
|
@@ -3215,14 +3916,30 @@ var require_srp = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
3215
3916
|
//#endregion
|
|
3216
3917
|
//#region src/pairing.ts
|
|
3217
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
|
+
*/
|
|
3218
3923
|
var BasePairing = class {
|
|
3924
|
+
/** The shared context carrying device identity and logger. */
|
|
3219
3925
|
get context() {
|
|
3220
3926
|
return this.#context;
|
|
3221
3927
|
}
|
|
3222
3928
|
#context;
|
|
3929
|
+
/**
|
|
3930
|
+
* @param context - Shared context for logging and device identity.
|
|
3931
|
+
*/
|
|
3223
3932
|
constructor(context) {
|
|
3224
3933
|
this.#context = context;
|
|
3225
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
|
+
*/
|
|
3226
3943
|
tlv(buffer) {
|
|
3227
3944
|
const data = TLV8.decode(buffer);
|
|
3228
3945
|
if (data.has(TLV8.Value.Error)) TLV8.bail(data);
|
|
@@ -3242,23 +3959,43 @@ var BasePairing = class {
|
|
|
3242
3959
|
* For transient pairing (HomePod), only M1-M4 are needed — no long-term credentials are stored.
|
|
3243
3960
|
*/
|
|
3244
3961
|
var AccessoryPair = class extends BasePairing {
|
|
3962
|
+
/** Human-readable name sent to the accessory during pairing. */
|
|
3245
3963
|
#name;
|
|
3964
|
+
/** Unique pairing identifier (UUID) for this controller. */
|
|
3246
3965
|
#pairingId;
|
|
3966
|
+
/** Protocol-specific callback for sending TLV8-encoded pair-setup requests. */
|
|
3247
3967
|
#requestHandler;
|
|
3968
|
+
/** Ed25519 public key for long-term identity. */
|
|
3248
3969
|
#publicKey;
|
|
3970
|
+
/** Ed25519 secret key for signing identity proofs. */
|
|
3249
3971
|
#secretKey;
|
|
3972
|
+
/** SRP client instance used for PIN-based authentication. */
|
|
3250
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
|
+
*/
|
|
3251
3978
|
constructor(context, requestHandler) {
|
|
3252
3979
|
super(context);
|
|
3253
3980
|
this.#name = "basmilius/apple-protocols";
|
|
3254
3981
|
this.#pairingId = Buffer.from(v4().toUpperCase());
|
|
3255
3982
|
this.#requestHandler = requestHandler;
|
|
3256
3983
|
}
|
|
3984
|
+
/** Generates a new Ed25519 key pair for long-term identity. Must be called before pin() or transient(). */
|
|
3257
3985
|
async start() {
|
|
3258
3986
|
const keyPair = Ed25519.generateKeyPair();
|
|
3259
3987
|
this.#publicKey = Buffer.from(keyPair.publicKey);
|
|
3260
3988
|
this.#secretKey = Buffer.from(keyPair.secretKey);
|
|
3261
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
|
+
*/
|
|
3262
3999
|
async pin(askPin) {
|
|
3263
4000
|
const m1 = await this.m1();
|
|
3264
4001
|
const m2 = await this.m2(m1, await askPin());
|
|
@@ -3269,6 +4006,15 @@ var AccessoryPair = class extends BasePairing {
|
|
|
3269
4006
|
if (!m6) throw new PairingError("Pairing failed, could not get accessory keys.");
|
|
3270
4007
|
return m6;
|
|
3271
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
|
+
*/
|
|
3272
4018
|
async transient() {
|
|
3273
4019
|
const m1 = await this.m1([[TLV8.Value.Flags, TLV8.Flags.TransientPairing]]);
|
|
3274
4020
|
const m2 = await this.m2(m1);
|
|
@@ -3447,13 +4193,28 @@ var AccessoryPair = class extends BasePairing {
|
|
|
3447
4193
|
* → M3 (controller proof) → M4 (session key derivation)
|
|
3448
4194
|
*/
|
|
3449
4195
|
var AccessoryVerify = class extends BasePairing {
|
|
4196
|
+
/** Ephemeral Curve25519 key pair for forward-secrecy ECDH exchange. */
|
|
3450
4197
|
#ephemeralKeyPair;
|
|
4198
|
+
/** Protocol-specific callback for sending TLV8-encoded pair-verify requests. */
|
|
3451
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
|
+
*/
|
|
3452
4204
|
constructor(context, requestHandler) {
|
|
3453
4205
|
super(context);
|
|
3454
4206
|
this.#ephemeralKeyPair = Curve25519.generateKeyPair();
|
|
3455
4207
|
this.#requestHandler = requestHandler;
|
|
3456
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
|
+
*/
|
|
3457
4218
|
async start(credentials) {
|
|
3458
4219
|
const m1 = await this.#m1();
|
|
3459
4220
|
const m2 = await this.#m2(credentials.accessoryIdentifier, credentials.accessoryLongTermPublicKey, m1);
|
|
@@ -3551,10 +4312,25 @@ var AccessoryVerify = class extends BasePairing {
|
|
|
3551
4312
|
|
|
3552
4313
|
//#endregion
|
|
3553
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
|
+
*/
|
|
3554
4319
|
const ENCRYPTION = Symbol();
|
|
3555
4320
|
|
|
3556
4321
|
//#endregion
|
|
3557
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
|
+
*/
|
|
3558
4334
|
var ConnectionRecovery = class extends EventEmitter {
|
|
3559
4335
|
#options;
|
|
3560
4336
|
#attempt = 0;
|
|
@@ -3564,6 +4340,9 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3564
4340
|
#retryTimeout;
|
|
3565
4341
|
#reconnectInterval;
|
|
3566
4342
|
#disposed = false;
|
|
4343
|
+
/**
|
|
4344
|
+
* @param options - Recovery configuration including the reconnect callback.
|
|
4345
|
+
*/
|
|
3567
4346
|
constructor(options) {
|
|
3568
4347
|
super();
|
|
3569
4348
|
this.#options = {
|
|
@@ -3576,17 +4355,26 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3576
4355
|
};
|
|
3577
4356
|
if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
|
|
3578
4357
|
}
|
|
4358
|
+
/** Whether a recovery attempt is currently in progress. */
|
|
3579
4359
|
get isRecovering() {
|
|
3580
4360
|
return this.#isRecovering;
|
|
3581
4361
|
}
|
|
4362
|
+
/** The current retry attempt number (0 when not recovering). */
|
|
3582
4363
|
get attempt() {
|
|
3583
4364
|
return this.#attempt;
|
|
3584
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
|
+
*/
|
|
3585
4372
|
handleDisconnect(unexpected) {
|
|
3586
4373
|
if (this.#disposed || !unexpected) return;
|
|
3587
4374
|
this.#stopReconnectInterval();
|
|
3588
4375
|
this.#recover();
|
|
3589
4376
|
}
|
|
4377
|
+
/** Resets the recovery state and restarts the periodic reconnect interval if configured. */
|
|
3590
4378
|
reset() {
|
|
3591
4379
|
this.#attempt = 0;
|
|
3592
4380
|
this.#errors = [];
|
|
@@ -3597,6 +4385,7 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3597
4385
|
}
|
|
3598
4386
|
if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
|
|
3599
4387
|
}
|
|
4388
|
+
/** Permanently disposes this recovery instance, cancelling all timers and removing listeners. */
|
|
3600
4389
|
dispose() {
|
|
3601
4390
|
this.#disposed = true;
|
|
3602
4391
|
this.#isRecovering = false;
|
|
@@ -3608,6 +4397,10 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3608
4397
|
this.#stopReconnectInterval();
|
|
3609
4398
|
this.removeAllListeners();
|
|
3610
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
|
+
*/
|
|
3611
4404
|
#recover() {
|
|
3612
4405
|
if (this.#isRecovering || this.#disposed) return;
|
|
3613
4406
|
if (this.#attempt >= this.#options.maxAttempts) {
|
|
@@ -3634,11 +4427,22 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3634
4427
|
}
|
|
3635
4428
|
}, delay);
|
|
3636
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
|
+
*/
|
|
3637
4437
|
#calculateDelay() {
|
|
3638
4438
|
if (!this.#options.useExponentialBackoff) return this.#options.baseDelay;
|
|
3639
4439
|
const delay = this.#options.baseDelay * Math.pow(2, this.#attempt - 1);
|
|
3640
4440
|
return Math.min(delay, this.#options.maxDelay);
|
|
3641
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
|
+
*/
|
|
3642
4446
|
#startReconnectInterval() {
|
|
3643
4447
|
this.#stopReconnectInterval();
|
|
3644
4448
|
this.#reconnectInterval = setInterval(async () => {
|
|
@@ -3651,6 +4455,7 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3651
4455
|
}
|
|
3652
4456
|
}, this.#options.reconnectInterval);
|
|
3653
4457
|
}
|
|
4458
|
+
/** Stops the periodic reconnect interval timer. */
|
|
3654
4459
|
#stopReconnectInterval() {
|
|
3655
4460
|
if (this.#reconnectInterval) {
|
|
3656
4461
|
clearInterval(this.#reconnectInterval);
|
|
@@ -3661,7 +4466,19 @@ var ConnectionRecovery = class extends EventEmitter {
|
|
|
3661
4466
|
|
|
3662
4467
|
//#endregion
|
|
3663
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
|
+
*/
|
|
3664
4480
|
var TimingServer = class {
|
|
4481
|
+
/** The UDP port the timing server is listening on, or 0 if not yet bound. */
|
|
3665
4482
|
get port() {
|
|
3666
4483
|
return this.#port;
|
|
3667
4484
|
}
|
|
@@ -3671,14 +4488,24 @@ var TimingServer = class {
|
|
|
3671
4488
|
constructor() {
|
|
3672
4489
|
this.#logger = new Logger("timing-server");
|
|
3673
4490
|
this.#socket = createSocket("udp4");
|
|
3674
|
-
this
|
|
3675
|
-
this
|
|
3676
|
-
this
|
|
3677
|
-
|
|
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. */
|
|
3678
4499
|
close() {
|
|
3679
4500
|
this.#socket.close();
|
|
3680
4501
|
this.#port = 0;
|
|
3681
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
|
+
*/
|
|
3682
4509
|
listen() {
|
|
3683
4510
|
return new Promise((resolve, reject) => {
|
|
3684
4511
|
const onError = (err) => {
|
|
@@ -3695,18 +4522,37 @@ var TimingServer = class {
|
|
|
3695
4522
|
this.#socket.bind(0);
|
|
3696
4523
|
});
|
|
3697
4524
|
}
|
|
3698
|
-
|
|
4525
|
+
/**
|
|
4526
|
+
* Handles the socket 'connect' event by configuring buffer sizes.
|
|
4527
|
+
*/
|
|
4528
|
+
onConnect() {
|
|
3699
4529
|
this.#socket.setRecvBufferSize(16384);
|
|
3700
4530
|
this.#socket.setSendBufferSize(16384);
|
|
3701
4531
|
}
|
|
3702
|
-
|
|
4532
|
+
/**
|
|
4533
|
+
* Handles socket errors by logging them.
|
|
4534
|
+
*
|
|
4535
|
+
* @param err - The error that occurred.
|
|
4536
|
+
*/
|
|
4537
|
+
onError(err) {
|
|
3703
4538
|
this.#logger.error("Timing server error", err);
|
|
3704
4539
|
}
|
|
4540
|
+
/**
|
|
4541
|
+
* Records the bound port after the socket starts listening.
|
|
4542
|
+
*/
|
|
3705
4543
|
#onListening() {
|
|
3706
4544
|
const { port } = this.#socket.address();
|
|
3707
4545
|
this.#port = port;
|
|
3708
4546
|
}
|
|
3709
|
-
|
|
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) {
|
|
3710
4556
|
try {
|
|
3711
4557
|
const request = NTP.decode(data);
|
|
3712
4558
|
const ntp = NTP.now();
|
|
@@ -3736,15 +4582,36 @@ var TimingServer = class {
|
|
|
3736
4582
|
|
|
3737
4583
|
//#endregion
|
|
3738
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
|
+
*/
|
|
3739
4591
|
function generateActiveRemoteId() {
|
|
3740
4592
|
return Math.floor(Math.random() * 2 ** 32).toString(10);
|
|
3741
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
|
+
*/
|
|
3742
4599
|
function generateDacpId() {
|
|
3743
4600
|
return Math.floor(Math.random() * 2 ** 64).toString(16).toUpperCase();
|
|
3744
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
|
+
*/
|
|
3745
4607
|
function generateSessionId() {
|
|
3746
4608
|
return Math.floor(Math.random() * 2 ** 32).toString(10);
|
|
3747
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
|
+
*/
|
|
3748
4615
|
function getLocalIP() {
|
|
3749
4616
|
const interfaces = networkInterfaces();
|
|
3750
4617
|
for (const iface of Object.values(interfaces)) {
|
|
@@ -3756,6 +4623,11 @@ function getLocalIP() {
|
|
|
3756
4623
|
}
|
|
3757
4624
|
return null;
|
|
3758
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
|
+
*/
|
|
3759
4631
|
function getMacAddress() {
|
|
3760
4632
|
const interfaces = networkInterfaces();
|
|
3761
4633
|
for (const iface of Object.values(interfaces)) {
|
|
@@ -3767,17 +4639,41 @@ function getMacAddress() {
|
|
|
3767
4639
|
}
|
|
3768
4640
|
return "00:00:00:00:00:00";
|
|
3769
4641
|
}
|
|
4642
|
+
/**
|
|
4643
|
+
* Generates a cryptographically random 32-bit unsigned integer.
|
|
4644
|
+
*
|
|
4645
|
+
* @returns A random unsigned 32-bit integer.
|
|
4646
|
+
*/
|
|
3770
4647
|
function randomInt32() {
|
|
3771
4648
|
return randomBytes(4).readUInt32BE(0);
|
|
3772
4649
|
}
|
|
4650
|
+
/**
|
|
4651
|
+
* Generates a cryptographically random 64-bit unsigned integer.
|
|
4652
|
+
*
|
|
4653
|
+
* @returns A random unsigned 64-bit bigint.
|
|
4654
|
+
*/
|
|
3773
4655
|
function randomInt64() {
|
|
3774
4656
|
return randomBytes(8).readBigUint64LE(0);
|
|
3775
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
|
+
*/
|
|
3776
4664
|
function uint16ToBE(value) {
|
|
3777
4665
|
const buffer = Buffer.allocUnsafe(2);
|
|
3778
4666
|
buffer.writeUInt16BE(value, 0);
|
|
3779
4667
|
return buffer;
|
|
3780
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
|
+
*/
|
|
3781
4677
|
function uint53ToLE(value) {
|
|
3782
4678
|
const [upper, lower] = splitUInt53(value);
|
|
3783
4679
|
const buffer = Buffer.allocUnsafe(8);
|
|
@@ -3785,6 +4681,13 @@ function uint53ToLE(value) {
|
|
|
3785
4681
|
buffer.writeUInt32LE(upper, 4);
|
|
3786
4682
|
return buffer;
|
|
3787
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
|
+
*/
|
|
3788
4691
|
function splitUInt53(number) {
|
|
3789
4692
|
const MAX_UINT32 = 4294967295;
|
|
3790
4693
|
if (number <= -1 || number > 9007199254740991) throw new Error("Number out of range.");
|
|
@@ -3798,6 +4701,7 @@ function splitUInt53(number) {
|
|
|
3798
4701
|
|
|
3799
4702
|
//#endregion
|
|
3800
4703
|
//#region src/deviceModel.ts
|
|
4704
|
+
/** Known Apple device models that can be discovered and controlled via AirPlay/Companion Link. */
|
|
3801
4705
|
let DeviceModel = /* @__PURE__ */ function(DeviceModel) {
|
|
3802
4706
|
DeviceModel[DeviceModel["Unknown"] = 0] = "Unknown";
|
|
3803
4707
|
DeviceModel[DeviceModel["AppleTVGen2"] = 1] = "AppleTVGen2";
|
|
@@ -3813,6 +4717,7 @@ let DeviceModel = /* @__PURE__ */ function(DeviceModel) {
|
|
|
3813
4717
|
DeviceModel[DeviceModel["AirPortExpressGen2"] = 21] = "AirPortExpressGen2";
|
|
3814
4718
|
return DeviceModel;
|
|
3815
4719
|
}({});
|
|
4720
|
+
/** High-level device categories derived from the specific device model. */
|
|
3816
4721
|
let DeviceType = /* @__PURE__ */ function(DeviceType) {
|
|
3817
4722
|
DeviceType[DeviceType["Unknown"] = 0] = "Unknown";
|
|
3818
4723
|
DeviceType[DeviceType["AppleTV"] = 1] = "AppleTV";
|
|
@@ -3820,6 +4725,7 @@ let DeviceType = /* @__PURE__ */ function(DeviceType) {
|
|
|
3820
4725
|
DeviceType[DeviceType["AirPort"] = 3] = "AirPort";
|
|
3821
4726
|
return DeviceType;
|
|
3822
4727
|
}({});
|
|
4728
|
+
/** Maps Apple model identifiers (e.g. "AppleTV6,2") to known device models. */
|
|
3823
4729
|
const MODEL_IDENTIFIERS = {
|
|
3824
4730
|
"AppleTV2,1": DeviceModel.AppleTVGen2,
|
|
3825
4731
|
"AppleTV3,1": DeviceModel.AppleTVGen3,
|
|
@@ -3836,6 +4742,7 @@ const MODEL_IDENTIFIERS = {
|
|
|
3836
4742
|
"AirPort4,107": DeviceModel.AirPortExpress,
|
|
3837
4743
|
"AirPort10,115": DeviceModel.AirPortExpressGen2
|
|
3838
4744
|
};
|
|
4745
|
+
/** Maps Apple internal code names (e.g. "J305AP") to known device models. */
|
|
3839
4746
|
const INTERNAL_NAMES = {
|
|
3840
4747
|
"K66AP": DeviceModel.AppleTVGen2,
|
|
3841
4748
|
"J33AP": DeviceModel.AppleTVGen3,
|
|
@@ -3846,6 +4753,7 @@ const INTERNAL_NAMES = {
|
|
|
3846
4753
|
"J255AP": DeviceModel.AppleTV4KGen3,
|
|
3847
4754
|
"B520AP": DeviceModel.HomePodMini
|
|
3848
4755
|
};
|
|
4756
|
+
/** Human-readable display names for each device model. */
|
|
3849
4757
|
const MODEL_NAMES = {
|
|
3850
4758
|
[DeviceModel.Unknown]: "Unknown",
|
|
3851
4759
|
[DeviceModel.AppleTVGen2]: "Apple TV (2nd generation)",
|
|
@@ -3860,6 +4768,7 @@ const MODEL_NAMES = {
|
|
|
3860
4768
|
[DeviceModel.AirPortExpress]: "AirPort Express",
|
|
3861
4769
|
[DeviceModel.AirPortExpressGen2]: "AirPort Express (2nd generation)"
|
|
3862
4770
|
};
|
|
4771
|
+
/** Maps each device model to its high-level device type category. */
|
|
3863
4772
|
const MODEL_TYPES = {
|
|
3864
4773
|
[DeviceModel.Unknown]: DeviceType.Unknown,
|
|
3865
4774
|
[DeviceModel.AppleTVGen2]: DeviceType.AppleTV,
|
|
@@ -3874,11 +4783,47 @@ const MODEL_TYPES = {
|
|
|
3874
4783
|
[DeviceModel.AirPortExpress]: DeviceType.AirPort,
|
|
3875
4784
|
[DeviceModel.AirPortExpressGen2]: DeviceType.AirPort
|
|
3876
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
|
+
*/
|
|
3877
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
|
+
*/
|
|
3878
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
|
+
*/
|
|
3879
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
|
+
*/
|
|
3880
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
|
+
*/
|
|
3881
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
|
+
*/
|
|
3882
4827
|
const isAirPort = (model) => getDeviceType(model) === DeviceType.AirPort;
|
|
3883
4828
|
|
|
3884
4829
|
//#endregion
|