Porting plan: Linux & Windows
This is a working plan, not a commitment. It records what porting Tailscreen off macOS actually involves: what carries over, what must be rebuilt per platform, and the specific protocol/architecture problems a port surfaces — several of which are worth fixing on macOS first, while there’s only one implementation to migrate.
Status: Phases 0 and 1 are done — the portable core (a real SwiftPM dependency of the app since the flip) builds and passes smoke tests on Linux, TailscaleKit builds and tests on Linux, and the live two-node tsnet exchange is verified (see Phase 1 below). Phases 2+ are proposal.
What carries over as-is
- Transport.
libtailscaleis Go; tsnet is fully cross-platform.go build -buildmode=c-archiveworks on Linux today (Windows needs a spike — see the effort/spike note under Phasing). Ephemeral-node identity, LocalAPI peer lookup, and the StableNodeID admission keying all carry over unchanged. - The wire protocol. Every byte on port 7447 is pinned by
WireByteRegistryTestsand the round-trip suites, and the protocol was designed to degrade per capability (extended HELLO caps, unknown type-byte skip, PT auto-detect). A new client that implements only the base profile — HELLO, RTP depacketize, PLI — interoperates with every shipped macOS sharer, then earns NACK/RR/FEC incrementally by advertising caps. - The portable core (~7 k lines, 29 files + the 3-file transport tier incl. interactive auth). RTP packetization, TCP
framing, UDP control codecs, NACK/retransmit/FEC/RR loss recovery, the
congestion/fairness decision functions, remote-control gate/coalescing
policy, zoom math, tuning constants. This is
TailscreenProtocoland it compiles and runs on Linux now.
What must be rebuilt per platform
| Subsystem | macOS (today) | Linux | Windows |
|---|---|---|---|
| Capture | ScreenCaptureKit (helper subprocess) | PipeWire via org.freedesktop.portal.ScreenCast |
Windows.Graphics.Capture (WinRT); DXGI duplication fallback |
| Picker | SCContentSharingPicker (helper) |
The portal’s own consent dialog is the picker | GraphicsCapturePicker |
| Encode | VideoToolbox H.264/HEVC | VA-API / NVENC (via FFmpeg), x264/x265 software fallback | Media Foundation HW encoders, or FFmpeg (NVENC/AMF/QSV) |
| Decode | VideoToolbox | FFmpeg (libavcodec) + VA-API hwaccel | Media Foundation / FFmpeg + D3D11VA |
| Render | Metal (CAMetalLayer) |
Vulkan or OpenGL; SDL as the pragmatic first cut | D3D11 swapchain; SDL again viable |
| Voice + system audio | CoreAudio / AVFoundation for I/O; Opus (OpusKit/libopus) codec — done | PipeWire (capture + playback); Opus already portable | WASAPI (loopback capture is first-class); Opus already portable |
| Remote-control injection | CGEvent + Accessibility TCC |
org.freedesktop.portal.RemoteDesktop (consent UX ≈ TCC) |
SendInput |
| Global hotkeys | Carbon | GlobalShortcuts portal (newer compositors only) | RegisterHotKey |
| Tray/menubar UI | SwiftUI MenuBarExtra + AppKit |
StatusNotifierItem + GTK4/libadwaita (or minimal custom) | Shell_NotifyIcon + Win32/WinUI shim |
| Notifications | UserNotifications | org.freedesktop.Notifications |
WinRT toasts |
Swift itself runs on both targets (Linux is mature; Windows is workable — swift-foundation replaced the old corelibs port). The alternative of writing non-macOS clients in Go or Rust against the pinned protocol remains open; this plan assumes Swift so the portable core is shared, but nothing in the protocol requires it.
Problems a port surfaces (found while extracting Phase 0)
These are the concrete details worth knowing before committing to a sharer port. The first two have wire-protocol implications and are cheapest to address now, additively, while all peers are macOS.
RESOLVED (pre-1.0 breaking change). The wire now carries USB HID keyboard-page usage IDs plus a five-bit platform-neutral modifier set (InputEventbakes the mac key model into the wire.KeyModifiers: shift/control/alt/meta/capsLock) instead ofCGKeyCode+ rawCGEventFlags; buttons grewmiddle, and button/scroll events carry the modifier snapshot so modified clicks work cross-platform. macOS endpoints translate through the bijectiveMacKeyCodeMappingtable (itself part of the portable package — a non-mac peer needs the same table to interoperate with mac endpoints). A Linux/Windows peer now only needs its own native↔HID table, which every platform ships.- App-share pointer confinement may not be portable. The security
property that an application share clamps injected pointer events to
the app’s window-rect union (
RemoteControlMapping.captureRect) depends on global desktop coordinates, which Wayland deliberately hides. The RemoteDesktop portal injects relative to the captured stream, which is equivalent for display/window shares but has no app-union concept. Decision needed: on Linux, either restrict remote-control grants to display/window shares, or accept whole-stream confinement. (Multi-app capture is also not a portal concept —PickerSelection.kindapp/multi-app maps only to macOS and partially to Windows.) - AVCC vs Annex-B. The RTP payloads carry AVCC-formatted NALs with in-band parameter sets, matching VideoToolbox’s native output. FFmpeg and Media Foundation speak Annex-B by default — every non-VT encoder and decoder adapter needs start-code ⇄ length-prefix conversion and extradata (SPS/PPS/VPS) extraction. Mechanical, but it must live in the shared adapter layer, not be reinvented per platform.
- Encoder rate-control semantics don’t transfer.
EncoderTuningis calibrated to VideoToolbox’s quality key +DataRateLimits; VA-API, NVENC, and MF each have different rate-control modes. The congestion controller’s contract is portable (set-bitrate, force-keyframe, set-frame-interval — exactly the capture-helper command set), so define the encoder adapter in those terms and calibrate per backend. - System-audio self-exclusion is hard on Linux. macOS uses
excludesCurrentProcessAudioso viewers’ voices played by Tailscreen are never re-captured into the PT-99 stream (echo). Windows has process-exclusion loopback (Win10 21H1+). PipeWire needs explicit routing (capture a sink the app doesn’t play into, or a filter-chain) — design the Linux audio graph up front or ship system audio later. AAC on Linux is awkward.RESOLVED — switched to Opus. fdk-aac is license-encumbered for distribution and FFmpeg’s native AAC encoder is worse, so rather than negotiate a second codec we replaced AAC outright with Opus (royalty-free, software-only, portable). It lives in the localOpusKitPackage(asystemLibrarywrapper over libopus, CI-gated on Linux bylinux-opus) and is wired into the app via the portableTailscreenAudiotier (OpusVoiceEncoder/OpusVoiceDecoder, 960-sample / 20 ms frames). The RTP payload types (98 voice / 99 system) are unchanged — pre-1.0 with no deployed AAC-only peers, so no negotiation was needed.TailscaleKit’s Swift wrapper needs a portability audit.RESOLVED — audited, patched (022), and verified live on Linux. The fixes were exactly the expected small ones:canImport(Combine)gates with anAsyncStreamfallback for the two state publishers, a Glibc shim for theDarwin.-qualified syscalls,FoundationNetworkingimports for URLSession types, compiling out the Network.framework SOCKS extension, and havingLocalAPIClienttalk to the tsnet loopback listener directly (it already sent the auth headers; the SOCKS hop was redundant for LocalAPI). The audit also surfaced and fixed a latent patch-stack bug (021 carried a stale hunk that GNU patch fuzz-fitted into duplicate Go exports — invisible on macOS’s BSD patch).TailscalePeerDiscovery/TailscaleIPNWatcherare now unblocked to join the portable set.- The app-state layer is Combine/SwiftUI-shaped.
ObservableObject/@Publisheddon’t exist off-Apple.PortabilityShims.swiftnow provides non-Apple stand-ins (including a$prop.values-compatible stream) so state classes like the transport tier’s can join the portable set unchanged — but there’s still no SwiftUI/objectWillChange machinery: non-mac UIs observe state their own way, and heavily SwiftUI-bound state (AppState) stays mac-side. (ViewerAccessPolicyStoreand theShareLockadvisory mutex compile via the shims and are in the portable set.) - Localization.
L(_:)ridesString(localized:bundle:), which is Apple-Foundation. Non-mac UIs need their own catalog mechanism; don’t pullLocalization.swiftinto the portable set. - The helper-process architecture is a macOS workaround, not a
design requirement.
replayd/TCC coupling is the only reason capture lives in a subprocess. On Linux the portal session can live in-process (the portal handles consent and revocation); on Windows likewise. Keep helper isolation as an option for crash containment, not a porting requirement — but then the hung-helper watchdog and restart budget logic need an in-process analog. - Color management shrinks off-mac. P3/HDR capture-and-tag depends
on
CGColorSpace/EDR probing and VT writing the SPS VUI. Linux/Windows Phase 1 should pin BT.709 8-bit (the wire already handles this — the viewer PROFILE_NO fallback anddowngradedTo8Bitexist), with wide gamut revisited per-encoder later. - Renderer lifetime quirks are per-platform. The mac viewer holds
its
NSWindowfor process lifetime to dodge a VideoToolbox/Metal teardown race. Vulkan/D3D swapchains have their own teardown orderings; budget time for the equivalent bug hunt.
Phasing
Phase 0 — portable core (done). TailscreenProtocolPackage compiles
the wire protocol + decision logic on Linux with smoke tests; the
linux-protocol CI job keeps it that way.
Phase 1 — transport spike (done). Verified on a Linux host
(Ubuntu 24.04, Swift 6.1.2, Go 1.24): libtailscale.a builds as a Linux
c-archive, the patched TailscaleKit wrapper compiles warning-free and
passes its unit tests (both now enforced by the linux-tailscalekit CI
job), and — live against a native headscale (scripts/e2e-up-native.sh
already runs on Linux) — two Swift tsnet nodes came up, exchanged TCP
(listener/dial/send/receive), exchanged UDP datagrams through the patched
PacketListener socketpair bridge (the exact transport the RTP video
path uses), and served LocalAPI status over the direct loopback path
(backend=Running, peer visible — peer discovery works). Notably the
WireGuard handshake that GitHub’s macOS runner sandbox blocks completed
without issue on Linux, so promoting the live two-node exchange to a
Linux CI job is a realistic follow-up. TailscalePeerDiscovery /
TailscaleIPNWatcher are in the portable set (the
TailscreenTransport target, Linux-built in CI) — a non-macOS client
gets tailnet bring-up, peer discovery, and IPN-bus watching from the
same sources the mac app ships.
Phase 2 — Linux viewer. Headless first: dial a macOS sharer, HELLO,
depacketize, FFmpeg-decode, assert on decoded frames (the Linux twin of
ScreenShareSyntheticFramesTests). Then an SDL/Vulkan window, audio
playback, PLI/NACK/RR/FEC (already in the portable core), and annotations
out. (Remote-control input capture is deprioritized — see the effort note
below.) A viewer-only Linux release is a shippable milestone on its own.
Phase 3 — Linux sharer. Portal capture → encoder adapter (#3, #4) → the existing broadcast/fan-out logic, which is already extracted into pure decision functions. Then admission UI, voice, system audio (#5, #6). Tray UI last. (Remote-control injection via the RemoteDesktop portal (#2) is deprioritized — the confinement question makes it the highest-risk piece, and it isn’t on the near path; see the effort note below.)
Phase 4 — Windows. Viewer first, reusing the Phase 2/3 adapter seams (capture/encode/decode/render/audio/input behind protocol-shaped interfaces is the real deliverable of Phases 2–3). Gated on a Windows transport spike (below) — the equivalent of Phase 1, but unproven and therefore the schedule risk, so it must come first.
Effort: viewer vs sharer, Linux vs Windows
The phases above are not equal-sized, and the asymmetry is worth stating so the sequencing decisions are on record.
A viewer is ~3× cheaper and lower-risk than a sharer, on either OS. A viewer consumes streams: its receive-side loss recovery (NACK/RR/FEC depacketize), admission-as-viewer, annotation-send, and input-capture are already portable and CI-tested, so what’s new is a decoder (FFmpeg — blessed path), a window (SDL/Vulkan or D3D11), and audio-out. Each has an obvious right answer, and the result ships standalone (“watch a Mac’s screen from Linux/Windows”). A sharer produces streams: it adds three new OS-integration edges (capture in, encode, and — deprioritized — inject out) that each have no single blessed path, plus the interactive consent surfaces. What already exists and shrinks the sharer: the entire server decision layer (congestion, fairness, FEC sweep, admission, retransmit) is portable and tested, the control-plane listener is portable, and the capture-helper wire protocol is portable — the sharer’s “brain” is done; only the I/O edges are new.
Linux sharer — the schedule risks are (1) capture: PipeWire via
xdg-desktop-portal ScreenCast has no FFmpeg-equivalent blessed path
(portal negotiation, permission dialogs, DMABUF/SHM formats, compositor
and Wayland-vs-X11 differences), and (2) encoder rate-control
calibration (#4), which only proves out on a real impaired network.
Windows sharer — counterintuitively, the media side is easier
than Linux: Windows.Graphics.Capture is one well-documented WinRT API
with a built-in GraphicsCapturePicker capturing to D3D11 textures (no
portal/PipeWire/compositor zoo); WASAPI loopback capture is
first-class and process-exclusion loopback (Win10 21H1+) solves the
system-audio self-exclusion problem (#5) that needs filter-chain routing
on PipeWire; and Media Foundation ships an in-box AAC encoder (no
fdk-aac licensing, #6). Three of the six port problems are easier on
Windows, one (AVCC↔Annex-B, #3) is identical, one (#4) is the same
everywhere. The catch is the substrate, not the media: Swift-on-
Windows is officially supported but rougher than Linux (fewer runners,
less-complete FoundationNetworking); libtailscale as a Windows
c-archive/DLL has never been exercised (CGO needs a mingw/MSVC C
toolchain); and TailscaleKit needs a third shim tier beside patch 022
— WinSDK/Winsock for the syscalls (closesocket, WSAPoll, SOCKET
handles) whose semantics differ from POSIX. So the Windows risk is
“does the Swift + tsnet base stack come up at all,” front-loaded into the
transport spike, rather than spread through the capture work like Linux.
Net: comparable total effort to the Linux sharer, different risk shape.
Windows transport spike (Phase 4 gate). Before any Windows capture
work: build libtailscale.a/.dll on Windows (Go c-archive with a C
toolchain), write the Windows shim tier for the TailscaleKit wrapper, get
swift build linking, and bring a tsnet node up against a local
headscale — the exact shape of Phase 1, but on the less-proven substrate.
The Windows viewer doubles as this spike: it forces the whole base
stack up before a line of capture code, and ships as a product if it
works. (Swift-on-Windows maturity moves fast — re-check its current state
when scheduling this, rather than trusting a dated assessment.)
Note on remote control: it’s deprioritized, which shifts the
estimates. On Linux it was the hardest item — the app-share
pointer-confinement security property (#2) depends on global coordinates
Wayland hides, a design decision, not a compile-fix — so dropping it
removes the worst Linux unknown. On Windows injection would’ve been one
of the easiest items (SendInput is global and simple, no confinement
problem), so deprioritizing it helps Linux more than Windows.
Continuous. Migrate the pure test suites (RTPPacketTests,
FECCodecTests, NACKSchedulerTests, ParserFuzzTests, …) from the main
package into TailscreenProtocolPackage so they run on Linux CI too — they
test portable code but currently import the mac-only Tailscreen module.
The flip of the macOS app to depend on the protocol package (instead of
the symlink-sharing Phase 0 started with) is done — the access-control
migration (internal → public across the module boundary, explicit
memberwise inits) was driven blind via macOS CI and converged in one
fixup round.
Explicit non-goals (for now)
- iOS/Android viewers — different UI stacks entirely; the protocol would carry over, but nothing else here does.
- Feature parity on day one. The capability-negotiation design means a port can ship base video + PLI and grow into NACK/RR/FEC/audio/remote control release by release, interoperating the whole way.