Companion Link
import { CompanionLinkSession, connect, discover, pair, toTarget } from '@corewire/companion-link';Companion Link is the protocol the Remote app on an iPhone uses with an Apple TV. It carries apps, accounts, power state, text input, touch and system settings as OPack messages over an encrypted TCP connection.
Connecting
Companion Link always needs credentials from a PIN pairing. The AirPlay credentials of an Apple TV work, so one pairing covers both protocols.
import { connect, discover, toTarget } from '@corewire/companion-link';
import type { AccessoryCredentials } from '@corewire/core/pairing';
declare const credentials: AccessoryCredentials;
const [found] = await discover();
const session = await connect(toTarget(found), {credentials});
session.on('closed', reason => console.log('closed', reason));
const apps = await session.apps.list();
await session.apps.launch(apps[0].bundleId);
await session.hid.press('Select');
console.log(await session.power.fetch());
await session.close();connect(target, options) runs pair-verify, turns on encryption, then the startup: system info, session start, the TV remote session, touch and text input start, the heartbeat, interest in the state events, and a fetch of the attention state and the media control status. It throws CredentialsError without credentials, PairingError when pair-verify fails and ConnectionClosedError when the signal aborts or the device closes during the handshake. SetupClosedError (a ConnectionClosedError) means the device or the socket ended the connection after it opened and before the session was ready.
| Option | Meaning |
|---|---|
credentials | Required |
context, identity | As for AirPlay |
activeUserAccount | The iCloud alternate DSID to announce as the active user at startup |
state | A CompanionLinkState to feed. Keep one across sessions to keep its listeners |
signal | Ends the session, or interrupts the handshake, when aborted |
A CompanionLinkTarget needs the address and the port of the _companion-link._tcp service. Unlike AirPlay there is no default port.
Pairing
pair(target, options?) sends pair-setup M1, and the Apple TV shows its PIN. PinPairing.pin(code) finishes and resolves AccessoryCredentials. It works like AirPlay pairing.
The groups
| Group | Methods |
|---|---|
apps | list(), launch(bundleId), openUrl(url), topShelf() |
accounts | list(), switch(accountId), switchActive(iCloudAltDSID) and sign-in requests |
hid | press(command, type?, holdDelayMs?) with a HidCommand name and SingleTap, DoubleTap or Hold; command(command, down) for one edge; game controller events |
touch | tap(holdMs?), swipe(direction, duration?), and raw send() and sendHid() touch events |
text | set(), append(), clear(), and state |
power | attentionState, fetch(), isAwake(), wake(), sleep() |
media | command(), skipBy(seconds), play(item), capabilities, nowPlayingInfo |
system | Captions, appearance, Reduce Loud Sounds, Find My mode, Siri, Up Next |
interests | subscribe(event, listener) and unsubscribe() for device events |
state | The CompanionLinkState |
import type { CompanionLinkSession } from '@corewire/companion-link';
declare const session: CompanionLinkSession;
await session.hid.press('Home', 'DoubleTap');
await session.touch.swipe('left');
await session.text.set('search terms');
await session.system.setAppearance(false);State
CompanionLinkState collects what the device reports: attentionState, textInputState, mediaControlFlags and mediaCapabilities, nowPlayingInfo, supportedActions and volumeAvailable. Its events are attentionStateChanged, textInputChanged, mediaControlFlagsChanged, nowPlayingInfoChanged, supportedActionsChanged and volumeAvailabilityChanged. Like the MRP state it does no I/O: apply(name, content) feeds it one event.
import type { CompanionLinkSession } from '@corewire/companion-link';
declare const session: CompanionLinkSession;
session.state.on('attentionStateChanged', state => console.log('power', state));
session.state.on('textInputChanged', input => console.log('keyboard', input.isActive, input.documentText));Raw messages
Messages builds the OPack messages the groups send. session.stream is the encrypted Stream, with exchange(type, message) for a request and sendOPack(type, message) for an event. FrameType and MessageType name the frame and message types. Prefer the groups, which track replies and errors for you.
The session sends a heartbeat to keep the connection open. Companion Link has no request and response keepalive. A peer that vanished without closing the socket therefore ends the session only when TCP gives up on the heartbeat bytes.