@corewire/companion-link 0.0.0 → 1.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -1
- package/dist/chunk-w6R9maHv.mjs +18 -0
- package/dist/index.d.mts +705 -0
- package/dist/index.mjs +1857 -0
- package/package.json +54 -18
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1857 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-w6R9maHv.mjs";
|
|
2
|
+
import { randomInt } from "node:crypto";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { CommandError, ConnectionClosedError, CredentialsError, InvalidResponseError, PairingError, TimeoutError, reporter, waitFor } from "@corewire/core";
|
|
5
|
+
import { Context, DEFAULT_CLIENT_NAME, EncryptionAwareConnection, buildNonce } from "@corewire/core/transport";
|
|
6
|
+
import { NSKeyedArchiver, OPack, Plist, RTI } from "@corewire/core/encoding";
|
|
7
|
+
import { ChaCha20, HKDF } from "@corewire/core/crypto";
|
|
8
|
+
import { PairSetup, PairVerify } from "@corewire/core/pairing";
|
|
9
|
+
import { Discovery } from "@corewire/core/discovery";
|
|
10
|
+
|
|
11
|
+
//#region src/const.ts
|
|
12
|
+
/** Siri Remote buttons, as the command ids of the `_hidC` request. */
|
|
13
|
+
const HidCommand = {
|
|
14
|
+
Up: 1,
|
|
15
|
+
Down: 2,
|
|
16
|
+
Left: 3,
|
|
17
|
+
Right: 4,
|
|
18
|
+
Menu: 5,
|
|
19
|
+
Select: 6,
|
|
20
|
+
Home: 7,
|
|
21
|
+
VolumeUp: 8,
|
|
22
|
+
VolumeDown: 9,
|
|
23
|
+
Siri: 10,
|
|
24
|
+
Screensaver: 11,
|
|
25
|
+
Sleep: 12,
|
|
26
|
+
Wake: 13,
|
|
27
|
+
PlayPause: 14,
|
|
28
|
+
ChannelIncrement: 15,
|
|
29
|
+
ChannelDecrement: 16,
|
|
30
|
+
Guide: 17,
|
|
31
|
+
PageUp: 18,
|
|
32
|
+
PageDown: 19,
|
|
33
|
+
SkipForward: 20,
|
|
34
|
+
SkipBackward: 21,
|
|
35
|
+
Back: 22,
|
|
36
|
+
Exit: 23,
|
|
37
|
+
Info: 24,
|
|
38
|
+
CaptionsToggle: 25,
|
|
39
|
+
Accessibility: 26,
|
|
40
|
+
InputSelect: 27,
|
|
41
|
+
Mute: 29,
|
|
42
|
+
Power: 30
|
|
43
|
+
};
|
|
44
|
+
/** Playback and caption commands of the `_mcc` request. */
|
|
45
|
+
const MediaControlCommand = {
|
|
46
|
+
Play: 1,
|
|
47
|
+
Pause: 2,
|
|
48
|
+
NextTrack: 3,
|
|
49
|
+
PreviousTrack: 4,
|
|
50
|
+
GetVolume: 5,
|
|
51
|
+
SetVolume: 6,
|
|
52
|
+
SkipBy: 7,
|
|
53
|
+
FastForwardBegin: 8,
|
|
54
|
+
FastForwardEnd: 9,
|
|
55
|
+
RewindBegin: 10,
|
|
56
|
+
RewindEnd: 11,
|
|
57
|
+
GetCaptionSettings: 12,
|
|
58
|
+
SetCaptionSettings: 13
|
|
59
|
+
};
|
|
60
|
+
/** Bit flags of the media controls the Apple TV supports right now, from the media control status. */
|
|
61
|
+
const MediaControlFlag = {
|
|
62
|
+
Play: 1,
|
|
63
|
+
Pause: 2,
|
|
64
|
+
PreviousTrack: 4,
|
|
65
|
+
NextTrack: 8,
|
|
66
|
+
FastForward: 16,
|
|
67
|
+
Rewind: 32,
|
|
68
|
+
Volume: 256,
|
|
69
|
+
SkipForward: 512,
|
|
70
|
+
SkipBackward: 1024
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/frame.ts
|
|
75
|
+
/** The first byte of every frame header; it decides how the payload is parsed and routed. */
|
|
76
|
+
const FrameType = {
|
|
77
|
+
Unknown: 0,
|
|
78
|
+
NoOp: 1,
|
|
79
|
+
PairSetupStart: 3,
|
|
80
|
+
PairSetupNext: 4,
|
|
81
|
+
PairVerifyStart: 5,
|
|
82
|
+
PairVerifyNext: 6,
|
|
83
|
+
OPackUnencrypted: 7,
|
|
84
|
+
OPackEncrypted: 8,
|
|
85
|
+
OPackPacked: 9,
|
|
86
|
+
PairingRequest: 10,
|
|
87
|
+
PairingResponse: 11,
|
|
88
|
+
SessionStartRequest: 16,
|
|
89
|
+
SessionStartResponse: 17,
|
|
90
|
+
SessionData: 18,
|
|
91
|
+
FamilyIdentityRequest: 32,
|
|
92
|
+
FamilyIdentityResponse: 33,
|
|
93
|
+
FamilyIdentityUpdate: 34
|
|
94
|
+
};
|
|
95
|
+
/** The `_t` field of an OPack message. */
|
|
96
|
+
const MessageType = {
|
|
97
|
+
Event: 1,
|
|
98
|
+
Request: 2,
|
|
99
|
+
Response: 3
|
|
100
|
+
};
|
|
101
|
+
/** Frame types with an OPack payload. */
|
|
102
|
+
const OPackFrameTypes = [
|
|
103
|
+
FrameType.PairSetupStart,
|
|
104
|
+
FrameType.PairSetupNext,
|
|
105
|
+
FrameType.PairVerifyStart,
|
|
106
|
+
FrameType.PairVerifyNext,
|
|
107
|
+
FrameType.OPackUnencrypted,
|
|
108
|
+
FrameType.OPackEncrypted,
|
|
109
|
+
FrameType.OPackPacked
|
|
110
|
+
];
|
|
111
|
+
/** Pair-setup and pair-verify frames. They have no `_x` exchange id, so they get a queue id of their own. */
|
|
112
|
+
const PairingFrameTypes = [
|
|
113
|
+
FrameType.PairSetupStart,
|
|
114
|
+
FrameType.PairSetupNext,
|
|
115
|
+
FrameType.PairVerifyStart,
|
|
116
|
+
FrameType.PairVerifyNext
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/messages.ts
|
|
121
|
+
var messages_exports = /* @__PURE__ */ __exportAll({
|
|
122
|
+
addToUpNext: () => addToUpNext,
|
|
123
|
+
appSignInRequest: () => appSignInRequest,
|
|
124
|
+
deregisterInterests: () => deregisterInterests,
|
|
125
|
+
fetchAttentionState: () => fetchAttentionState,
|
|
126
|
+
fetchCurrentTopShelfItems: () => fetchCurrentTopShelfItems,
|
|
127
|
+
fetchLaunchableApps: () => fetchLaunchableApps,
|
|
128
|
+
fetchMediaControlStatus: () => fetchMediaControlStatus,
|
|
129
|
+
fetchNowPlayingInfo: () => fetchNowPlayingInfo,
|
|
130
|
+
fetchSiriRemoteInfo: () => fetchSiriRemoteInfo,
|
|
131
|
+
fetchSupportedActions: () => fetchSupportedActions,
|
|
132
|
+
fetchUpNext: () => fetchUpNext,
|
|
133
|
+
fetchUserAccounts: () => fetchUserAccounts,
|
|
134
|
+
gameControllerEvent: () => gameControllerEvent,
|
|
135
|
+
gameControllerStart: () => gameControllerStart,
|
|
136
|
+
gameControllerStop: () => gameControllerStop,
|
|
137
|
+
hidCommand: () => hidCommand,
|
|
138
|
+
hidTouchEvent: () => hidTouchEvent,
|
|
139
|
+
launchApp: () => launchApp,
|
|
140
|
+
launchUrl: () => launchUrl,
|
|
141
|
+
markAsWatched: () => markAsWatched,
|
|
142
|
+
mediaCaptionSettingGet: () => mediaCaptionSettingGet,
|
|
143
|
+
mediaCaptionSettingSet: () => mediaCaptionSettingSet,
|
|
144
|
+
mediaControlCommand: () => mediaControlCommand,
|
|
145
|
+
mediaSkipBySeconds: () => mediaSkipBySeconds,
|
|
146
|
+
playMedia: () => playMedia,
|
|
147
|
+
registerInterests: () => registerInterests,
|
|
148
|
+
removeFromUpNext: () => removeFromUpNext,
|
|
149
|
+
restrictedAccessRequest: () => restrictedAccessRequest,
|
|
150
|
+
sessionStart: () => sessionStart,
|
|
151
|
+
sessionStop: () => sessionStop,
|
|
152
|
+
siriStart: () => siriStart,
|
|
153
|
+
siriStop: () => siriStop,
|
|
154
|
+
switchActiveUserAccount: () => switchActiveUserAccount,
|
|
155
|
+
switchUserAccount: () => switchUserAccount,
|
|
156
|
+
systemInfo: () => systemInfo,
|
|
157
|
+
tiChange: () => tiChange,
|
|
158
|
+
tiStart: () => tiStart,
|
|
159
|
+
tiStop: () => tiStop,
|
|
160
|
+
toggleCaptions: () => toggleCaptions,
|
|
161
|
+
toggleFindingMode: () => toggleFindingMode,
|
|
162
|
+
toggleReduceLoudSounds: () => toggleReduceLoudSounds,
|
|
163
|
+
toggleSystemAppearance: () => toggleSystemAppearance,
|
|
164
|
+
touchEvent: () => touchEvent,
|
|
165
|
+
touchStart: () => touchStart,
|
|
166
|
+
touchStop: () => touchStop,
|
|
167
|
+
tvProviderRequest: () => tvProviderRequest,
|
|
168
|
+
tvrcSessionStart: () => tvrcSessionStart,
|
|
169
|
+
tvrcSessionStop: () => tvrcSessionStop
|
|
170
|
+
});
|
|
171
|
+
/** A request; the device replies. */
|
|
172
|
+
function request(identifier, content = {}) {
|
|
173
|
+
return {
|
|
174
|
+
_i: identifier,
|
|
175
|
+
_t: MessageType.Request,
|
|
176
|
+
_c: content
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** A request with Bluetooth high priority off (`_btHP: false`), for messages that should not cause a Bluetooth wake. */
|
|
180
|
+
function requestBtHP(identifier, content = {}) {
|
|
181
|
+
return {
|
|
182
|
+
_i: identifier,
|
|
183
|
+
_t: MessageType.Request,
|
|
184
|
+
_btHP: false,
|
|
185
|
+
_c: content
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** An event; the device sends no reply. */
|
|
189
|
+
function event(identifier, content = {}) {
|
|
190
|
+
return {
|
|
191
|
+
_i: identifier,
|
|
192
|
+
_t: MessageType.Event,
|
|
193
|
+
_c: content
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The `_systemInfo` request of the startup: the identity, model and services of this controller. */
|
|
197
|
+
function systemInfo(pairingId, name, model = "iPhone16,2", sourceVersion = "715.2") {
|
|
198
|
+
return requestBtHP("_systemInfo", {
|
|
199
|
+
_bf: 0,
|
|
200
|
+
_cf: 512,
|
|
201
|
+
_clFl: 128,
|
|
202
|
+
_i: "b561af32aea6",
|
|
203
|
+
_idsID: pairingId.toString(),
|
|
204
|
+
_pubID: "DA:6D:1E:D8:A0:4F",
|
|
205
|
+
_sf: 1099511628032,
|
|
206
|
+
_sv: sourceVersion,
|
|
207
|
+
model,
|
|
208
|
+
name,
|
|
209
|
+
_lP: 50402,
|
|
210
|
+
_dC: "1",
|
|
211
|
+
_stA: [
|
|
212
|
+
"com.apple.sharingd.AirDrop",
|
|
213
|
+
"SymptomNetworkDiagnostics",
|
|
214
|
+
"com.apple.photosface.network-service",
|
|
215
|
+
"com.apple.ApplicationService.chrono",
|
|
216
|
+
"com.apple.DDUI-Picker",
|
|
217
|
+
"com.apple.biomesyncd.cascade.rapport",
|
|
218
|
+
"com.apple.SeymourSession",
|
|
219
|
+
"com.apple.workflow.remotewidgets",
|
|
220
|
+
"SCD.MessageCenter.remoteIntelligence",
|
|
221
|
+
"DeviceSharingDaemonApplicationService",
|
|
222
|
+
"com.apple.biomesyncd.rapport",
|
|
223
|
+
"com.apple.devicediscoveryui.rapportwake",
|
|
224
|
+
"com.apple.healthd.rapport",
|
|
225
|
+
"com.apple.dropin.setup",
|
|
226
|
+
"com.apple.coreduet.sync",
|
|
227
|
+
"com.apple.siri.wakeup",
|
|
228
|
+
"com.apple.wifivelocityd.rapportWake",
|
|
229
|
+
"com.apple.Seymour",
|
|
230
|
+
"CPSRemoteLLM",
|
|
231
|
+
"com.apple.networkrelay.on-demand-setup",
|
|
232
|
+
"com.apple.home.messaging",
|
|
233
|
+
"com.apple.accessibility.axremoted.rapportWake",
|
|
234
|
+
"com.apple.continuitycapture.sideband",
|
|
235
|
+
"com.apple.announce",
|
|
236
|
+
"com.apple.coreidv.coreidvd.handoff"
|
|
237
|
+
]
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** Starts a session with the `com.apple.tvremoteservices` service under a session id of our own. */
|
|
241
|
+
function sessionStart(localSid) {
|
|
242
|
+
return {
|
|
243
|
+
_i: "_sessionStart",
|
|
244
|
+
_t: MessageType.Request,
|
|
245
|
+
_btHP: false,
|
|
246
|
+
_c: {
|
|
247
|
+
_srvT: "com.apple.tvremoteservices",
|
|
248
|
+
_sid: localSid,
|
|
249
|
+
_btHP: false
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function sessionStop(localSid) {
|
|
254
|
+
return request("_sessionStop", {
|
|
255
|
+
_srvT: "com.apple.tvremoteservices",
|
|
256
|
+
_sid: localSid
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** Starts a TV Remote Control session, which `tvremoted` handles on the Apple TV. */
|
|
260
|
+
function tvrcSessionStart() {
|
|
261
|
+
return {
|
|
262
|
+
_i: "TVRCSessionStart",
|
|
263
|
+
_t: MessageType.Request,
|
|
264
|
+
_btHP: false,
|
|
265
|
+
_inUseProc: "tvremoted",
|
|
266
|
+
_c: { ProtocolVersionKey: "1.2" }
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function tvrcSessionStop() {
|
|
270
|
+
return request("TVRCSessionStop");
|
|
271
|
+
}
|
|
272
|
+
/** One button edge of a {@link HidCommand}; `down` false sends the release. */
|
|
273
|
+
function hidCommand(commandId, down) {
|
|
274
|
+
return request("_hidC", {
|
|
275
|
+
_hBtS: down ? 1 : 2,
|
|
276
|
+
_hidC: commandId
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/** Starts the virtual touchpad; the size sets its coordinate space. */
|
|
280
|
+
function touchStart(width = 1e3, height = 1e3) {
|
|
281
|
+
return requestBtHP("_touchStart", {
|
|
282
|
+
_height: OPack.float(height),
|
|
283
|
+
_tFl: 0,
|
|
284
|
+
_width: OPack.float(width)
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function touchStop() {
|
|
288
|
+
return request("_touchStop", { _i: 1 });
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* A `_touchC` event, in the coordinate space of the touchpad.
|
|
292
|
+
*
|
|
293
|
+
* @param finger - The finger index, 0-based.
|
|
294
|
+
* @param phase - A {@link TouchPhase} value.
|
|
295
|
+
*/
|
|
296
|
+
function touchEvent(finger, phase, x, y) {
|
|
297
|
+
return event("_touchC", {
|
|
298
|
+
_tFg: finger,
|
|
299
|
+
_tPh: phase,
|
|
300
|
+
_tX: OPack.float(x),
|
|
301
|
+
_tY: OPack.float(y)
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A `_hidT` event, the form an Apple remote sends touchpad gestures in on the wire. Unlike
|
|
306
|
+
* {@link touchEvent}, the coordinates are integers and the event carries a timestamp.
|
|
307
|
+
*
|
|
308
|
+
* @param finger - The finger index, 1-based.
|
|
309
|
+
* @param phase - A {@link HidTouchPhase} value.
|
|
310
|
+
* @param timestamp - A monotonic timestamp in nanoseconds.
|
|
311
|
+
*/
|
|
312
|
+
function hidTouchEvent(finger, phase, x, y, timestamp = Number(process.hrtime.bigint())) {
|
|
313
|
+
return event("_hidT", {
|
|
314
|
+
_ns: OPack.sizedInteger(timestamp, 8),
|
|
315
|
+
_tFg: finger,
|
|
316
|
+
_cx: Math.round(x),
|
|
317
|
+
_tPh: phase,
|
|
318
|
+
_cy: Math.round(y)
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** Starts remote text input (RTI); the reply carries the keyboard session. */
|
|
322
|
+
function tiStart() {
|
|
323
|
+
return requestBtHP("_tiStart", { _tiV: 1 });
|
|
324
|
+
}
|
|
325
|
+
function tiStop() {
|
|
326
|
+
return requestBtHP("_tiStop");
|
|
327
|
+
}
|
|
328
|
+
/** A `_tiC` event with serialized RTITextOperations. */
|
|
329
|
+
function tiChange(tiD) {
|
|
330
|
+
return event("_tiC", {
|
|
331
|
+
_tiV: 1,
|
|
332
|
+
_tiD: tiD
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* A `_mcc` request.
|
|
337
|
+
*
|
|
338
|
+
* @param commandId - A {@link MediaControlCommand} value.
|
|
339
|
+
* @param content - Extra fields, such as the volume for `SetVolume`.
|
|
340
|
+
*/
|
|
341
|
+
function mediaControlCommand(commandId, content) {
|
|
342
|
+
return request("_mcc", {
|
|
343
|
+
_mcc: commandId,
|
|
344
|
+
...content ?? {}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** Skips forward, or backward for a negative number of seconds. */
|
|
348
|
+
function mediaSkipBySeconds(seconds) {
|
|
349
|
+
return request("_mcc", {
|
|
350
|
+
_mcc: MediaControlCommand.SkipBy,
|
|
351
|
+
_ski: seconds
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function mediaCaptionSettingGet() {
|
|
355
|
+
return request("_mcc", { _mcc: MediaControlCommand.GetCaptionSettings });
|
|
356
|
+
}
|
|
357
|
+
function mediaCaptionSettingSet(enabled) {
|
|
358
|
+
return request("_mcc", {
|
|
359
|
+
_mcc: MediaControlCommand.SetCaptionSettings,
|
|
360
|
+
_cse: enabled
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function launchApp(bundleId) {
|
|
364
|
+
return request("_launchApp", { _bundleID: bundleId });
|
|
365
|
+
}
|
|
366
|
+
/** Opens a URL; the device picks the app through universal links. */
|
|
367
|
+
function launchUrl(url) {
|
|
368
|
+
return request("_launchApp", { _urlS: url });
|
|
369
|
+
}
|
|
370
|
+
/** Asks for the attention (power) state. */
|
|
371
|
+
function fetchAttentionState() {
|
|
372
|
+
return request("FetchAttentionState");
|
|
373
|
+
}
|
|
374
|
+
function fetchLaunchableApps() {
|
|
375
|
+
return request("FetchLaunchableApplicationsEvent");
|
|
376
|
+
}
|
|
377
|
+
function fetchMediaControlStatus() {
|
|
378
|
+
return request("FetchMediaControlStatus");
|
|
379
|
+
}
|
|
380
|
+
function fetchNowPlayingInfo() {
|
|
381
|
+
return request("FetchCurrentNowPlayingInfoEvent");
|
|
382
|
+
}
|
|
383
|
+
function fetchSiriRemoteInfo() {
|
|
384
|
+
return request("FetchSiriRemoteInfo");
|
|
385
|
+
}
|
|
386
|
+
function fetchSupportedActions() {
|
|
387
|
+
return request("FetchSupportedActionsEvent");
|
|
388
|
+
}
|
|
389
|
+
function fetchCurrentTopShelfItems() {
|
|
390
|
+
return requestBtHP("FetchCurrentTopShelfItemsEvent");
|
|
391
|
+
}
|
|
392
|
+
function fetchUserAccounts() {
|
|
393
|
+
return request("FetchUserAccountsEvent");
|
|
394
|
+
}
|
|
395
|
+
function switchUserAccount(accountId) {
|
|
396
|
+
return request("SwitchUserAccountEvent", { SwitchAccountID: accountId });
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Tells the Apple TV which iCloud account (alternate DSID) this controller is active on. An iOS
|
|
400
|
+
* remote sends it during the startup.
|
|
401
|
+
*/
|
|
402
|
+
function switchActiveUserAccount(iCloudAltDSID) {
|
|
403
|
+
return requestBtHP("SwitchActiveUserAccountEvent", { iCloudAltDSID });
|
|
404
|
+
}
|
|
405
|
+
/** Registers interest in events the device pushes, such as `_iMC` or `SystemStatus`. */
|
|
406
|
+
function registerInterests(events) {
|
|
407
|
+
return event("_interest", { _regEvents: events });
|
|
408
|
+
}
|
|
409
|
+
function deregisterInterests(events) {
|
|
410
|
+
return event("_interest", { _deregEvents: events });
|
|
411
|
+
}
|
|
412
|
+
function toggleCaptions() {
|
|
413
|
+
return request("ToggleCaptions");
|
|
414
|
+
}
|
|
415
|
+
/** Light appearance when `light` is true, dark when false. */
|
|
416
|
+
function toggleSystemAppearance(light) {
|
|
417
|
+
return request("ToggleSystemAppearance", { SystemAppearanceLight: light });
|
|
418
|
+
}
|
|
419
|
+
function toggleReduceLoudSounds(enabled) {
|
|
420
|
+
return request("ToggleReduceLoudSounds", { ReduceLoundSoundsEnabled: enabled });
|
|
421
|
+
}
|
|
422
|
+
/** Turns the Find My finding mode on or off. */
|
|
423
|
+
function toggleFindingMode(enabled) {
|
|
424
|
+
return request("ToggleFindingMode", { FindingModeEnabledKey: enabled });
|
|
425
|
+
}
|
|
426
|
+
/** @param paginationToken - Fetches the page after the one that returned it. */
|
|
427
|
+
function fetchUpNext(paginationToken) {
|
|
428
|
+
return request("FetchUpNextInfoEvent", paginationToken ? { PaginationTokenKey: paginationToken } : {});
|
|
429
|
+
}
|
|
430
|
+
/** @param kind - The content kind, such as movie or episode. */
|
|
431
|
+
function addToUpNext(identifier, kind) {
|
|
432
|
+
return request("AddToUpNextEvent", {
|
|
433
|
+
IdentifierKey: identifier,
|
|
434
|
+
KindKey: kind
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function removeFromUpNext(identifier, kind) {
|
|
438
|
+
return request("RemoveFromUpNextEvent", {
|
|
439
|
+
IdentifierKey: identifier,
|
|
440
|
+
KindKey: kind
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
function markAsWatched(identifier, kind) {
|
|
444
|
+
return request("MarkAsWatchedEvent", {
|
|
445
|
+
IdentifierKey: identifier,
|
|
446
|
+
KindKey: kind
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function playMedia(item) {
|
|
450
|
+
return request("PlayMediaEvent", item);
|
|
451
|
+
}
|
|
452
|
+
/** Starts Siri push-to-talk. */
|
|
453
|
+
function siriStart() {
|
|
454
|
+
return request("_siriStart");
|
|
455
|
+
}
|
|
456
|
+
/** Ends Siri push-to-talk. */
|
|
457
|
+
function siriStop() {
|
|
458
|
+
return request("_siriStop");
|
|
459
|
+
}
|
|
460
|
+
/** One stick position (-1 to 1 per axis) and the button state, as an event without a reply. */
|
|
461
|
+
function gameControllerEvent(x, y, isDown) {
|
|
462
|
+
return event("_gcC", {
|
|
463
|
+
_gcX: x,
|
|
464
|
+
_gcY: y,
|
|
465
|
+
_gcBtS: isDown ? 1 : 0
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
function gameControllerStart() {
|
|
469
|
+
return requestBtHP("_gcStart");
|
|
470
|
+
}
|
|
471
|
+
function gameControllerStop() {
|
|
472
|
+
return requestBtHP("_gcStop");
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Runs the sign-in of an Apple TV app through this controller, as a paired iPhone or iPad does.
|
|
476
|
+
*
|
|
477
|
+
* @param requestType - The kind of sign-in: `appleID`, `password` or `custom`.
|
|
478
|
+
*/
|
|
479
|
+
function appSignInRequest(bundleId, requestType = "appleID") {
|
|
480
|
+
return request("_cpsAISR", {
|
|
481
|
+
_bundleID: bundleId,
|
|
482
|
+
_reqType: requestType
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/** A TV provider (MVPD) sign-in request. */
|
|
486
|
+
function tvProviderRequest(providerUrl, providerName) {
|
|
487
|
+
return request("_cpsTVPR", {
|
|
488
|
+
_pvUrl: providerUrl,
|
|
489
|
+
_pvName: providerName
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
/** A restricted access (parental controls) approval request. */
|
|
493
|
+
function restrictedAccessRequest(restrictionType) {
|
|
494
|
+
return request("_cpsRAR", { _raType: restrictionType });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region src/accounts.ts
|
|
499
|
+
/** User accounts on the device, and the experimental sign-in, TV provider and parental approval requests. */
|
|
500
|
+
var Accounts = class {
|
|
501
|
+
#channel;
|
|
502
|
+
constructor(channel) {
|
|
503
|
+
this.#channel = channel;
|
|
504
|
+
}
|
|
505
|
+
async list() {
|
|
506
|
+
const reply = await this.#channel.exchange(fetchUserAccounts());
|
|
507
|
+
return Object.entries(reply._c).map(([accountId, name]) => ({
|
|
508
|
+
accountId,
|
|
509
|
+
name
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
async switch(accountId) {
|
|
513
|
+
await this.#channel.exchange(switchUserAccount(accountId));
|
|
514
|
+
}
|
|
515
|
+
/** Tells the device which iCloud account (alternate DSID) this controller is active on. */
|
|
516
|
+
async switchActive(iCloudAltDSID) {
|
|
517
|
+
await this.#channel.exchange(switchActiveUserAccount(iCloudAltDSID));
|
|
518
|
+
}
|
|
519
|
+
/** @param requestType The kind of sign-in: `appleID`, `password` or `custom`. */
|
|
520
|
+
async requestAppSignIn(bundleId, requestType = "appleID") {
|
|
521
|
+
return await this.#channel.exchange(appSignInRequest(bundleId, requestType));
|
|
522
|
+
}
|
|
523
|
+
async requestTVProvider(providerUrl, providerName) {
|
|
524
|
+
return await this.#channel.exchange(tvProviderRequest(providerUrl, providerName));
|
|
525
|
+
}
|
|
526
|
+
async requestRestrictedAccess(restrictionType) {
|
|
527
|
+
return await this.#channel.exchange(restrictedAccessRequest(restrictionType));
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/apps.ts
|
|
533
|
+
/** The apps on the device: listing, launching, opening a URL and the top shelf. */
|
|
534
|
+
var Apps = class {
|
|
535
|
+
#channel;
|
|
536
|
+
constructor(channel) {
|
|
537
|
+
this.#channel = channel;
|
|
538
|
+
}
|
|
539
|
+
async list() {
|
|
540
|
+
const reply = await this.#channel.exchange(fetchLaunchableApps());
|
|
541
|
+
return Object.entries(reply._c).map(([bundleId, name]) => ({
|
|
542
|
+
bundleId,
|
|
543
|
+
name
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
async launch(bundleId) {
|
|
547
|
+
await this.#channel.exchange(launchApp(bundleId));
|
|
548
|
+
}
|
|
549
|
+
/** Opens a URL; the device picks the app through universal links or the URL scheme. */
|
|
550
|
+
async openUrl(url) {
|
|
551
|
+
await this.#channel.exchange(launchUrl(url));
|
|
552
|
+
}
|
|
553
|
+
/** The top shelf items the device shows now, as the raw reply. */
|
|
554
|
+
async topShelf() {
|
|
555
|
+
return await this.#channel.exchange(fetchCurrentTopShelfItems());
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/channel.ts
|
|
561
|
+
/**
|
|
562
|
+
* What the groups of a session send with: encrypted requests that check the reply, events without
|
|
563
|
+
* one, and the events the device sends. Listeners go when the session signal aborts.
|
|
564
|
+
*/
|
|
565
|
+
var Channel = class {
|
|
566
|
+
/** Aborts when the session ends. */
|
|
567
|
+
get signal() {
|
|
568
|
+
return this.#signal;
|
|
569
|
+
}
|
|
570
|
+
/** Whether the socket can still carry a frame; false while the session closes. */
|
|
571
|
+
get isConnected() {
|
|
572
|
+
return this.#stream.isConnected;
|
|
573
|
+
}
|
|
574
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
575
|
+
#signal;
|
|
576
|
+
#stream;
|
|
577
|
+
constructor(stream, signal) {
|
|
578
|
+
this.#signal = signal;
|
|
579
|
+
this.#stream = stream;
|
|
580
|
+
if (signal.aborted) return;
|
|
581
|
+
const onEvent = (name, content) => this.#dispatch(name, content);
|
|
582
|
+
stream.on("event", onEvent);
|
|
583
|
+
signal.addEventListener("abort", () => {
|
|
584
|
+
stream.off("event", onEvent);
|
|
585
|
+
this.#listeners.clear();
|
|
586
|
+
}, { once: true });
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Sends a request and resolves with its reply.
|
|
590
|
+
*
|
|
591
|
+
* @throws {CommandError} When the reply carries `_em` or a non-zero `_ec`.
|
|
592
|
+
* @throws {TimeoutError} When no reply arrives in time.
|
|
593
|
+
* @throws {ConnectionClosedError} When the session ends first.
|
|
594
|
+
*/
|
|
595
|
+
async exchange(message) {
|
|
596
|
+
const [, response] = await this.#stream.exchange(FrameType.OPackEncrypted, message);
|
|
597
|
+
const reply = objectOrFail(response);
|
|
598
|
+
if (reply._em || reply._ec !== void 0 && Number(reply._ec) !== 0) {
|
|
599
|
+
const details = [reply._ed, reply._ec === void 0 ? void 0 : Number(reply._ec)].filter((value) => value !== void 0).join(" ");
|
|
600
|
+
throw new CommandError(`${message._i}: ${reply._em || "Command failed"}${details ? ` (${details})` : ""}`);
|
|
601
|
+
}
|
|
602
|
+
return reply;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Sends an event, which gets no reply.
|
|
606
|
+
*
|
|
607
|
+
* @throws {ConnectionClosedError} When the session has ended.
|
|
608
|
+
*/
|
|
609
|
+
send(message) {
|
|
610
|
+
this.#stream.sendOPack(FrameType.OPackEncrypted, message);
|
|
611
|
+
}
|
|
612
|
+
/** Calls the listener with the `_c` of every event with this name, until the session ends. */
|
|
613
|
+
listen(name, listener) {
|
|
614
|
+
if (this.#signal.aborted) return;
|
|
615
|
+
let listeners = this.#listeners.get(name);
|
|
616
|
+
if (!listeners) {
|
|
617
|
+
listeners = /* @__PURE__ */ new Set();
|
|
618
|
+
this.#listeners.set(name, listeners);
|
|
619
|
+
}
|
|
620
|
+
listeners.add(listener);
|
|
621
|
+
}
|
|
622
|
+
unlisten(name, listener) {
|
|
623
|
+
this.#listeners.get(name)?.delete(listener);
|
|
624
|
+
}
|
|
625
|
+
#dispatch(name, content) {
|
|
626
|
+
for (const listener of [...this.#listeners.get(name) ?? []]) listener(content);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
/** @throws {TypeError} When the value is not an object. */
|
|
630
|
+
function objectOrFail(value) {
|
|
631
|
+
if (value !== null && typeof value === "object") return value;
|
|
632
|
+
throw new TypeError("Expected an object.");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/hid.ts
|
|
637
|
+
/** Remote buttons as HID commands, and the experimental game controller. */
|
|
638
|
+
var Hid = class {
|
|
639
|
+
#channel;
|
|
640
|
+
constructor(channel) {
|
|
641
|
+
this.#channel = channel;
|
|
642
|
+
}
|
|
643
|
+
/** Sends one button edge: down, or up when `down` is false. */
|
|
644
|
+
async command(command, down = false) {
|
|
645
|
+
await this.#channel.exchange(hidCommand(HidCommand[command], down));
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* A full press: down and up for a single tap, that twice without a pause for a double tap,
|
|
649
|
+
* and for a hold the release after `holdDelayMs`.
|
|
650
|
+
*/
|
|
651
|
+
async press(command, type = "SingleTap", holdDelayMs = 500) {
|
|
652
|
+
switch (type) {
|
|
653
|
+
case "DoubleTap":
|
|
654
|
+
await this.command(command, true);
|
|
655
|
+
await this.command(command, false);
|
|
656
|
+
await this.command(command, true);
|
|
657
|
+
await this.command(command, false);
|
|
658
|
+
break;
|
|
659
|
+
case "Hold":
|
|
660
|
+
await this.command(command, true);
|
|
661
|
+
await waitFor(holdDelayMs);
|
|
662
|
+
await this.command(command, false);
|
|
663
|
+
break;
|
|
664
|
+
case "SingleTap":
|
|
665
|
+
await this.command(command, true);
|
|
666
|
+
await this.command(command, false);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async gameControllerStart() {
|
|
671
|
+
await this.#channel.exchange(gameControllerStart());
|
|
672
|
+
}
|
|
673
|
+
async gameControllerStop() {
|
|
674
|
+
await this.#channel.exchange(gameControllerStop());
|
|
675
|
+
}
|
|
676
|
+
/** One stick position (-1 to 1 per axis) and the button state. */
|
|
677
|
+
sendGameControllerEvent(x, y, isDown) {
|
|
678
|
+
this.#channel.send(gameControllerEvent(x, y, isDown));
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/interests.ts
|
|
684
|
+
/** The events the device pushes once interest in them is registered, such as `_iMC` or `SystemStatus`. */
|
|
685
|
+
var Interests = class {
|
|
686
|
+
#channel;
|
|
687
|
+
constructor(channel) {
|
|
688
|
+
this.#channel = channel;
|
|
689
|
+
}
|
|
690
|
+
/** Registers interest in the events, in one `_interest` event. */
|
|
691
|
+
register(events) {
|
|
692
|
+
this.#channel.send(registerInterests(events));
|
|
693
|
+
}
|
|
694
|
+
/** Deregisters interest in the events; nothing when the socket is gone, so a teardown cannot fail on it. */
|
|
695
|
+
deregister(events) {
|
|
696
|
+
if (!this.#channel.isConnected) return;
|
|
697
|
+
this.#channel.send(deregisterInterests(events));
|
|
698
|
+
}
|
|
699
|
+
/** Listens to an event, until the session ends, and registers interest in it. */
|
|
700
|
+
subscribe(event, listener) {
|
|
701
|
+
this.#channel.listen(event, listener);
|
|
702
|
+
this.register([event]);
|
|
703
|
+
}
|
|
704
|
+
/** Stops listening, when a listener is given, and deregisters interest in the event. */
|
|
705
|
+
unsubscribe(event, listener) {
|
|
706
|
+
if (listener) this.#channel.unlisten(event, listener);
|
|
707
|
+
this.deregister([event]);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
//#endregion
|
|
712
|
+
//#region src/media.ts
|
|
713
|
+
/**
|
|
714
|
+
* Media control over `_mcc`, what the device reports about playback, and playing a media item.
|
|
715
|
+
* The flags follow the `_iMC` events; the state reports a change.
|
|
716
|
+
*/
|
|
717
|
+
var Media = class {
|
|
718
|
+
get capabilities() {
|
|
719
|
+
return this.#state.mediaCapabilities;
|
|
720
|
+
}
|
|
721
|
+
/** The raw `_iMC` bitmask, see `MediaControlFlag`. */
|
|
722
|
+
get flags() {
|
|
723
|
+
return this.#state.mediaControlFlags;
|
|
724
|
+
}
|
|
725
|
+
get nowPlayingInfo() {
|
|
726
|
+
return this.#state.nowPlayingInfo;
|
|
727
|
+
}
|
|
728
|
+
get supportedActions() {
|
|
729
|
+
return this.#state.supportedActions;
|
|
730
|
+
}
|
|
731
|
+
get volumeAvailable() {
|
|
732
|
+
return this.#state.volumeAvailable;
|
|
733
|
+
}
|
|
734
|
+
#channel;
|
|
735
|
+
#state;
|
|
736
|
+
constructor(channel, state) {
|
|
737
|
+
this.#channel = channel;
|
|
738
|
+
this.#state = state;
|
|
739
|
+
}
|
|
740
|
+
/** @param content Extra fields of the command, such as the volume for `SetVolume`. */
|
|
741
|
+
async command(command, content) {
|
|
742
|
+
return await this.#channel.exchange(mediaControlCommand(MediaControlCommand[command], content));
|
|
743
|
+
}
|
|
744
|
+
/** Skips forward, or backward for a negative number of seconds. */
|
|
745
|
+
async skipBy(seconds) {
|
|
746
|
+
return await this.#channel.exchange(mediaSkipBySeconds(seconds));
|
|
747
|
+
}
|
|
748
|
+
/** Asks for the media control status; the reply is not used. */
|
|
749
|
+
async fetchControlStatus() {
|
|
750
|
+
await this.#channel.exchange(fetchMediaControlStatus());
|
|
751
|
+
}
|
|
752
|
+
/** The now playing information, as the raw reply. */
|
|
753
|
+
async fetchNowPlayingInfo() {
|
|
754
|
+
return await this.#channel.exchange(fetchNowPlayingInfo());
|
|
755
|
+
}
|
|
756
|
+
/** Asks for the supported actions; the reply is not used. */
|
|
757
|
+
async fetchSupportedActions() {
|
|
758
|
+
await this.#channel.exchange(fetchSupportedActions());
|
|
759
|
+
}
|
|
760
|
+
/** Plays a media item, described by the OPack dictionary the device expects. */
|
|
761
|
+
async play(item) {
|
|
762
|
+
await this.#channel.exchange(playMedia(item));
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/stream.ts
|
|
768
|
+
/** A 1-byte frame type and a 3-byte big-endian payload length. */
|
|
769
|
+
const HEADER_SIZE = 4;
|
|
770
|
+
/** A peer that sends more than this without completing a frame is broken. */
|
|
771
|
+
const MAX_BUFFER_SIZE = 1024 * 1024;
|
|
772
|
+
/** Pairing replies carry no `_x`, so the one pending pairing exchange waits under this key. */
|
|
773
|
+
const PAIRING_QUEUE_IDENTIFIER = -1;
|
|
774
|
+
const DEFAULT_EXCHANGE_TIMEOUT = 1e4;
|
|
775
|
+
const MESSAGE_TYPE_NAMES = {
|
|
776
|
+
[MessageType.Event]: "event",
|
|
777
|
+
[MessageType.Request]: "request",
|
|
778
|
+
[MessageType.Response]: "response"
|
|
779
|
+
};
|
|
780
|
+
/**
|
|
781
|
+
* The Companion Link socket: OPack frames of `[type:1][length:3][payload][tag:16?]`, the header
|
|
782
|
+
* being the AAD once encryption is on. The nonce is the 64-bit little-endian message counter at
|
|
783
|
+
* offset 0 of 12 bytes. {@link exchange} waits for the reply with the same `_x`; every other frame
|
|
784
|
+
* with an `_i` becomes an `event`.
|
|
785
|
+
*/
|
|
786
|
+
var Stream = class extends EncryptionAwareConnection {
|
|
787
|
+
#pending = /* @__PURE__ */ new Map();
|
|
788
|
+
#buffer = Buffer.alloc(0);
|
|
789
|
+
#xid = randomInt(0, 2 ** 16);
|
|
790
|
+
constructor(context, address, port) {
|
|
791
|
+
super(context, address, port);
|
|
792
|
+
const connection = this;
|
|
793
|
+
connection.on("closed", () => this.#onClosed());
|
|
794
|
+
connection.on("data", (data) => this.#onData(data));
|
|
795
|
+
connection.on("error", (error) => this.#rejectAll(error));
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Sends an OPack frame and resolves with the decoded reply. Pairing frames share one slot,
|
|
799
|
+
* because their replies carry no `_x`.
|
|
800
|
+
*
|
|
801
|
+
* @throws {TimeoutError} When no reply arrives in time.
|
|
802
|
+
* @throws {ConnectionClosedError} When the stream ends first.
|
|
803
|
+
*/
|
|
804
|
+
exchange(type, message, timeout = DEFAULT_EXCHANGE_TIMEOUT) {
|
|
805
|
+
if (this.signal.aborted) return Promise.reject(new ConnectionClosedError("Stream cleanup."));
|
|
806
|
+
const key = PairingFrameTypes.includes(type) ? PAIRING_QUEUE_IDENTIFIER : message._x !== void 0 && message._x !== null ? Number(message._x) : this.#xid;
|
|
807
|
+
return new Promise((resolve, reject) => {
|
|
808
|
+
const timer = setTimeout(() => {
|
|
809
|
+
this.#pending.delete(key);
|
|
810
|
+
reject(new TimeoutError(`Exchange timed out for type ${type}`));
|
|
811
|
+
}, timeout);
|
|
812
|
+
this.#pending.set(key, {
|
|
813
|
+
resolve: (frame) => {
|
|
814
|
+
clearTimeout(timer);
|
|
815
|
+
resolve(frame);
|
|
816
|
+
},
|
|
817
|
+
reject: (error) => {
|
|
818
|
+
clearTimeout(timer);
|
|
819
|
+
reject(error);
|
|
820
|
+
},
|
|
821
|
+
timer
|
|
822
|
+
});
|
|
823
|
+
try {
|
|
824
|
+
this.sendOPack(type, message);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
this.#pending.delete(key);
|
|
827
|
+
clearTimeout(timer);
|
|
828
|
+
reject(err);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Encodes an object as OPack and sends it, with the next `_x` unless it carries one.
|
|
834
|
+
*
|
|
835
|
+
* @throws {ConnectionClosedError} When the stream has ended.
|
|
836
|
+
*/
|
|
837
|
+
sendOPack(type, message) {
|
|
838
|
+
if (this.signal.aborted) throw new ConnectionClosedError("Cannot write to a disconnected connection.");
|
|
839
|
+
const xid = this.#xid++;
|
|
840
|
+
message._x ??= OPack.sizedInteger(xid, 8);
|
|
841
|
+
this.context.logger.raw("[companion-link]", "Sending opack frame", type, this.isEncrypted, message);
|
|
842
|
+
if (reporter.tapsTraffic) this.context.logger.traffic("companionLink", "out", summarize(type, message), message);
|
|
843
|
+
this.send(type, Buffer.from(OPack.encode(message)));
|
|
844
|
+
}
|
|
845
|
+
/** Writes one frame; encrypted once encryption is on, except NoOp, which stays plaintext. */
|
|
846
|
+
send(type, payload) {
|
|
847
|
+
const encryption = type === FrameType.NoOp ? void 0 : this.encryption;
|
|
848
|
+
const header = Buffer.allocUnsafe(HEADER_SIZE);
|
|
849
|
+
header.writeUint8(type, 0);
|
|
850
|
+
header.writeUintBE(payload.byteLength + (encryption ? ChaCha20.CHACHA20_AUTH_TAG_LENGTH : 0), 1, 3);
|
|
851
|
+
this.context.logger.raw("[companion-link]", "Sending data frame", this.isEncrypted, type);
|
|
852
|
+
if (!encryption) {
|
|
853
|
+
this.write(Buffer.concat([header, payload]));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const nonce = buildNonce(encryption.writeCount++, "counter-at-0");
|
|
857
|
+
const encrypted = ChaCha20.encrypt(encryption.writeKey, nonce, header, payload);
|
|
858
|
+
this.write(Buffer.concat([
|
|
859
|
+
header,
|
|
860
|
+
encrypted.ciphertext,
|
|
861
|
+
encrypted.authTag
|
|
862
|
+
]));
|
|
863
|
+
}
|
|
864
|
+
#decrypt(frame) {
|
|
865
|
+
const header = frame.subarray(0, HEADER_SIZE);
|
|
866
|
+
const payloadLength = header.readUintBE(1, 3);
|
|
867
|
+
if (payloadLength < ChaCha20.CHACHA20_AUTH_TAG_LENGTH) throw new Error(`Encrypted frame payload too short: ${payloadLength} bytes (minimum 16 for auth tag)`);
|
|
868
|
+
const payload = frame.subarray(HEADER_SIZE, HEADER_SIZE + payloadLength);
|
|
869
|
+
const authTag = payload.subarray(payload.byteLength - ChaCha20.CHACHA20_AUTH_TAG_LENGTH);
|
|
870
|
+
const ciphertext = payload.subarray(0, payload.byteLength - ChaCha20.CHACHA20_AUTH_TAG_LENGTH);
|
|
871
|
+
const nonce = buildNonce(this.encryption.readCount++, "counter-at-0");
|
|
872
|
+
return Buffer.concat([header, ChaCha20.decrypt(this.encryption.readKey, nonce, header, ciphertext, authTag)]);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* A reply settles its exchange, a pairing frame the pairing slot, anything else with an `_i`
|
|
876
|
+
* becomes an event.
|
|
877
|
+
*/
|
|
878
|
+
#handle(header, payload) {
|
|
879
|
+
const type = header.readUint8();
|
|
880
|
+
if (!OPackFrameTypes.includes(type)) {
|
|
881
|
+
this.context.logger.warn("[companion-link]", "Packet not handled, no opack frame.");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const message = OPack.decode(payload);
|
|
885
|
+
this.context.logger.raw("[companion-link]", "Decoded OPACK", {
|
|
886
|
+
header,
|
|
887
|
+
payload: message
|
|
888
|
+
});
|
|
889
|
+
if (reporter.tapsTraffic) this.context.logger.traffic("companionLink", "in", summarize(type, message), message);
|
|
890
|
+
if ("_x" in message && message._t === MessageType.Response && this.#settle(Number(message._x), header, message)) return;
|
|
891
|
+
if (PairingFrameTypes.includes(type) && this.#settle(PAIRING_QUEUE_IDENTIFIER, header, message)) return;
|
|
892
|
+
if (!("_i" in message)) {
|
|
893
|
+
this.context.logger.warn("[companion-link]", "Unhandled message", message);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
this.emit("event", message._i, message._c);
|
|
897
|
+
}
|
|
898
|
+
#onClosed() {
|
|
899
|
+
this.#buffer = Buffer.alloc(0);
|
|
900
|
+
this.#rejectAll(new ConnectionClosedError("Stream cleanup."));
|
|
901
|
+
}
|
|
902
|
+
#onData(data) {
|
|
903
|
+
this.#buffer = Buffer.concat([this.#buffer, data]);
|
|
904
|
+
if (this.#buffer.byteLength > MAX_BUFFER_SIZE) {
|
|
905
|
+
this.context.logger.error("[companion-link]", `Buffer exceeded max size (${this.#buffer.byteLength} bytes), closing the connection.`);
|
|
906
|
+
this.#buffer = Buffer.alloc(0);
|
|
907
|
+
this.destroy(/* @__PURE__ */ new Error("Buffer overflow: exceeded maximum buffer size"));
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
while (this.#buffer.byteLength >= HEADER_SIZE) {
|
|
912
|
+
const header = this.#buffer.subarray(0, HEADER_SIZE);
|
|
913
|
+
const totalLength = HEADER_SIZE + header.readUintBE(1, 3);
|
|
914
|
+
if (this.#buffer.byteLength < totalLength) {
|
|
915
|
+
this.context.logger.debug("[companion-link]", `Data packet is too short needed=${totalLength} available=${this.#buffer.byteLength} receivedLength=${data.byteLength}`);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
this.context.logger.raw("[companion-link]", `Received frame length=${totalLength} availableLength=${this.#buffer.byteLength} receivedLength=${data.byteLength}`);
|
|
919
|
+
let frame = Buffer.from(this.#buffer.subarray(0, totalLength));
|
|
920
|
+
this.#buffer = this.#buffer.subarray(totalLength);
|
|
921
|
+
this.context.logger.raw("[companion-link]", `Handle frame, ${this.#buffer.byteLength} bytes left...`);
|
|
922
|
+
if (this.isEncrypted) frame = this.#decrypt(frame);
|
|
923
|
+
this.#handle(header, frame.subarray(HEADER_SIZE));
|
|
924
|
+
}
|
|
925
|
+
} catch (err) {
|
|
926
|
+
this.context.logger.error("[companion-link]", "onStreamData()", err);
|
|
927
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
#rejectAll(error) {
|
|
931
|
+
for (const { reject } of this.#pending.values()) reject(error);
|
|
932
|
+
this.#pending.clear();
|
|
933
|
+
}
|
|
934
|
+
#settle(key, header, message) {
|
|
935
|
+
const pending = this.#pending.get(key);
|
|
936
|
+
if (!pending) return false;
|
|
937
|
+
this.#pending.delete(key);
|
|
938
|
+
pending.resolve([header.readUint8(), message]);
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
function summarize(frameType, message) {
|
|
943
|
+
return [
|
|
944
|
+
Object.keys(FrameType).find((name) => FrameType[name] === frameType) ?? `frame=${frameType}`,
|
|
945
|
+
MESSAGE_TYPE_NAMES[Number(message._t)],
|
|
946
|
+
message._i,
|
|
947
|
+
"_x" in message ? `x=${Number(message._x)}` : void 0
|
|
948
|
+
].filter((part) => part !== void 0).join(" ");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
//#endregion
|
|
952
|
+
//#region src/pairing.ts
|
|
953
|
+
const PAIRING_TIMEOUT = 3e4;
|
|
954
|
+
/**
|
|
955
|
+
* Sends each pairing message as `_pd` in an OPack frame: a start frame for M1, a next frame after
|
|
956
|
+
* that. `extra` carries the field that tells pair-setup and pair-verify apart.
|
|
957
|
+
*/
|
|
958
|
+
function frameTransport(stream, startFrame, nextFrame, extra) {
|
|
959
|
+
return async (payload, step) => {
|
|
960
|
+
const [, response] = await stream.exchange(step === "m1" ? startFrame : nextFrame, {
|
|
961
|
+
_pd: payload,
|
|
962
|
+
...extra
|
|
963
|
+
}, PAIRING_TIMEOUT);
|
|
964
|
+
if (typeof response !== "object" || response === null) throw new InvalidResponseError("Invalid response from receiver.");
|
|
965
|
+
return response["_pd"];
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Pair-verify over OPack frames, followed by the channel keys. The HKDF info strings are named
|
|
970
|
+
* from the device's side: it encrypts with `ServerEncrypt-main`, our read key.
|
|
971
|
+
*/
|
|
972
|
+
async function verifyPairing(stream, context, credentials) {
|
|
973
|
+
const keys = await new PairVerify(context, frameTransport(stream, FrameType.PairVerifyStart, FrameType.PairVerifyNext, { _auTy: 4 })).start(credentials);
|
|
974
|
+
const { readKey: accessoryToControllerKey, writeKey: controllerToAccessoryKey } = HKDF.deriveEncryptionKeys(keys.sharedSecret, "", "ServerEncrypt-main", "ClientEncrypt-main");
|
|
975
|
+
return {
|
|
976
|
+
accessoryToControllerKey,
|
|
977
|
+
controllerToAccessoryKey,
|
|
978
|
+
pairingId: keys.pairingId,
|
|
979
|
+
sharedSecret: keys.sharedSecret
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Starts a PIN pairing: connects and sends pair-setup M1, upon which the device shows its PIN.
|
|
984
|
+
* Finish with {@link PinPairing.pin}, which returns the long-term credentials.
|
|
985
|
+
*
|
|
986
|
+
* @throws {PairingError} When the device refuses to start pairing.
|
|
987
|
+
* @throws {ConnectionClosedError} When the signal aborts first.
|
|
988
|
+
*/
|
|
989
|
+
async function pair(target, options = {}) {
|
|
990
|
+
const context = options.context ?? new Context(target.id ?? target.address, options.identity);
|
|
991
|
+
const stream = new Stream(context, target.address, target.port);
|
|
992
|
+
const interrupt = () => stream.destroy();
|
|
993
|
+
options.signal?.addEventListener("abort", interrupt, {
|
|
994
|
+
once: true,
|
|
995
|
+
signal: stream.signal
|
|
996
|
+
});
|
|
997
|
+
try {
|
|
998
|
+
await stream.connect();
|
|
999
|
+
const setup = new PairSetup(context, frameTransport(stream, FrameType.PairSetupStart, FrameType.PairSetupNext, { _pwTy: 1 }));
|
|
1000
|
+
await setup.start();
|
|
1001
|
+
return new PinPairing(stream, setup, await setup.m1());
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
stream.destroy();
|
|
1004
|
+
throw options.signal?.aborted ? new ConnectionClosedError("Pairing was interrupted.") : err;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/** A PIN pairing whose PIN the device shows now. One connection, used once. */
|
|
1008
|
+
var PinPairing = class {
|
|
1009
|
+
/** Aborts when the pairing connection closes. */
|
|
1010
|
+
get signal() {
|
|
1011
|
+
return this.#stream.signal;
|
|
1012
|
+
}
|
|
1013
|
+
#m1;
|
|
1014
|
+
#setup;
|
|
1015
|
+
#stream;
|
|
1016
|
+
constructor(stream, setup, m1) {
|
|
1017
|
+
this.#m1 = m1;
|
|
1018
|
+
this.#setup = setup;
|
|
1019
|
+
this.#stream = stream;
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Runs pair-setup M2 to M6 with the PIN from the screen. The connection stays open; call
|
|
1023
|
+
* {@link close} when done.
|
|
1024
|
+
*
|
|
1025
|
+
* @throws {PairingError} When a step fails or the PIN is wrong.
|
|
1026
|
+
*/
|
|
1027
|
+
async pin(code) {
|
|
1028
|
+
const m2 = await this.#setup.m2(this.#m1, code);
|
|
1029
|
+
const m3 = await this.#setup.m3(m2);
|
|
1030
|
+
const m4 = await this.#setup.m4(m3);
|
|
1031
|
+
const m5 = await this.#setup.m5(m4);
|
|
1032
|
+
const credentials = await this.#setup.m6(m4, m5);
|
|
1033
|
+
if (!credentials) throw new PairingError("Pairing failed, could not get accessory keys.");
|
|
1034
|
+
return credentials;
|
|
1035
|
+
}
|
|
1036
|
+
/** Closes the pairing connection. */
|
|
1037
|
+
close() {
|
|
1038
|
+
this.#stream.destroy();
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/utils.ts
|
|
1044
|
+
/** Maps the numeric attention state of the Apple TV to its name; `unknown` for any other value. */
|
|
1045
|
+
function convertAttentionState(state) {
|
|
1046
|
+
switch (state) {
|
|
1047
|
+
case 1: return "asleep";
|
|
1048
|
+
case 2: return "screensaver";
|
|
1049
|
+
case 3: return "awake";
|
|
1050
|
+
case 4: return "idle";
|
|
1051
|
+
default: return "unknown";
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/power.ts
|
|
1057
|
+
/**
|
|
1058
|
+
* Attention state and the power buttons. The state follows the `SystemStatus` and `TVSystemStatus`
|
|
1059
|
+
* events; its `attentionStateChanged` event reports a change.
|
|
1060
|
+
*/
|
|
1061
|
+
var Power = class {
|
|
1062
|
+
/** The attention state the device reported last. */
|
|
1063
|
+
get attentionState() {
|
|
1064
|
+
return this.#state.attentionState;
|
|
1065
|
+
}
|
|
1066
|
+
#channel;
|
|
1067
|
+
#hid;
|
|
1068
|
+
#state;
|
|
1069
|
+
constructor(channel, hid, state) {
|
|
1070
|
+
this.#channel = channel;
|
|
1071
|
+
this.#hid = hid;
|
|
1072
|
+
this.#state = state;
|
|
1073
|
+
}
|
|
1074
|
+
/** Asks the device for its attention state; the state is left as it is. */
|
|
1075
|
+
async fetch() {
|
|
1076
|
+
return convertAttentionState((await this.#channel.exchange(fetchAttentionState()))._c.state);
|
|
1077
|
+
}
|
|
1078
|
+
async isAwake() {
|
|
1079
|
+
return await this.fetch() === "awake";
|
|
1080
|
+
}
|
|
1081
|
+
/** Presses the Wake button. */
|
|
1082
|
+
async wake() {
|
|
1083
|
+
await this.#hid.press("Wake", "SingleTap");
|
|
1084
|
+
}
|
|
1085
|
+
/** Presses the Sleep button. */
|
|
1086
|
+
async sleep() {
|
|
1087
|
+
await this.#hid.press("Sleep", "SingleTap");
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region src/state.ts
|
|
1093
|
+
/** The events the state derives something from; a session registers interest in the first five. */
|
|
1094
|
+
const STATE_EVENTS = [
|
|
1095
|
+
"_iMC",
|
|
1096
|
+
"SystemStatus",
|
|
1097
|
+
"TVSystemStatus",
|
|
1098
|
+
"NowPlayingInfo",
|
|
1099
|
+
"SupportedActions",
|
|
1100
|
+
"_tiStarted",
|
|
1101
|
+
"_tiStopped"
|
|
1102
|
+
];
|
|
1103
|
+
const DEFAULT_TEXT_INPUT = {
|
|
1104
|
+
isActive: false,
|
|
1105
|
+
documentText: "",
|
|
1106
|
+
isSecure: false,
|
|
1107
|
+
keyboardType: 0,
|
|
1108
|
+
autocorrection: false,
|
|
1109
|
+
autocapitalization: false
|
|
1110
|
+
};
|
|
1111
|
+
/**
|
|
1112
|
+
* What a Companion Link device reports about itself: attention state, media controls, now playing,
|
|
1113
|
+
* supported actions and text input. A model without I/O: feed it with {@link apply}, from a session
|
|
1114
|
+
* or from recorded events.
|
|
1115
|
+
*/
|
|
1116
|
+
var CompanionLinkState = class extends EventEmitter {
|
|
1117
|
+
get attentionState() {
|
|
1118
|
+
return this.#attentionState;
|
|
1119
|
+
}
|
|
1120
|
+
get mediaCapabilities() {
|
|
1121
|
+
return this.#mediaCapabilities;
|
|
1122
|
+
}
|
|
1123
|
+
/** The raw `_iMC` bitmask, see {@link MediaControlFlag}. */
|
|
1124
|
+
get mediaControlFlags() {
|
|
1125
|
+
return this.#mediaControlFlags;
|
|
1126
|
+
}
|
|
1127
|
+
get nowPlayingInfo() {
|
|
1128
|
+
return this.#nowPlayingInfo;
|
|
1129
|
+
}
|
|
1130
|
+
get supportedActions() {
|
|
1131
|
+
return this.#supportedActions;
|
|
1132
|
+
}
|
|
1133
|
+
get textInputState() {
|
|
1134
|
+
return this.#textInputState;
|
|
1135
|
+
}
|
|
1136
|
+
get volumeAvailable() {
|
|
1137
|
+
return this.#volumeAvailable;
|
|
1138
|
+
}
|
|
1139
|
+
#attentionState;
|
|
1140
|
+
#mediaCapabilities;
|
|
1141
|
+
#mediaControlFlags;
|
|
1142
|
+
#nowPlayingInfo;
|
|
1143
|
+
#supportedActions;
|
|
1144
|
+
#textInputState;
|
|
1145
|
+
#volumeAvailable;
|
|
1146
|
+
constructor() {
|
|
1147
|
+
super();
|
|
1148
|
+
this.clear();
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Applies the `_c` of one event. Events the state does not track are ignored.
|
|
1152
|
+
* `FetchAttentionState` takes the content of that reply and always emits, also when the state
|
|
1153
|
+
* did not change, so a fresh session reports its attention state once.
|
|
1154
|
+
*
|
|
1155
|
+
* @throws When the content is malformed; text input falls back to its default state first.
|
|
1156
|
+
*/
|
|
1157
|
+
apply(name, content) {
|
|
1158
|
+
switch (name) {
|
|
1159
|
+
case "_iMC": return this.#onMediaControl(content);
|
|
1160
|
+
case "SystemStatus":
|
|
1161
|
+
case "TVSystemStatus": return this.#onSystemStatus(content);
|
|
1162
|
+
case "NowPlayingInfo": return this.#onNowPlayingInfo(content);
|
|
1163
|
+
case "SupportedActions": return this.#onSupportedActions(content);
|
|
1164
|
+
case "_tiStarted": return this.#onTextInputStarted(content);
|
|
1165
|
+
case "_tiStopped": return this.#onTextInputStopped();
|
|
1166
|
+
case "FetchAttentionState": return this.#onAttentionStateFetched(content);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
/** Resets every value to its initial one, without events. */
|
|
1170
|
+
clear() {
|
|
1171
|
+
this.#attentionState = "unknown";
|
|
1172
|
+
this.#mediaCapabilities = parseMediaControlFlags(0);
|
|
1173
|
+
this.#mediaControlFlags = 0;
|
|
1174
|
+
this.#nowPlayingInfo = null;
|
|
1175
|
+
this.#supportedActions = null;
|
|
1176
|
+
this.#textInputState = { ...DEFAULT_TEXT_INPUT };
|
|
1177
|
+
this.#volumeAvailable = false;
|
|
1178
|
+
}
|
|
1179
|
+
#onAttentionStateFetched(content) {
|
|
1180
|
+
const state = convertAttentionState(content.state);
|
|
1181
|
+
this.#attentionState = state;
|
|
1182
|
+
this.emit("attentionStateChanged", state);
|
|
1183
|
+
}
|
|
1184
|
+
/** Emits only when the flags change, and the volume event only when the volume bit flips. */
|
|
1185
|
+
#onMediaControl(content) {
|
|
1186
|
+
const flags = Number(content?._mcF ?? 0);
|
|
1187
|
+
if (flags === this.#mediaControlFlags) return;
|
|
1188
|
+
this.#mediaControlFlags = flags;
|
|
1189
|
+
this.#mediaCapabilities = parseMediaControlFlags(flags);
|
|
1190
|
+
const wasVolumeAvailable = this.#volumeAvailable;
|
|
1191
|
+
this.#volumeAvailable = this.#mediaCapabilities.volume;
|
|
1192
|
+
this.emit("mediaControlFlagsChanged", flags, this.#mediaCapabilities);
|
|
1193
|
+
if (wasVolumeAvailable !== this.#volumeAvailable) this.emit("volumeAvailabilityChanged", this.#volumeAvailable);
|
|
1194
|
+
}
|
|
1195
|
+
/** The NSKeyedArchiver plist in `NowPlayingInfoKey` when present, otherwise the dictionary itself. */
|
|
1196
|
+
#onNowPlayingInfo(content) {
|
|
1197
|
+
if (content?.NowPlayingInfoKey) {
|
|
1198
|
+
const bytes = Buffer.from(content.NowPlayingInfoKey);
|
|
1199
|
+
const plist = Plist.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
1200
|
+
const decoded = NSKeyedArchiver.decode(plist);
|
|
1201
|
+
this.#nowPlayingInfo = decoded && typeof decoded === "object" && !Array.isArray(decoded) ? decoded : null;
|
|
1202
|
+
} else this.#nowPlayingInfo = content ?? null;
|
|
1203
|
+
this.emit("nowPlayingInfoChanged", this.#nowPlayingInfo);
|
|
1204
|
+
}
|
|
1205
|
+
#onSupportedActions(content) {
|
|
1206
|
+
this.#supportedActions = content ?? null;
|
|
1207
|
+
this.emit("supportedActionsChanged", content ?? {});
|
|
1208
|
+
}
|
|
1209
|
+
/** Only the states 1 to 4 count, as a number or a numeric string; a repeat emits nothing. */
|
|
1210
|
+
#onSystemStatus(content) {
|
|
1211
|
+
const value = content?.state;
|
|
1212
|
+
if (typeof value !== "number" && !(typeof value === "string" && /^[1-4]$/.test(value))) return;
|
|
1213
|
+
const state = convertAttentionState(Number(value));
|
|
1214
|
+
if (state === "unknown" || state === this.#attentionState) return;
|
|
1215
|
+
this.#attentionState = state;
|
|
1216
|
+
this.emit("attentionStateChanged", state);
|
|
1217
|
+
}
|
|
1218
|
+
#onTextInputStarted(content) {
|
|
1219
|
+
try {
|
|
1220
|
+
if (!content?._tiD) this.#textInputState = { ...DEFAULT_TEXT_INPUT };
|
|
1221
|
+
else {
|
|
1222
|
+
const { uuid, ...state } = RTI.decodeSession(content._tiD);
|
|
1223
|
+
this.#textInputState = {
|
|
1224
|
+
isActive: true,
|
|
1225
|
+
...state
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
this.emit("textInputChanged", this.#textInputState);
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
this.#textInputState = { ...DEFAULT_TEXT_INPUT };
|
|
1231
|
+
this.emit("textInputChanged", this.#textInputState);
|
|
1232
|
+
throw err;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
#onTextInputStopped() {
|
|
1236
|
+
this.#textInputState = { ...DEFAULT_TEXT_INPUT };
|
|
1237
|
+
this.emit("textInputChanged", this.#textInputState);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
function parseMediaControlFlags(flags) {
|
|
1241
|
+
return {
|
|
1242
|
+
play: (flags & MediaControlFlag.Play) !== 0,
|
|
1243
|
+
pause: (flags & MediaControlFlag.Pause) !== 0,
|
|
1244
|
+
previousTrack: (flags & MediaControlFlag.PreviousTrack) !== 0,
|
|
1245
|
+
nextTrack: (flags & MediaControlFlag.NextTrack) !== 0,
|
|
1246
|
+
fastForward: (flags & MediaControlFlag.FastForward) !== 0,
|
|
1247
|
+
rewind: (flags & MediaControlFlag.Rewind) !== 0,
|
|
1248
|
+
volume: (flags & MediaControlFlag.Volume) !== 0,
|
|
1249
|
+
skipForward: (flags & MediaControlFlag.SkipForward) !== 0,
|
|
1250
|
+
skipBackward: (flags & MediaControlFlag.SkipBackward) !== 0
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/system.ts
|
|
1256
|
+
/** System settings, Siri and the Up Next queue. */
|
|
1257
|
+
var System = class {
|
|
1258
|
+
#channel;
|
|
1259
|
+
constructor(channel) {
|
|
1260
|
+
this.#channel = channel;
|
|
1261
|
+
}
|
|
1262
|
+
async toggleCaptions() {
|
|
1263
|
+
await this.#channel.exchange(toggleCaptions());
|
|
1264
|
+
}
|
|
1265
|
+
/** The caption settings, over `_mcc`. */
|
|
1266
|
+
async getCaptionSetting() {
|
|
1267
|
+
return await this.#channel.exchange(mediaCaptionSettingGet());
|
|
1268
|
+
}
|
|
1269
|
+
async setCaptionSetting(enabled) {
|
|
1270
|
+
return await this.#channel.exchange(mediaCaptionSettingSet(enabled));
|
|
1271
|
+
}
|
|
1272
|
+
/** Light appearance when true, dark when false. */
|
|
1273
|
+
async setAppearance(light) {
|
|
1274
|
+
await this.#channel.exchange(toggleSystemAppearance(light));
|
|
1275
|
+
}
|
|
1276
|
+
async setReduceLoudSounds(enabled) {
|
|
1277
|
+
await this.#channel.exchange(toggleReduceLoudSounds(enabled));
|
|
1278
|
+
}
|
|
1279
|
+
async setFindingMode(enabled) {
|
|
1280
|
+
await this.#channel.exchange(toggleFindingMode(enabled));
|
|
1281
|
+
}
|
|
1282
|
+
/** Push-to-talk begin; see `supportsSiriPTT` on the session. */
|
|
1283
|
+
async siriStart() {
|
|
1284
|
+
await this.#channel.exchange(siriStart());
|
|
1285
|
+
}
|
|
1286
|
+
async siriStop() {
|
|
1287
|
+
await this.#channel.exchange(siriStop());
|
|
1288
|
+
}
|
|
1289
|
+
/** What the device knows about the paired Siri Remote, from the plist in the reply. */
|
|
1290
|
+
async siriRemoteInfo() {
|
|
1291
|
+
const reply = await this.#channel.exchange(fetchSiriRemoteInfo());
|
|
1292
|
+
return Plist.parse(Uint8Array.from(reply._c.SiriRemoteInfoKey).buffer);
|
|
1293
|
+
}
|
|
1294
|
+
/** The Up Next queue, as the raw reply. */
|
|
1295
|
+
async fetchUpNext(paginationToken) {
|
|
1296
|
+
return await this.#channel.exchange(fetchUpNext(paginationToken));
|
|
1297
|
+
}
|
|
1298
|
+
async addToUpNext(identifier, kind) {
|
|
1299
|
+
await this.#channel.exchange(addToUpNext(identifier, kind));
|
|
1300
|
+
}
|
|
1301
|
+
async removeFromUpNext(identifier, kind) {
|
|
1302
|
+
await this.#channel.exchange(removeFromUpNext(identifier, kind));
|
|
1303
|
+
}
|
|
1304
|
+
async markAsWatched(identifier, kind) {
|
|
1305
|
+
await this.#channel.exchange(markAsWatched(identifier, kind));
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
//#endregion
|
|
1310
|
+
//#region src/text.ts
|
|
1311
|
+
/**
|
|
1312
|
+
* Remote text input into the focused field. Operations run one at a time; each one starts the
|
|
1313
|
+
* text input session anew for the keyboard session UUID. An operation queued before the field
|
|
1314
|
+
* changed, stopped or the session ended fails instead of typing into the wrong field.
|
|
1315
|
+
*/
|
|
1316
|
+
var Text = class {
|
|
1317
|
+
/** The text input session the device reported last. */
|
|
1318
|
+
get state() {
|
|
1319
|
+
return this.#state.textInputState;
|
|
1320
|
+
}
|
|
1321
|
+
#channel;
|
|
1322
|
+
#state;
|
|
1323
|
+
#generation = 0;
|
|
1324
|
+
#queue = Promise.resolve();
|
|
1325
|
+
#session;
|
|
1326
|
+
constructor(channel, state) {
|
|
1327
|
+
this.#channel = channel;
|
|
1328
|
+
this.#state = state;
|
|
1329
|
+
channel.listen("_tiStarted", (content) => this.#onStarted(content));
|
|
1330
|
+
channel.listen("_tiStopped", () => this.#onStopped());
|
|
1331
|
+
channel.signal.addEventListener("abort", () => this.#generation++, { once: true });
|
|
1332
|
+
}
|
|
1333
|
+
/** Replaces the text of the field. */
|
|
1334
|
+
async set(text) {
|
|
1335
|
+
await this.command(text, true);
|
|
1336
|
+
}
|
|
1337
|
+
async append(text) {
|
|
1338
|
+
await this.command(text, false);
|
|
1339
|
+
}
|
|
1340
|
+
async clear() {
|
|
1341
|
+
await this.command("", true);
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Clears the field when asked, then inserts the text when there is any.
|
|
1345
|
+
*
|
|
1346
|
+
* @throws {Error} When no field has focus, or the field changed before the operation ran.
|
|
1347
|
+
*/
|
|
1348
|
+
command(text, clearPreviousInput) {
|
|
1349
|
+
const generation = this.#generation;
|
|
1350
|
+
const operation = this.#queue.then(async () => {
|
|
1351
|
+
if (generation !== this.#generation) throw new Error("Text input session changed.");
|
|
1352
|
+
const reply = await this.start();
|
|
1353
|
+
if (generation !== this.#generation) throw new Error("Text input session changed; retry with the active field.");
|
|
1354
|
+
const data = reply?._c?._tiD;
|
|
1355
|
+
if (!data) throw new Error("No active text input session.");
|
|
1356
|
+
const session = RTI.decodeSession(data);
|
|
1357
|
+
if (clearPreviousInput) this.#channel.send(tiChange(Buffer.from(RTI.encodeOperation(session.uuid, "", true))));
|
|
1358
|
+
if (text) this.#channel.send(tiChange(Buffer.from(RTI.encodeOperation(session.uuid, text, false))));
|
|
1359
|
+
return text;
|
|
1360
|
+
});
|
|
1361
|
+
this.#queue = operation.catch(() => {});
|
|
1362
|
+
return operation;
|
|
1363
|
+
}
|
|
1364
|
+
/** Starts the text input session; the reply carries the keyboard session in `_c._tiD`. */
|
|
1365
|
+
async start() {
|
|
1366
|
+
return await this.#channel.exchange(tiStart());
|
|
1367
|
+
}
|
|
1368
|
+
async stop() {
|
|
1369
|
+
await this.#channel.exchange(tiStop());
|
|
1370
|
+
}
|
|
1371
|
+
/** A field with another keyboard session UUID is another field. */
|
|
1372
|
+
#onStarted(content) {
|
|
1373
|
+
try {
|
|
1374
|
+
const uuid = Buffer.from(RTI.decodeSession(content?._tiD).uuid).toString("hex");
|
|
1375
|
+
if (this.#session && this.#session !== uuid) this.#generation++;
|
|
1376
|
+
this.#session = uuid;
|
|
1377
|
+
} catch {
|
|
1378
|
+
this.#generation++;
|
|
1379
|
+
this.#session = void 0;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
#onStopped() {
|
|
1383
|
+
this.#generation++;
|
|
1384
|
+
this.#session = void 0;
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
//#endregion
|
|
1389
|
+
//#region src/types.ts
|
|
1390
|
+
/** The phases of the `_touchC` event, the 0-based `UITouchPhase` values. */
|
|
1391
|
+
const TouchPhase = {
|
|
1392
|
+
Began: 0,
|
|
1393
|
+
Moved: 1,
|
|
1394
|
+
Ended: 2,
|
|
1395
|
+
Cancelled: 3,
|
|
1396
|
+
Stationary: 4
|
|
1397
|
+
};
|
|
1398
|
+
/** The phases of the `_hidT` event. Unlike {@link TouchPhase}, the HID touch stream on the wire is 1-based. */
|
|
1399
|
+
const HidTouchPhase = {
|
|
1400
|
+
Began: 1,
|
|
1401
|
+
Moved: 2,
|
|
1402
|
+
Stationary: 3,
|
|
1403
|
+
Ended: 4,
|
|
1404
|
+
Cancelled: 5
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/touch.ts
|
|
1409
|
+
/** The finger a gesture reports itself as; the HID touch stream is 1-based. */
|
|
1410
|
+
const TOUCH_FINGER = 1;
|
|
1411
|
+
/** Start and end point of a swipe in the 0 to 1000 touchpad space. */
|
|
1412
|
+
const SWIPES = {
|
|
1413
|
+
up: [
|
|
1414
|
+
500,
|
|
1415
|
+
700,
|
|
1416
|
+
500,
|
|
1417
|
+
300
|
|
1418
|
+
],
|
|
1419
|
+
down: [
|
|
1420
|
+
500,
|
|
1421
|
+
300,
|
|
1422
|
+
500,
|
|
1423
|
+
700
|
|
1424
|
+
],
|
|
1425
|
+
left: [
|
|
1426
|
+
700,
|
|
1427
|
+
500,
|
|
1428
|
+
300,
|
|
1429
|
+
500
|
|
1430
|
+
],
|
|
1431
|
+
right: [
|
|
1432
|
+
300,
|
|
1433
|
+
500,
|
|
1434
|
+
700,
|
|
1435
|
+
500
|
|
1436
|
+
]
|
|
1437
|
+
};
|
|
1438
|
+
/** The virtual touchpad. Coordinates run from 0 to 1000 on both axes. */
|
|
1439
|
+
var Touch = class {
|
|
1440
|
+
#channel;
|
|
1441
|
+
#hid;
|
|
1442
|
+
constructor(channel, hid) {
|
|
1443
|
+
this.#channel = channel;
|
|
1444
|
+
this.#hid = hid;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Sends a `_touchC` event. Kept for protocol work: tvOS registers no handler for it, so
|
|
1448
|
+
* {@link sendHid} is what reaches the device.
|
|
1449
|
+
*
|
|
1450
|
+
* @param finger The finger index, 0-based.
|
|
1451
|
+
* @param phase A `TouchPhase` value.
|
|
1452
|
+
*/
|
|
1453
|
+
send(finger, phase, x, y) {
|
|
1454
|
+
this.#channel.send(touchEvent(finger, phase, x, y));
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Sends a `_hidT` event, the form the HID daemon of the Apple TV listens for.
|
|
1458
|
+
*
|
|
1459
|
+
* @param finger The finger index, 1-based.
|
|
1460
|
+
* @param phase A {@link HidTouchPhase} value.
|
|
1461
|
+
*/
|
|
1462
|
+
sendHid(finger, phase, x, y) {
|
|
1463
|
+
this.#channel.send(hidTouchEvent(finger, phase, x, y));
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Taps the touch surface, which selects whatever has focus. The HID touch device reports
|
|
1467
|
+
* contact and position only, so a touch that lands and lifts moves no focus and clicks
|
|
1468
|
+
* nothing; a real remote sends the Select button for a tap.
|
|
1469
|
+
*
|
|
1470
|
+
* @param holdMs How long to hold, in milliseconds. Omit for a plain tap.
|
|
1471
|
+
*/
|
|
1472
|
+
async tap(holdMs) {
|
|
1473
|
+
if (holdMs === void 0) {
|
|
1474
|
+
await this.#hid.press("Select", "SingleTap");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
await this.#hid.press("Select", "Hold", holdMs);
|
|
1478
|
+
}
|
|
1479
|
+
/** Began, at least four Moved samples spread over `duration` milliseconds, then Ended at the end point. */
|
|
1480
|
+
async swipe(direction, duration = 200) {
|
|
1481
|
+
const [startX, startY, endX, endY] = SWIPES[direction];
|
|
1482
|
+
const steps = Math.max(4, Math.floor(duration / 50));
|
|
1483
|
+
const deltaX = (endX - startX) / steps;
|
|
1484
|
+
const deltaY = (endY - startY) / steps;
|
|
1485
|
+
const stepDuration = duration / steps;
|
|
1486
|
+
this.sendHid(TOUCH_FINGER, HidTouchPhase.Began, startX, startY);
|
|
1487
|
+
for (let i = 1; i <= steps; i++) {
|
|
1488
|
+
await waitFor(stepDuration);
|
|
1489
|
+
this.sendHid(TOUCH_FINGER, HidTouchPhase.Moved, Math.round(startX + deltaX * i), Math.round(startY + deltaY * i));
|
|
1490
|
+
}
|
|
1491
|
+
this.sendHid(TOUCH_FINGER, HidTouchPhase.Ended, endX, endY);
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/session.ts
|
|
1497
|
+
/** NoOp frames keep the connection alive; the device answers none of them. */
|
|
1498
|
+
const HEARTBEAT_INTERVAL = 15e3;
|
|
1499
|
+
/**
|
|
1500
|
+
* The graceful stop and the socket close together. A slow but alive Apple TV can take up to 10 s to
|
|
1501
|
+
* answer `_sessionStop`; a half-open peer would otherwise hold the close for minutes.
|
|
1502
|
+
*/
|
|
1503
|
+
const CLOSE_TIMEOUT = 1e4;
|
|
1504
|
+
/** The state events the device only pushes after an `_interest` registration. */
|
|
1505
|
+
const INTEREST_EVENTS = [
|
|
1506
|
+
"_iMC",
|
|
1507
|
+
"SystemStatus",
|
|
1508
|
+
"TVSystemStatus",
|
|
1509
|
+
"NowPlayingInfo",
|
|
1510
|
+
"SupportedActions"
|
|
1511
|
+
];
|
|
1512
|
+
/** The interests a graceful stop deregisters in one event, after each one on its own. */
|
|
1513
|
+
const STOP_INTERESTS = [
|
|
1514
|
+
"_iMC",
|
|
1515
|
+
"SystemStatus",
|
|
1516
|
+
"TVSystemStatus"
|
|
1517
|
+
];
|
|
1518
|
+
/**
|
|
1519
|
+
* The device or the socket ended the connection after it opened and before the session was
|
|
1520
|
+
* ready, which a caller may want to treat like a session that dropped.
|
|
1521
|
+
*/
|
|
1522
|
+
var SetupClosedError = class extends ConnectionClosedError {
|
|
1523
|
+
reason;
|
|
1524
|
+
constructor(reason) {
|
|
1525
|
+
super(`The Companion Link connection closed during setup (${reason}).`);
|
|
1526
|
+
this.name = "SetupClosedError";
|
|
1527
|
+
this.reason = reason;
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
/** Package-internal: lets {@link connect} run the startup, which no caller of a session may repeat. */
|
|
1531
|
+
let startup;
|
|
1532
|
+
/**
|
|
1533
|
+
* Connects to a Companion Link device and returns a ready session. It runs TCP, pair-verify and
|
|
1534
|
+
* encryption, then the startup: `_systemInfo`, `_sessionStart`, `TVRCSessionStart`, optionally
|
|
1535
|
+
* `SwitchActiveUserAccountEvent`, `_touchStart` and `_tiStart` as requests, the heartbeat, one
|
|
1536
|
+
* `_interest` event per state event, and the attention state and media control status.
|
|
1537
|
+
*
|
|
1538
|
+
* @throws {CredentialsError} Without credentials.
|
|
1539
|
+
* @throws {PairingError} When pair-verify fails.
|
|
1540
|
+
* @throws {ConnectionClosedError} When the signal aborts or the device closes during the handshake.
|
|
1541
|
+
*/
|
|
1542
|
+
async function connect(target, options) {
|
|
1543
|
+
if (!options?.credentials) throw new CredentialsError("Credentials are required to connect to a Companion Link device.");
|
|
1544
|
+
const signal = options.signal;
|
|
1545
|
+
if (signal?.aborted) throw new ConnectionClosedError("Companion Link setup was interrupted.");
|
|
1546
|
+
const context = options.context ?? new Context(target.id ?? target.address, options.identity);
|
|
1547
|
+
const stream = new Stream(context, target.address, target.port);
|
|
1548
|
+
const interrupt = () => stream.destroy();
|
|
1549
|
+
stream.on("error", (error) => context.logger.error("Companion Link error", error));
|
|
1550
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
1551
|
+
let closedBy;
|
|
1552
|
+
try {
|
|
1553
|
+
await stream.connect();
|
|
1554
|
+
stream.on("closed", (reason) => {
|
|
1555
|
+
closedBy = reason;
|
|
1556
|
+
});
|
|
1557
|
+
const keys = await verifyPairing(stream, context, options.credentials);
|
|
1558
|
+
if (signal?.aborted || !stream.isConnected) throw new ConnectionClosedError("Companion Link setup was interrupted.");
|
|
1559
|
+
stream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
|
|
1560
|
+
const session = new CompanionLinkSession({
|
|
1561
|
+
target,
|
|
1562
|
+
context,
|
|
1563
|
+
stream,
|
|
1564
|
+
state: options.state ?? new CompanionLinkState(),
|
|
1565
|
+
signal
|
|
1566
|
+
});
|
|
1567
|
+
await startup(session, options.credentials, options.activeUserAccount);
|
|
1568
|
+
return session;
|
|
1569
|
+
} catch (err) {
|
|
1570
|
+
stream.destroy();
|
|
1571
|
+
if (signal?.aborted) throw new ConnectionClosedError("Companion Link setup was interrupted.");
|
|
1572
|
+
throw closedBy && closedBy !== "local" ? new SetupClosedError(closedBy) : err;
|
|
1573
|
+
} finally {
|
|
1574
|
+
signal?.removeEventListener("abort", interrupt);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* One authenticated Companion Link connection, with its methods in groups. It lives once: it ends
|
|
1579
|
+
* exactly once, with a reason, and then its {@link signal} aborts and the heartbeat, listeners and
|
|
1580
|
+
* pending requests go with it. Requests on an ended session fail with {@link ConnectionClosedError}.
|
|
1581
|
+
*/
|
|
1582
|
+
var CompanionLinkSession = class extends EventEmitter {
|
|
1583
|
+
static {
|
|
1584
|
+
startup = (session, credentials, activeUserAccount) => session.#start(credentials, activeUserAccount);
|
|
1585
|
+
}
|
|
1586
|
+
/** Why the session ended, or undefined while it is open. */
|
|
1587
|
+
get closeReason() {
|
|
1588
|
+
return this.#closeReason;
|
|
1589
|
+
}
|
|
1590
|
+
get context() {
|
|
1591
|
+
return this.#context;
|
|
1592
|
+
}
|
|
1593
|
+
get isOpen() {
|
|
1594
|
+
return !this.signal.aborted;
|
|
1595
|
+
}
|
|
1596
|
+
/** Aborts with a {@link ConnectionClosedError} when the session ends. */
|
|
1597
|
+
get signal() {
|
|
1598
|
+
return this.#controller.signal;
|
|
1599
|
+
}
|
|
1600
|
+
/** The source version the device reported in `_systemInfo`, such as `715.2`; `0` when it sent none. */
|
|
1601
|
+
get sourceVersion() {
|
|
1602
|
+
return this.#sourceVersion;
|
|
1603
|
+
}
|
|
1604
|
+
/** The encrypted OPack stream, for raw frames. */
|
|
1605
|
+
get stream() {
|
|
1606
|
+
return this.#stream;
|
|
1607
|
+
}
|
|
1608
|
+
/** Media control needs source version 250.3 or later. */
|
|
1609
|
+
get supportsMediaControl() {
|
|
1610
|
+
return compareVersions(this.#sourceVersion, "250.3") >= 0;
|
|
1611
|
+
}
|
|
1612
|
+
/** Siri push-to-talk needs source version 600.20 or later. */
|
|
1613
|
+
get supportsSiriPTT() {
|
|
1614
|
+
return compareVersions(this.#sourceVersion, "600.20") >= 0;
|
|
1615
|
+
}
|
|
1616
|
+
/** Remote text input needs source version 340.15 or later. */
|
|
1617
|
+
get supportsTextInput() {
|
|
1618
|
+
return compareVersions(this.#sourceVersion, "340.15") >= 0;
|
|
1619
|
+
}
|
|
1620
|
+
get target() {
|
|
1621
|
+
return this.#target;
|
|
1622
|
+
}
|
|
1623
|
+
accounts;
|
|
1624
|
+
apps;
|
|
1625
|
+
hid;
|
|
1626
|
+
interests;
|
|
1627
|
+
media;
|
|
1628
|
+
power;
|
|
1629
|
+
state;
|
|
1630
|
+
system;
|
|
1631
|
+
text;
|
|
1632
|
+
touch;
|
|
1633
|
+
#channel;
|
|
1634
|
+
#context;
|
|
1635
|
+
#controller = new AbortController();
|
|
1636
|
+
#stateListeners = [];
|
|
1637
|
+
#stream;
|
|
1638
|
+
#target;
|
|
1639
|
+
#closeReason;
|
|
1640
|
+
#closing;
|
|
1641
|
+
#heartbeat;
|
|
1642
|
+
#sessionId = 0n;
|
|
1643
|
+
#sessionIdLocal = 0;
|
|
1644
|
+
#sourceVersion = "0";
|
|
1645
|
+
/** Use {@link connect}; a session starts from a verified, encrypted stream. */
|
|
1646
|
+
constructor(parts) {
|
|
1647
|
+
super();
|
|
1648
|
+
this.#context = parts.context;
|
|
1649
|
+
this.#stream = parts.stream;
|
|
1650
|
+
this.#target = parts.target;
|
|
1651
|
+
this.state = parts.state;
|
|
1652
|
+
this.#channel = new Channel(this.#stream, this.signal);
|
|
1653
|
+
this.hid = new Hid(this.#channel);
|
|
1654
|
+
this.touch = new Touch(this.#channel, this.hid);
|
|
1655
|
+
this.text = new Text(this.#channel, this.state);
|
|
1656
|
+
this.power = new Power(this.#channel, this.hid, this.state);
|
|
1657
|
+
this.apps = new Apps(this.#channel);
|
|
1658
|
+
this.accounts = new Accounts(this.#channel);
|
|
1659
|
+
this.media = new Media(this.#channel, this.state);
|
|
1660
|
+
this.system = new System(this.#channel);
|
|
1661
|
+
this.interests = new Interests(this.#channel);
|
|
1662
|
+
this.#stream.on("closed", (reason, error) => this.#end(reason, error));
|
|
1663
|
+
if (!this.#stream.isConnected) this.#end("remote");
|
|
1664
|
+
parts.signal?.addEventListener("abort", () => this.#end("local"), {
|
|
1665
|
+
once: true,
|
|
1666
|
+
signal: this.signal
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Ends the session gracefully: stops the heartbeat, deregisters the interests, stops text
|
|
1671
|
+
* input, touch, the remote control session and the service session (each failure ignored),
|
|
1672
|
+
* then closes the socket. Resolves once it is closed; a peer that does not finish within
|
|
1673
|
+
* {@link CLOSE_TIMEOUT} gets its socket destroyed.
|
|
1674
|
+
*/
|
|
1675
|
+
close() {
|
|
1676
|
+
this.#closing ??= this.#close();
|
|
1677
|
+
return this.#closing;
|
|
1678
|
+
}
|
|
1679
|
+
/** Sends a NoOp frame, which stays plaintext. */
|
|
1680
|
+
noOp() {
|
|
1681
|
+
this.#context.logger.debug("Sending no-op operation.");
|
|
1682
|
+
this.#stream.send(FrameType.NoOp, Buffer.allocUnsafe(0));
|
|
1683
|
+
}
|
|
1684
|
+
/** Throws when the session ended while a startup step was waiting. */
|
|
1685
|
+
#checkpoint() {
|
|
1686
|
+
if (this.signal.aborted) throw new ConnectionClosedError("Companion Link setup was interrupted.");
|
|
1687
|
+
}
|
|
1688
|
+
async #close() {
|
|
1689
|
+
if (this.signal.aborted) return;
|
|
1690
|
+
this.#stopHeartbeat();
|
|
1691
|
+
this.#stopStateFeed();
|
|
1692
|
+
const deadline = setTimeout(() => {
|
|
1693
|
+
this.#context.logger.warn("[companion-link]", `Graceful stop did not finish within ${CLOSE_TIMEOUT} ms, destroying the socket.`);
|
|
1694
|
+
this.#end("local");
|
|
1695
|
+
}, CLOSE_TIMEOUT);
|
|
1696
|
+
try {
|
|
1697
|
+
await this.#gracefulStop();
|
|
1698
|
+
} catch (err) {
|
|
1699
|
+
this.#context.logger.warn("[companion-link]", "Graceful stop failed during disconnect", err);
|
|
1700
|
+
}
|
|
1701
|
+
try {
|
|
1702
|
+
await this.#stream.disconnect();
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
this.#context.logger.warn("[companion-link]", "Closing the socket failed during disconnect", err);
|
|
1705
|
+
} finally {
|
|
1706
|
+
clearTimeout(deadline);
|
|
1707
|
+
this.#end("local");
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
/** The single exit: stops the heartbeat, aborts the signal (listeners clean up there), destroys the stream and emits `closed`. */
|
|
1711
|
+
#end(reason, error) {
|
|
1712
|
+
if (this.#closeReason) return;
|
|
1713
|
+
this.#closeReason = reason;
|
|
1714
|
+
this.#context.logger.net("[session]", `Companion Link session closed (${reason}${error ? `: ${error.message}` : ""}).`);
|
|
1715
|
+
this.#stopHeartbeat();
|
|
1716
|
+
this.#controller.abort(new ConnectionClosedError(`Companion Link session closed (${reason}).`));
|
|
1717
|
+
this.#stream.destroy();
|
|
1718
|
+
this.emit("closed", reason, error);
|
|
1719
|
+
}
|
|
1720
|
+
/** Feeds the state from here on and registers interest in its events, one `_interest` event each. */
|
|
1721
|
+
#feedState() {
|
|
1722
|
+
for (const name of STATE_EVENTS) {
|
|
1723
|
+
const listener = (content) => {
|
|
1724
|
+
try {
|
|
1725
|
+
this.state.apply(name, content);
|
|
1726
|
+
} catch (err) {
|
|
1727
|
+
this.#context.logger.error("[cl-state]", `${name} parse error`, err);
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
this.#channel.listen(name, listener);
|
|
1731
|
+
this.#stateListeners.push([name, listener]);
|
|
1732
|
+
}
|
|
1733
|
+
for (const event of INTEREST_EVENTS) try {
|
|
1734
|
+
this.interests.register([event]);
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
this.#context.logger.warn("[cl-state]", `Failed to register interest for ${event}`, err);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
/** Each step stops early once the socket is gone. */
|
|
1740
|
+
async #gracefulStop() {
|
|
1741
|
+
if (!this.#stream.isConnected) return;
|
|
1742
|
+
this.interests.deregister(STOP_INTERESTS);
|
|
1743
|
+
try {
|
|
1744
|
+
await this.text.stop();
|
|
1745
|
+
} catch {}
|
|
1746
|
+
if (!this.#stream.isConnected) return;
|
|
1747
|
+
try {
|
|
1748
|
+
await this.#channel.exchange(touchStop());
|
|
1749
|
+
} catch {}
|
|
1750
|
+
if (!this.#stream.isConnected) return;
|
|
1751
|
+
try {
|
|
1752
|
+
await this.#channel.exchange(tvrcSessionStop());
|
|
1753
|
+
} catch {}
|
|
1754
|
+
if (!this.#stream.isConnected) return;
|
|
1755
|
+
try {
|
|
1756
|
+
await this.#sessionStop();
|
|
1757
|
+
} catch {}
|
|
1758
|
+
}
|
|
1759
|
+
async #sessionStop() {
|
|
1760
|
+
if (this.#sessionId === 0n) return;
|
|
1761
|
+
await this.#channel.exchange(sessionStop(this.#sessionIdLocal));
|
|
1762
|
+
this.#sessionId = 0n;
|
|
1763
|
+
this.#sessionIdLocal = 0;
|
|
1764
|
+
}
|
|
1765
|
+
async #start(credentials, activeUserAccount) {
|
|
1766
|
+
const channel = this.#channel;
|
|
1767
|
+
const systemInfo$1 = await channel.exchange(systemInfo(credentials.pairingId, this.#context.identity.name ?? DEFAULT_CLIENT_NAME));
|
|
1768
|
+
this.#checkpoint();
|
|
1769
|
+
const sourceVersion = systemInfo$1?._c?._sv;
|
|
1770
|
+
if (sourceVersion) {
|
|
1771
|
+
this.#sourceVersion = String(sourceVersion);
|
|
1772
|
+
this.#context.logger.info("[companion-link]", `Receiver sourceVersion: ${sourceVersion} (mediaControl=${this.supportsMediaControl}, textInput=${this.supportsTextInput}, siriPTT=${this.supportsSiriPTT})`);
|
|
1773
|
+
}
|
|
1774
|
+
const localSid = randomInt(0, 2 ** 32 - 1);
|
|
1775
|
+
const sessionStart$1 = await channel.exchange(sessionStart(localSid));
|
|
1776
|
+
this.#checkpoint();
|
|
1777
|
+
this.#sessionIdLocal = localSid;
|
|
1778
|
+
this.#sessionId = BigInt(Number(sessionStart$1?._c?._sid ?? 0)) << 32n | BigInt(localSid);
|
|
1779
|
+
await channel.exchange(tvrcSessionStart());
|
|
1780
|
+
this.#checkpoint();
|
|
1781
|
+
if (activeUserAccount) {
|
|
1782
|
+
await this.accounts.switchActive(activeUserAccount);
|
|
1783
|
+
this.#checkpoint();
|
|
1784
|
+
}
|
|
1785
|
+
await channel.exchange(touchStart());
|
|
1786
|
+
this.#checkpoint();
|
|
1787
|
+
await this.text.start();
|
|
1788
|
+
this.#checkpoint();
|
|
1789
|
+
this.#startHeartbeat();
|
|
1790
|
+
this.#feedState();
|
|
1791
|
+
try {
|
|
1792
|
+
const attention = await channel.exchange(fetchAttentionState());
|
|
1793
|
+
this.state.apply("FetchAttentionState", attention._c);
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
this.#context.logger.warn("[cl-state]", "Failed to fetch initial attention state", err);
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
await this.media.fetchControlStatus();
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
this.#context.logger.warn("[cl-state]", "Failed to fetch media control status", err);
|
|
1801
|
+
}
|
|
1802
|
+
this.#checkpoint();
|
|
1803
|
+
}
|
|
1804
|
+
#startHeartbeat() {
|
|
1805
|
+
this.#heartbeat = setInterval(() => {
|
|
1806
|
+
try {
|
|
1807
|
+
this.noOp();
|
|
1808
|
+
} catch (err) {
|
|
1809
|
+
this.#context.logger.error("Heartbeat failed", err);
|
|
1810
|
+
}
|
|
1811
|
+
}, HEARTBEAT_INTERVAL);
|
|
1812
|
+
}
|
|
1813
|
+
#stopHeartbeat() {
|
|
1814
|
+
clearInterval(this.#heartbeat);
|
|
1815
|
+
this.#heartbeat = void 0;
|
|
1816
|
+
}
|
|
1817
|
+
/** Stops feeding the state and deregisters each of its interests on its own. */
|
|
1818
|
+
#stopStateFeed() {
|
|
1819
|
+
if (this.#stateListeners.length === 0) return;
|
|
1820
|
+
for (const [name, listener] of this.#stateListeners.splice(0)) this.#channel.unlisten(name, listener);
|
|
1821
|
+
try {
|
|
1822
|
+
for (const event of INTEREST_EVENTS) this.interests.deregister([event]);
|
|
1823
|
+
} catch {}
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
/** Compares dot-separated versions segment by segment; negative when `a` is lower. */
|
|
1827
|
+
function compareVersions(a, b) {
|
|
1828
|
+
const partsA = a.split(".").map(Number);
|
|
1829
|
+
const partsB = b.split(".").map(Number);
|
|
1830
|
+
const length = Math.max(partsA.length, partsB.length);
|
|
1831
|
+
for (let i = 0; i < length; i++) {
|
|
1832
|
+
const segmentA = partsA[i] ?? 0;
|
|
1833
|
+
const segmentB = partsB[i] ?? 0;
|
|
1834
|
+
if (segmentA !== segmentB) return segmentA - segmentB;
|
|
1835
|
+
}
|
|
1836
|
+
return 0;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
//#endregion
|
|
1840
|
+
//#region src/discovery.ts
|
|
1841
|
+
const COMPANION_LINK_SERVICE = "_companion-link._tcp.local";
|
|
1842
|
+
/** Finds the devices on the local network that accept Companion Link connections. */
|
|
1843
|
+
async function discover(options = {}) {
|
|
1844
|
+
return await new Discovery(COMPANION_LINK_SERVICE).find(options.useCache ?? true);
|
|
1845
|
+
}
|
|
1846
|
+
/** The target of a discovery result. Reads the address, the port, the id and the TXT record only. */
|
|
1847
|
+
function toTarget(result) {
|
|
1848
|
+
return {
|
|
1849
|
+
address: result.address,
|
|
1850
|
+
port: result.service.port,
|
|
1851
|
+
id: result.id,
|
|
1852
|
+
txt: result.txt
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
//#endregion
|
|
1857
|
+
export { Accounts, Apps, COMPANION_LINK_SERVICE, Channel, CompanionLinkSession, CompanionLinkState, FrameType, Hid, HidCommand, HidTouchPhase, Interests, Media, MediaControlCommand, MediaControlFlag, MessageType, messages_exports as Messages, OPackFrameTypes, PairingFrameTypes, PinPairing, Power, SetupClosedError, Stream, System, Text, Touch, TouchPhase, connect, convertAttentionState, discover, pair, toTarget };
|