Turning My Phone Into a Linux Webcam
A Rust server and an Android app that make a phone a webcam and mic on Linux.
Contents
AirLink turns an Android phone into a webcam and microphone for a Linux desktop. Open the app and the phone’s camera shows up as a normal camera device called AirLink and its microphone as AirLink Microphone, in every video call app, with no drivers to configure and nothing to pay for.
Apps that do this already exist. Most of them are closed, split their features into paid tiers and fail in the same frustrating way: when something doesn’t work, you’re told to check your Wi-Fi and nothing else. AirLink is my attempt at one that’s free, open and tells you what’s actually wrong.
It has two parts:
- The desktop server, written in Rust. It finds the local network address, advertises itself, accepts the phone’s stream and writes it into a virtual camera and a virtual microphone.
- The Android app, written in Kotlin with Jetpack Compose. It finds the desktop, captures the camera and mic and streams them.
How it works#
A session goes through four stages:
- Discovery. The desktop advertises itself on the local network over mDNS, so the app finds it without anyone typing an IP address.
- Signaling. The app opens a secure WebSocket to the desktop and the two sides negotiate a WebRTC session.
- Media. The phone sends H.264 video and Opus audio over WebRTC, straight across the LAN.
- Output. The desktop decodes both and writes them into a virtual camera (
/dev/video20, via v4l2loopback) and a virtual microphone (via PulseAudio or PipeWire).
Everything stays on the local network. No ICE servers are configured, because both devices are on the same LAN and their local addresses are enough. AirLink keeps working with the router unplugged from the internet.
Where it started: raw UDP#
The first version, from late 2025, was a small server in Python and then C. The phone sent video as raw UDP packets and the server wrote each packet straight into a pipe feeding the decoder, in whatever order it arrived.
That design had a bug built into it. UDP doesn’t preserve order and an H.264 decoder that receives packets out of order corrupts part of a frame every time the network reorders something. On screen that shows up as green blocks, or a picture that freezes until the next keyframe. Ordinary Wi-Fi reorders packets often enough to make it visible.
There was a second bug, which is almost funny in hindsight. The C server advertised itself as _airlink._udp, while the Android app was searching for _procam._udp, a leftover from an earlier name. The two never matched, so automatic discovery never worked once.
In September 2026 I rewrote the desktop side in Rust and moved the transport to WebRTC, which fixed both problems properly instead of patching them.
The rewrite#
WebRTC instead of raw UDP#
WebRTC brings everything the raw UDP version was missing: RTP sequence numbers and timestamps, retransmission requests and a standard way to ask the sender for a fresh keyframe. The desktop registers exactly two codecs, H.264 for video and Opus for audio, because those are the two the rest of the pipeline can handle. Offering only what can actually be decoded avoids negotiating a format that would fail later.
A jitter buffer that fixes the original bug#
Incoming video packets go through a small reorder buffer before they reach the decoder:
- Packets are held briefly and released in sequence order.
- If a packet is missing, the buffer waits about two frame intervals at 60 fps for it to turn up. That’s long enough to cover ordinary Wi-Fi reordering and short enough that nobody notices the delay.
- If the gap still hasn’t filled, the buffer skips it deliberately and tells the session, which asks the phone for a new keyframe. The picture recovers cleanly instead of showing corrupted frames.
RTP sequence numbers are 16-bit, so they wrap around roughly every 20 minutes of video. The buffer works in an extended 64-bit sequence space and treats a forward jump of more than half the 16-bit range as a late packet from before the wrap, not a huge leap forward.
By the time video reaches ffmpeg, it’s a clean, ordered, gap-free stream. ffmpeg only has to decode it and convert the colours, which is the job it’s good at.
Audio, decoded in-process#
Audio takes a different path. Opus is decoded inside the Rust process instead of being piped to ffmpeg. Opus is a packet codec: when a packet is lost, the decoder can smooth over the gap, but only if it knows a packet was lost. That information doesn’t survive a plain byte pipe, so the decoding happens where the packet boundaries are still visible.
The decoded audio goes into a PulseAudio null sink with a remapped source on top, which is what makes AirLink Microphone appear in every app’s device list with no extra setup.
Discovery that works on real desktops#
mDNS needs one detail to be right that’s easy to get wrong. Only one process on a machine can hold the mDNS port (5353) and on most Linux desktops that process is already avahi-daemon, since GNOME and KDE both install and enable it. A second responder either fails to bind or gives inconsistent answers.
So when avahi is running, AirLink asks avahi to publish the service for it and only runs its own responder when avahi genuinely isn’t there. The machine I wrote it on had avahi installed but disabled, which is the rare case, so this is exactly the kind of bug that only shows up on everyone else’s machine.
Security without warnings#
The phone talks to the desktop over HTTPS and a secure WebSocket. No certificate authority will issue a certificate for a private IP address, so the desktop generates its own self-signed certificate.
Normally that means a scary browser warning. AirLink avoids it by pinning. The desktop puts the SHA-256 fingerprint of its certificate in its mDNS record (or in the QR pairing code) and the app checks that the server presents exactly that certificate. Pinning a known key is actually a stronger guarantee than trusting the public certificate authorities and it happens silently, with nothing for the user to accept or dismiss.
This matters more than it sounds. Without it, anyone else on the same Wi-Fi could run their own server, pretend to be your computer and feed a substitute image into your video call.
A few details make it practical:
- The certificate is cached and only regenerated when the machine’s LAN address changes, so a pinned fingerprint stays valid across restarts.
- The private key is written with permissions only the current user can read, because it’s a credential.
- The fingerprint is shown in the same colon-separated hex format that browsers and
openssl x509 -fingerprintuse, so a suspicious user can check it by hand.
Telling the user what’s wrong#
Most of the effort beyond the core pipeline went into failure messages. “It didn’t work” is the failure mode that sinks apps in this category, so AirLink tries hard to say why.
The virtual camera#
There are three different reasons /dev/video20 might be missing and each one needs a different fix:
| Situation | What AirLink tells you |
|---|---|
| v4l2loopback isn’t installed at all | The package to install, for your distribution (Arch, Debian/Ubuntu, Fedora or openSUSE) |
| It’s installed but not loaded | The exact modprobe command and how to make it load at boot |
| It’s loaded, but created a different device number | How to reload it with the right options |
Telling someone to run modprobe when the module was never installed just sends them to FATAL: Module v4l2loopback not found with no idea what to do next. So each case gets its own instructions. The same checks run through airlink doctor and the server runs them before printing its QR code, so a broken setup is obvious while it can still be fixed.
Live stats on the phone#
The WebSocket stays open for the whole session instead of closing after setup. Once video is flowing, the desktop reports what it is actually writing to the virtual camera, counted at the point where media reaches the virtual devices, not at the network socket.
That turns “it isn’t working” into something you can act on. If the phone can see that the computer is receiving 30 frames a second, the problem isn’t the network, it’s further along, in the app you’re calling from. If it sees nothing arriving, the network really is the problem.
Letting go cleanly#
When a session ends, the desktop explicitly releases the camera and microphone instead of leaving it to Rust’s automatic cleanup. The media tasks share those devices, so dropping one handle doesn’t guarantee they’re released. A virtual camera that outlives its session is what makes a video call app hang when you close it. AirLink handles SIGTERM as carefully as Ctrl-C, since that’s what systemd or a desktop session sends at logout and it only exits once ffmpeg has stopped and the audio modules are unloaded.
The Android app#
The app is written in Kotlin with Jetpack Compose and uses the WebRTC library for the media connection and OkHttp for signaling. A few things from its history:
- Real camera controls, with the screen acting as a viewfinder.
- Orientation is negotiated, not assumed, so a phone held upright produces an upright camera on the desktop. The desktop can reshape the virtual camera to match without renegotiating the whole session.
- A much smaller download, cut from 65 MB to 13 MB.
- Protocol contract tests on both sides, which check the app’s messages against the exact bytes the desktop sends, so the two can’t quietly drift apart.
Running it#
AirLink currently supports Linux. The virtual camera needs the v4l2loopback kernel module:
# Debian / Ubuntu (other distributions: run `airlink doctor` for the right command)
sudo apt install v4l2loopback-dkms linux-headers-$(uname -r)
sudo modprobe v4l2loopback video_nr=20 card_label=AirLink exclusive_caps=1
Then build and run the server:
git clone https://git.srtk.in/AirLink/server.git
cd server
cargo build --release
./target/release/airlink doctor # checks everything, and says how to fix what's missing
./target/release/airlink # starts the server
Open the app on a phone on the same network and it finds the desktop by itself. --size 1920 switches the output from 720p to 1080p.
Windows and macOS need entirely different mechanisms for virtual devices (a Media Foundation virtual camera on Windows, a CoreMediaIO plugin on macOS), so the output code is split by platform from the start, ready for those to be added.