Converging the macOS viewer onto ViewerSession
A working plan (not a commitment) for making the macOS app’s screen-share
viewer reuse the portable ViewerSession (Packages/TailscreenKit/Sources/
TailscreenViewer/) as its receive-side data plane, instead of the
bespoke logic in Apps/macOS/Sources/TailscaleScreenShareClient.swift.
Why
ViewerSession and TailscaleScreenShareClient already carry a near
line-for-line duplicate of the loss-recovery core: HELLO/HELLO_ACK, NACK,
receiver reports, PLI, and (since the FEC-ingest PR) FEC. Every new
loss-recovery behavior has to be written twice and can drift. ViewerSession
is the one that runs under linux-protocol / linux-viewer CI on every PR.
Converging means: one tested data-plane implementation, and the mac
client shrinks to socket + a ViewerSession + the mac-only side channels.
The payoff is architectural (one implementation, features land once), not a user-visible feature — so it’s a refactor to weigh against near-term work like the Linux sharer.
What converges vs. what stays mac-side
The key enabler, confirmed by reading the receive path: ViewerSession
never inspects a decoded frame’s contents — it only routes decoder output →
sink and runs bookkeeping.
Absorbed by ViewerSession (today duplicated in the client): HELLO /
HELLO_ACK, PING / RR, NACK, PLI, FEC, video reassembly, control-byte demux.
Stays mac-side, arranged around the session (not inside it): the
annotation TCP channel, the remote-control TCP channel, metadata /
request-to-share (already a wholly separate channel in AppState), the
approval UI + the “suppress idle-disconnect while awaiting approval” nuance,
the keepalive task, the idle-disconnect timer, stats-overlay counters, the
codec (0x07) / profile (0x09) fallbacks, and the decode-recovery
escalation ladder (VideoDecoder.decodeRecoveryAction).
The macOS audio path (VoiceChannel) is far richer than the session’s
naive Opus path (per-SSRC jitter buffer, gap concealment, decoder cooldown,
voice/system demux, dual AVAudioPlayerNodes), so ViewerSession must not
own audio decode on mac — see seam 3.
The three seam changes (Phase A)
A.1 — Frame opacity ✅ (landed)
Make the decoded video frame opaque to the session so a mac
CVPixelBuffer can flow decoder → sink without the portable target importing
CoreVideo, and without a per-frame CPU copy that would defeat the zero-copy
VideoToolbox → Metal path.
A DecodedFrame marker protocol (requiring only width / height — what a
generic decorator or the stats overlay needs, cheap for any backing) is the
frame currency. VideoDecoding.decode returns [any DecodedFrame] and
VideoSink.present takes any DecodedFrame; the session carries them
untouched. DecodedVideoFrame (CPU I420) is the Linux/default instantiation;
a mac CVPixelBufferBox will be the zero-copy one. The concrete sink
downcasts to reach pixels; the session and metadata-only decorators never do.
Chosen over genericizing ViewerSession<D, S> because ViewerPipeline and the
CLI wire the seam through existentials already, so the existential marker
adds near-zero ripple; the per-frame boxing cost is negligible at video rates.
Compile-time frame/sink pairing via generics stays open as a later refinement
if wanted.
A.2 — Async frame delivery ✅ (landed)
The mac decoder is asynchronous (VideoToolbox decompression → an
onDecodedFrame: (CVPixelBuffer) -> Void callback on VT’s thread); the
portable decode(...) -> [frame] was synchronous. The seam now has a decoder
emit frames via callbacks rather than return them: VideoDecoding gains
onDecodedFrame / onDecodeFailure and decode(...) returns Void (submit).
ViewerSession wires those in init — a decoded frame goes straight to the
sink, a failure to a PLI — so decode is now just a submit. FFmpeg fires the
callback synchronously inside decode (satisfying the threading contract for
free); VideoToolbox will fire it later, and the adapter must hop back to the
host’s serialization context before invoking it, because ViewerSession is
not Sendable and owns no queue — that contract is documented on the protocol.
Covered by a synchronous-stub path plus an explicit async-delivery test (a
stub that defers frames past decode and delivers on a later poke).
A.3 — Audio passthrough ✅ (landed)
ViewerSession.init gained an optional onAudioDatagram hook: when set,
inbound audio RTP (PT 98/99) is forwarded to the host verbatim and the
built-in AudioRTPDepacketizer + OpusVoiceDecoder path is skipped, so a
host with its own richer audio pipeline (macOS’s VoiceChannel — per-SSRC
jitter buffer, concealment, voice/system demux, dual AVAudioPlayerNodes)
owns decode, exactly as the mac client’s onAudioReceived → VoiceChannel
does today. nil (the default) keeps the built-in AudioSink path, so Linux
is unchanged. Covered by a passthrough test (a real PT-98 datagram is
forwarded byte-for-byte and the built-in path stays silent).
A.4 — Observation hooks ✅ (landed)
Lightweight onPLISent / onNACKSent / onFECRecovered callbacks on
ViewerSession so the mac stats overlay stays fed once the session owns loss-
recovery emission: they fire alongside every emitted PLI/NACK and each
FEC-recovered packet. The mac client wires them to the renderer’s
notePLISent / noteNACKSent / noteFECRecovered counters in
buildViewerSession; received-bytes/codec accounting rides a small
noteReceivedVideoStats helper in the receive loop (the session treats frames
as opaque, so byte/codec accounting stays host-side). Covered by hook tests in
ViewerSessionTests.
Adapter design (Phase B — in progress)
Landed in Apps/macOS/Sources/ViewerSessionAdapters.swift (not yet wired into
the client — that’s Phase C):
CVPixelBufferBox: DecodedFrame— the mac frame currency. Holds the VideoToolboxCVPixelBuffer(IOSurface-backed, Metal-compatible) so the zero-copy path survives routing throughViewerSession;width/heightcome fromCVPixelBufferGetWidth/Height.VTVideoDecoderAdapter: VideoDecodingwraps the existingVideoDecoder. It hides two mismatches inside the adapter: it extracts in-band SPS/PPS/VPS from each keyframe and callssetParameterSetsbeforedecode(so param-set install stops being a session concern), and it bridges VideoToolbox’s asynchronousCVPixelBuffercallback to the session’sonDecodedFrame— with the thread hop onto a host-suppliedcallbackQueue(the receive queue) the threading contract requires. EmitsCVPixelBufferBox. It also exposes mac-only pass-through hooks (onCodecUnsupported,onFrameDecodeFailed,onRecoveryAction,onRecovered) that bypassViewerSessionand carry the full CODEC_NO H.264 fallback + decode-recovery ladder to the client, so a session-build failure drives the codec fallback (not a plain PLI) — parity with the legacy loop.MetalSinkAdapter: VideoSinkforwardspresent(box)→renderer.setPixelBuffer(box.buffer, receiveUptimeNs:)(the timestamp the stats overlay wants rides on the box).- Audio: no adapter — the A.3 passthrough hook wires to
VoiceChannel.
Unit-tested by ViewerSessionAdapterTests (parameter-set extraction for
H.264/HEVC incl. the missing-set nil, and CVPixelBufferBox dimensions); the
live VT-decode + Metal path rides the existing on-CI
ScreenShareSyntheticFramesTests. These are macOS-only and verified by the
mac build/test CI job, not the Linux loop.
Migration order
- Phase A — the seam refinements above, in the package; update
Apps/linuxwiring + tests; Linux CI stays green. No mac change. Self-contained. - Phase B — write + unit-test the mac adapters (no client rewiring yet).
- Phase C — add a
ViewerSession-backed receive path inTailscaleScreenShareClientbehind a feature flag, legacy path intact; A/B them in dev (frame counts, loss recovery underscripts/net-impair.sh, stats parity). First cut landed (TAILSCREEN_VIEWER_SESSION=1): abuildViewerSessionfactory wires the adapters +onAudioDatagram→VoiceChannel+onControlToSend→ UDP, andreceiveLoopViaViewerSessionroutes datagrams toViewerSession.receiveRTP/tickand translates the session’s negotiated state (SSRC + caps, pending/denied/stopped) into the existing client callbacks. Default off; the legacy loop is untouched. This path is compile-verified on CI only — its runtime correctness needs a local A/B on a Mac (validated once undernet-impair.sh). Reach-parity landed: the stats overlay is now fed (received bytes/codec + PLI/NACK/FEC counters via the A.4 hooks), and theVTVideoDecoderAdapterroutes its codec-unsupported failure to the client’s HEVC→H.264CODEC_NOfallback and passes the decode-recovery ladder (onFrameDecodeFailed/onRecoveryAction/onRecovered) straight through. The last suspected gap — a legacy 5-byte HELLO_ACK (old sharer) leaving the session without an SSRC — turned out to be a non-issue: the session’s tolerantdecodeHelloAckCapsalready learns the SSRC from the 5-byte form (caps[], so the loss-recovery matrix degrades to PLI-only), now pinned by a regression test. The flagged path is therefore at full feature parity; what remains is runtime baking on a Mac before swapping the default (Phase E) and deleting the client’s duplicated loss-recovery code (Phase D). - Phase D + E — full cutover ✅ (landed). Because the duplicated
FEC/NACK/RR/PLI machinery lived entirely inside the legacy
receiveLoop(theViewerSessionpath never touched it), deleting the duplication and removing the legacy path were the same change — so D and E collapsed into one cutover.ViewerSessionis now the mac viewer’s sole receive path: theTAILSCREEN_VIEWER_SESSIONflag is gone, the legacyreceiveLoop+ingestVideoPacket/processRecoveredPacket/handleFECParityDatagram/deliverAU+ the whole feedback cluster (handleVideoFeedback/maybePeriodicFeedback/dispatchNACKAction/sendReceiverReport) and their fields (negotiatedCaps,nackScheduler,fecBuffer,rrAccounting, the depacketizer, PING/RR bookkeeping) are deleted (~550 lines). What stays mac-side, arranged around the session: the socket, the VT/Metal adapters, the annotation + remote-control TCP channels,VoiceChannelaudio, the keepalive + idle-disconnect plumbing, the stats overlay (fed via the session’s observation hooks + anoteReceivedVideoStatshelper), the CODEC_NO fallback, and the decode-recovery ladder (sendPLIUnthrottled). TheonDecodedFrameForTestingE2E seam is preserved via a newVTVideoDecoderAdapter.onDecodedPixelBufferForTestingpass-through. Compile-verified by the macbuild/testCI job; the live render + loss-recovery path is the local A/B (validated undernet-impair.shthrough Phase C).
Risks
- Zero-copy regression if the frame seam is wrong → mitigated by the
opaque
DecodedFrame(portable code never touches pixels). (A.1 addresses this.) - Threading: the VT-callback → single-queue session hop (A.2) → mitigated by hopping in the adapter to the client’s receive queue.
- Behavior drift: subtle mac nuances (idle-suppression while awaiting approval, PLI throttle, the decode-recovery ladder) must be preserved outside the session → mitigated by the flag + A/B comparison and keeping those concerns mac-side.
- Multi-PR effort with an architectural (not user-facing) payoff — weigh against the Linux sharer.