QUIC Transport with Network.framework: Beyond TCP and UDP
QUIC Transport with Network.framework: Beyond TCP and UDP
QUIC is the transport protocol that makes modern remote desktop streaming possible. It gives you the reliability of TCP (for control messages) and the speed of UDP (for video frames) in a single multiplexed connection with built-in TLS 1.3 encryption.
Apple's Network.framework provides native QUIC support since macOS 14 and iOS 17. Astropad Workbench uses QUIC as its primary transport (via the Rust quinn-proto library), but for a Swift-first approach, Network.framework is the way to go.
Why QUIC for Remote Desktop?
| Feature | TCP | UDP | QUIC | |---------|-----|-----|------| | Reliability | Yes | No | Configurable per-stream | | Multiplexing | No (head-of-line blocking) | Manual | Built-in streams | | Encryption | Separate TLS | Manual | Built-in TLS 1.3 | | Connection migration | No | No | Yes (WiFi↔cellular) | | 0-RTT reconnection | No | N/A | Yes | | Datagrams (unreliable) | No | Yes | Yes (QUIC datagrams) |
For remote desktop, you need:
- Reliable streams for control (codec params, session config, clipboard sync)
- Reliable streams for input events (mouse, keyboard — can't lose these)
- Unreliable datagrams for video frames (losing a frame is better than waiting for retransmission)
QUIC gives you all three over a single connection.
Server Setup (Host Mac)
import Network
class StreamServer {
private var listener: NWListener?
private var sessions: [StreamSession] = []
func start(port: UInt16 = 9876) throws {
// Create QUIC parameters with TLS
let quicOptions = NWProtocolQUIC.Options()
quicOptions.alpn = ["liquid-v1"] // Application-Layer Protocol Negotiation
// For development: use a pre-shared key or self-signed cert
// For production: use a proper TLS certificate
sec_protocol_options_add_pre_shared_key(
quicOptions.securityProtocolOptions,
pskData as __DispatchData,
pskIdentity as __DispatchData
)
let parameters = NWParameters(quic: quicOptions)
listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port)!)
listener?.newConnectionHandler = { [weak self] connection in
self?.handleNewConnection(connection)
}
listener?.stateUpdateHandler = { state in
switch state {
case .ready:
print("Server listening on port \(port)")
case .failed(let error):
print("Server failed: \(error)")
default:
break
}
}
listener?.start(queue: .main)
}
private func handleNewConnection(_ connection: NWConnection) {
let session = StreamSession(connection: connection)
sessions.append(session)
session.start()
}
}
Client Setup (Viewer)
class StreamClient {
private var connection: NWConnection?
func connect(host: String, port: UInt16 = 9876) {
let quicOptions = NWProtocolQUIC.Options()
quicOptions.alpn = ["liquid-v1"]
// Match server's PSK or TLS config
sec_protocol_options_add_pre_shared_key(
quicOptions.securityProtocolOptions,
pskData as __DispatchData,
pskIdentity as __DispatchData
)
let parameters = NWParameters(quic: quicOptions)
connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: parameters
)
connection?.stateUpdateHandler = { state in
switch state {
case .ready:
print("Connected to host")
self.openStreams()
case .failed(let error):
print("Connection failed: \(error)")
default:
break
}
}
connection?.start(queue: .main)
}
}
Channel Design
We multiplex multiple logical channels over a single QUIC connection:
Control Stream (Bidirectional, Reliable)
Used for session negotiation and ongoing control:
- Codec parameters (SPS/PPS/VPS for decoder init)
- Display info (resolution, refresh rate)
- Keepalive pings (latency measurement)
- Session teardown
Input Stream (Client→Host, Reliable)
Carries user input events:
- Mouse move, click, scroll
- Keyboard press/release
- Apple Pencil pressure/tilt
- Clipboard data
Video Datagrams (Host→Client, Unreliable)
Carries encoded video frames:
- Each datagram is one NAL unit (or fragment thereof)
- Lost frames are simply skipped — the next keyframe recovers
- No retransmission delay = lowest latency
Opening QUIC Streams
// Open the control stream (bidirectional)
func openControlStream() {
let controlStream = connection?.createStream(direction: .bidirectional)
controlStream?.stateUpdateHandler = { state in
if case .ready = state {
self.sendSessionConfig(on: controlStream!)
}
}
controlStream?.start(queue: .main)
}
Sending Video via Datagrams
// Send encoded frame as QUIC datagram
func sendVideoFrame(data: Data, frameId: UInt32, isKeyframe: Bool, timestamp: UInt32) {
// Build frame header
var header = Data(capacity: 11)
header.append(contentsOf: withUnsafeBytes(of: frameId.bigEndian) { Array($0) })
header.append(isKeyframe ? 0x01 : 0x00) // flags
header.append(contentsOf: [0x00, 0x01]) // fragment 0 of 1
header.append(contentsOf: withUnsafeBytes(of: timestamp.bigEndian) { Array($0) })
let packet = header + data
// Fragment if larger than max datagram size (~1200 bytes)
if packet.count > 1200 {
sendFragmented(data: data, frameId: frameId, isKeyframe: isKeyframe, timestamp: timestamp)
} else {
connection?.send(content: packet, contentContext: .datagram,
isComplete: true, completion: .idempotent)
}
}
func sendFragmented(data: Data, frameId: UInt32, isKeyframe: Bool, timestamp: UInt32) {
let maxPayload = 1200 - 11 // header size
let totalFragments = (data.count + maxPayload - 1) / maxPayload
for i in 0..<totalFragments {
let start = i * maxPayload
let end = min(start + maxPayload, data.count)
let fragment = data[start..<end]
var header = Data(capacity: 11)
header.append(contentsOf: withUnsafeBytes(of: frameId.bigEndian) { Array($0) })
header.append(isKeyframe ? 0x01 : 0x00)
header.append(UInt8(i)) // fragment index
header.append(UInt8(totalFragments)) // total fragments
header.append(contentsOf: withUnsafeBytes(of: timestamp.bigEndian) { Array($0) })
let packet = header + fragment
connection?.send(content: packet, contentContext: .datagram,
isComplete: true, completion: .idempotent)
}
}
Receiving Datagrams
func receiveDatagrams() {
connection?.receiveMessage { [weak self] data, context, isComplete, error in
if let data = data, context?.protocolMetadata(definition: .quic) != nil {
self?.handleReceivedDatagram(data)
}
// Continue receiving
self?.receiveDatagrams()
}
}
func handleReceivedDatagram(_ data: Data) {
guard data.count >= 11 else { return }
let frameId = data[0..<4].withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
let flags = data[4]
let fragIndex = data[5]
let totalFrags = data[6]
let timestamp = data[7..<11].withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
let payload = data[11...]
let isKeyframe = (flags & 0x01) != 0
if totalFrags == 1 {
// Complete frame
decoder.decode(Data(payload), isKeyframe: isKeyframe, timestamp: timestamp)
} else {
// Fragment — reassemble
reassembler.addFragment(frameId: frameId, index: Int(fragIndex),
total: Int(totalFrags), data: Data(payload),
isKeyframe: isKeyframe, timestamp: timestamp)
}
}
What Astropad Does
From binary analysis, Astropad's networking is more sophisticated:
liquid_net::runtime::datagram— Custom datagram handling withpacket_reader,packet_sender,transmitter,receiver, andpulse(keepalive)liquid_net::router— Message routing across channels: Video, StateSyncEvent, ClipboardSync, MultiplayerInputCoordination, Diagnostics, and moreliquid_net::sockets::encrypted_udp— Additional encryption layer beyond QUIC's TLSliquid_net::throttle— Bandwidth throttlingliquid_net::runtime::network_quality— Real-time quality monitoring
They use quinn-proto 0.11.9 (Rust QUIC) rather than Network.framework, likely for cross-platform consistency and finer control over congestion algorithms.
Latency Measurement
Use the control stream for ping/pong latency measurement:
func measureLatency() {
let sendTime = mach_absolute_time()
sendPing(on: controlStream)
// In pong handler:
let receiveTime = mach_absolute_time()
let rtt = machTimeToMilliseconds(receiveTime - sendTime)
let oneWayLatency = rtt / 2.0
}
Connection Migration
One of QUIC's killer features: if the client switches from WiFi to cellular, the connection survives. Network.framework handles this automatically — the NWConnection maintains its state across network transitions.
Part 4 of the "Building a Remote Desktop from Scratch" series. Based on reverse engineering analysis of Astropad Workbench 1.1.0.