Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion internal/airplay/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type ReceiverInfo struct {
PI string `plist:"pi"`
MacAddress string `plist:"macAddress"`
Displays []DisplayInfo `plist:"displays"`
hasPTPInfo bool
}

// AirPlay receiver status flags used to choose one authentication prompt.
Expand Down Expand Up @@ -281,7 +282,7 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) {
}
return keys
}())
for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats"} {
for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats", "PTPInfo"} {
if v, ok := fullInfo[key]; ok {
dbg("[INFO] %s: %+v", key, v)
}
Expand All @@ -292,6 +293,9 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) {
if _, err := plist.Unmarshal(resp, &info); err != nil {
return nil, fmt.Errorf("decode info plist: %w", err)
}
if _, ok := fullInfo["PTPInfo"]; ok {
info.hasPTPInfo = true
}
c.info = &info
return &info, nil
}
Expand Down
31 changes: 27 additions & 4 deletions internal/airplay/mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ type MirrorSession struct {
}

func selectAudioSecurityMode(encrypted bool) audioSecurityMode {
// Encrypted pair-verify sessions (Apple and third-party HAP) encrypt audio
// with a stream key advertised as shk. FairPlay ekey is a separate path and
// is not available on TVs that omit FPSAP. The SETUP *shape* (controlPort
// vs streamConnections) is chosen later from usesModernSessionSetup().
if encrypted {
return audioSecurityChaCha
}
Expand All @@ -155,6 +159,17 @@ func timingProtocolForSession(modern bool) string {
return timingProtocolNTP
}

func (i *ReceiverInfo) advertisesPTP() bool {
return i != nil && i.hasPTPInfo
}

func timingProtocolForClient(c *AirPlayClient, modern bool) string {
if modern || c != nil && c.info.advertisesPTP() {
return timingProtocolPTP
}
return timingProtocolNTP
}

func (c *AirPlayClient) usesModernSessionSetup() bool {
return c.encrypted && c.info != nil && c.info.usesModernPairing()
}
Expand Down Expand Up @@ -240,7 +255,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
senderName := pairingClientName()
modernSession := c.usesModernSessionSetup()
sourceVersion := sourceVersionForSession(modernSession)
timingProtocol := timingProtocolForSession(modernSession)
timingProtocol := timingProtocolForClient(c, modernSession)
var clock *mediaClock
if timingProtocol == timingProtocolPTP {
clock = &mediaClock{}
Expand Down Expand Up @@ -475,13 +490,19 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
audioStreamDesc["redundantAudio"] = int64(2)
}

// Modern HAP receivers look for shk on the audio stream descriptor.
modernAudio := audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32
// Modern Apple SETUP replaces controlPort with streamConnections.
// Third-party HAP TVs accepted PTP + controlPort; they still need shk or
// they silently drop plaintext ALAC.
modernAudio := modernSession && audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32
if modernAudio {
addModernScreenAudioStreamFields(audioStreamDesc, audioChaChaKey, audioControlLPort)
dbg("[SETUP] audio stream descriptor includes shk (%d bytes)", len(audioChaChaKey))
} else {
audioStreamDesc["controlPort"] = int64(audioControlLPort)
if audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 {
audioStreamDesc["shk"] = audioChaChaKey
dbg("[SETUP] audio stream descriptor includes shk (%d bytes) with legacy controlPort", len(audioChaChaKey))
}
}
var audioSetupPlist map[string]interface{}
if modernControlSetup {
Expand Down Expand Up @@ -517,7 +538,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig
skipRecord, _ = audioResp["skipRecord"].(bool)
if timingProtocol == timingProtocolPTP {
if err := clock.configureFromSetup(audioResp, audioRespHeaders, audioRespReceivedAt); err != nil {
return nil, fmt.Errorf("configure PTP media clock: %w", err)
// Third-party TVs advertise PTPInfo but often omit Apple clock
// headers. Keep the session; frames fall back to local time.
dbg("[PTP] %v; using local timestamps", err)
}
}
receiverEventPort = plistInt(audioResp["eventPort"])
Expand Down
14 changes: 14 additions & 0 deletions internal/airplay/mirror_clock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,20 @@ func TestTimingProtocolForSession(t *testing.T) {
}
}

func TestTimingProtocolForClientUsesAdvertisedPTP(t *testing.T) {
legacy := &AirPlayClient{info: &ReceiverInfo{}}
if got := timingProtocolForClient(legacy, false); got != timingProtocolNTP {
t.Fatalf("legacy without PTPInfo = %q, want NTP", got)
}
ptpTV := &AirPlayClient{info: &ReceiverInfo{hasPTPInfo: true}}
if got := timingProtocolForClient(ptpTV, false); got != timingProtocolPTP {
t.Fatalf("third-party with PTPInfo = %q, want PTP", got)
}
if got := timingProtocolForClient(&AirPlayClient{info: &ReceiverInfo{hasPTPInfo: true}}, true); got != timingProtocolPTP {
t.Fatalf("modern + PTPInfo = %q, want PTP", got)
}
}

func TestModernSessionSetupRequiresFirstPartyProfile(t *testing.T) {
const rokuFeatures = uint64(0x38bcf46007f8ad0)

Expand Down
9 changes: 9 additions & 0 deletions internal/airplay/mirror_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ type rtspTestRequest struct {
headers map[string]string
}

func TestSelectAudioSecurityMode(t *testing.T) {
if got := selectAudioSecurityMode(false); got != audioSecurityLegacyAES {
t.Fatalf("plaintext session audio mode = %v, want AES/legacy", got)
}
if got := selectAudioSecurityMode(true); got != audioSecurityChaCha {
t.Fatalf("encrypted HAP session audio mode = %v, want ChaCha", got)
}
}

func TestSourceVersionForSession(t *testing.T) {
if got := sourceVersionForSession(false); got != legacyAirPlaySourceVersion {
t.Fatalf("legacy source version = %q, want %q", got, legacyAirPlaySourceVersion)
Expand Down