From 8b76eecc9f3e502fd0d9b4d72b9cfc1173bbbfaa Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:12:50 +0200 Subject: [PATCH 1/4] Enhance ReceiverInfo with PTPInfo support Added PTPInfo key handling and updated ReceiverInfo struct. --- internal/airplay/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/airplay/client.go b/internal/airplay/client.go index 89caa35..a358415 100644 --- a/internal/airplay/client.go +++ b/internal/airplay/client.go @@ -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. @@ -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) } @@ -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 } From 0c1e5ed2c666f7c0352ee192875983c4e4252944 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:13:39 +0200 Subject: [PATCH 2/4] Add test for timing protocol selection in clients --- internal/airplay/mirror_clock_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/airplay/mirror_clock_test.go b/internal/airplay/mirror_clock_test.go index 593d17c..eefa978 100644 --- a/internal/airplay/mirror_clock_test.go +++ b/internal/airplay/mirror_clock_test.go @@ -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) From a1fa712655dd5178809c56f851db0ea298df79ea Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:13:58 +0200 Subject: [PATCH 3/4] Implement tests for selectAudioSecurityMode function Add tests for audio security mode selection. --- internal/airplay/mirror_setup_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/airplay/mirror_setup_test.go b/internal/airplay/mirror_setup_test.go index a66ac5f..1a7e43d 100644 --- a/internal/airplay/mirror_setup_test.go +++ b/internal/airplay/mirror_setup_test.go @@ -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) From 99fc2f99ea90366f586b5ceabf22e1ad7c171184 Mon Sep 17 00:00:00 2001 From: 3rd3 <2372391+3rd3@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:14:16 +0200 Subject: [PATCH 4/4] Refactor audio security mode and timing protocol handling --- internal/airplay/mirror.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go index ff7f83f..d832570 100644 --- a/internal/airplay/mirror.go +++ b/internal/airplay/mirror.go @@ -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 } @@ -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() } @@ -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{} @@ -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 { @@ -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"])