Use an Android tablet as a second monitor on Linux/Wayland, over USB. An alternative for Hyprland - wired, not wireless.
Plug the tablet in and a virtual monitor appears. Unplug it and the monitor disappears. Wayland-native, hardware-encoded, zero-copy - no VNC, no RDP, no X11.
More land: more screen real estate. And it lives next door to Wayland and Hyprland.
~19 ms host → rendered on tablet, median round trip at 120 fps
120 fps at 1920x1200 (90 is the default; 144 saturates)
16 Mbps 6% of the measured USB 2.0 ceiling
0.6% of one CPU core for capture
Scope. Verified on exactly one setup: Hyprland + AMD VA-API + a Xiaomi Pad 6. Other GPUs are plausible and untested; other compositors need work. See Compatibility. Every number here is measured on that hardware, not estimated - see
docs/for how.
Plug the cable in, and the tablet becomes a monitor - no pairing, no app to launch on the host, no settings dialog:
Hyprland ──IPC──▶ headless output, sized to your tablet's aspect ratio
│
ext-image-copy-capture-v1 (damage-driven, DMA-BUF)
▼
DMA-BUF on the GPU ← pixels never touch the CPU
│ vapostproc: XR24 → NV12, in-GPU
▼
vah264enc VBR, no B-frames
│ [len][pts][flags][Annex-B]
▼
adb forward ──▶ localabstract socket ──▶ USB
▼
MediaCodec (hardware) ──▶ SurfaceView
The whole path from compositor to encoder is zero-copy: the buffer Hyprland renders into is the same buffer the video encoder reads. That holds only while the compositor renders on the GPU that encodes - on a dual-GPU laptop it is easy to have it silently not hold. See Multi-GPU hosts.
Host
- Hyprland, or labwc with
wlr-randr(see Compatibility for others) - A GPU with VA-API encode - AMD, Intel, or NVIDIA via
nvidia-vaapi-driver gstreamer,gst-plugins-base,gst-plugin-va,libvaandroid-tools(adb), Rust toolchain
Arch / EndeavourOS:
sudo pacman -S --needed rust gstreamer gst-plugins-base gst-plugins-good \
gst-plugin-va libva android-tools android-udev wayland-utilsTablet
- Android 10+ (API 29), with hardware H.264 decode - effectively any tablet
- USB debugging enabled
- A USB data cable - charge-only cables will not enumerate
USB 2.0 is fine. It measured 13× more bandwidth than this needs; USB 3 buys nothing here.
git clone https://github.com/adiimanav/moreland.git moreland && cd moreland
./install.shInstalls ~/.local/bin/moreland and a systemd user unit. Nothing needs
root and nothing is written outside $HOME.
Settings → About tablet → tap Build number (MIUI/HyperOS: OS version) seven times → back → Developer options → USB debugging. Replug and accept the RSA prompt.
Verify: adb devices -l should list the tablet as device.
Needs the Android SDK (ANDROID_HOME), platform 34, and JDK 17:
cd android
ANDROID_HOME=/opt/android-sdk ./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apkINSTALL_FAILED_USER_RESTRICTED on Xiaomi / MIUI / HyperOS
MIUI blocks ADB installs by default. Either enable Developer options → Install via USB, or sideload without an account:
adb push app/build/outputs/apk/release/app-release.apk /sdcard/Download/then on the tablet: Files → Downloads → tap the APK → Install.
moreland # foreground
systemctl --user enable --now moreland.service # on login
journalctl --user -u moreland -f # logsPlug the tablet in. A monitor appears; drag windows to it.
moreland watch for the tablet; stream whenever plugged in
moreland --once stream one session, then exit
moreland --seconds 15 stop after 15 s and print latency statistics
--max-width <PX> cap the auto-detected width [default: 1920]
--native stream at the tablet's full panel resolution
--width <PX> --height <PX> pin an explicit resolution (both required)
--fps <N> virtual output refresh rate [default: 90]
--bitrate <KBPS> H.264 target bitrate [default: 20000]
--position <X> x offset of the virtual output [default: 0]
--position-y <Y> y offset of the virtual output [default: 1080]
default sits below a primary at 0x0, no taller
than 1080px; override either axis to fit your
layout
--output-name <NAME> name of the virtual output [default: moreland]
required on labwc, which cannot create one and
must be pointed at an existing headless output
--show-cursor composite the mouse cursor into the stream
Resolution is automatic. The daemon reads the tablet's panel size over ADB
and picks a matching aspect ratio, capped at --max-width so the encoder is not
asked to do more than the screen can show:
2880x1800 (16:10) -> 1920x1200
2560x1440 (16:9) -> 1920x1080
1280x800 -> 1280x800 (already below the cap)
--native streams the panel's full resolution. On a 2880×1800 tablet that is
2.5× the pixels of 1080p and will push the encoder hard for detail you cannot
resolve on an 11" screen - measure before keeping it.
Measured at 1920×1200@60 on a Ryzen 7 5800HS with AMD Vega, streaming to a Xiaomi Pad 6 over USB 2.0.
| Stage | Latency |
|---|---|
| Capture → DMA-BUF | 2.1 ms |
| VAAPI encode | 6.3 ms |
| Transport + decode + display queue | 24.2 ms |
| Panel scanout | 7–16 ms (not measurable in software) |
| Glass to glass | ~33–48 ms |
Sustained 60.07 fps, 99.7% of frames acknowledged as rendered, 16.2 Mbps.
Round trip is host send → device render → host ack, measured over 25 s with a full-screen animating client on the virtual output. Measuring against an idle output instead reports ~1 fps and a ~800 ms median — that is the compositor correctly not redrawing a static screen, not the pipeline.
--fps |
frames | unacked | median | p95 | max |
|---|---|---|---|---|---|
| 60 | 1193 | 3 | 33.5 ms | 34.8 ms | 62.6 ms |
| 90 | 1567 | 6 | 25.5 ms | 33.4 ms | 61.1 ms |
| 120 | 2022 | 22 | 18.8 ms | 29.0 ms | 50.8 ms |
| 144 | 1764 | 163 | 26.4 ms | 31.3 ms | 208.5 ms |
Higher frame rates lower latency, which is not obvious: a shorter frame interval means less time waiting for the next slot. They also cost fewer bytes — 49.7 MB at 60 fps versus 41.9 MB at 120 — because smaller inter-frame deltas compress better. 144 is past the wall: a tenth of its frames go unacknowledged and the tail blows out to 208 ms.
At a fixed bitrate, more frames means fewer bits each, so raise --bitrate
alongside --fps for video or photo work. At 120 fps the encoder drew 16 Mbps
against a measured 275 Mbps USB ceiling, so headroom is not the constraint.
On a laptop with both an integrated and a discrete GPU, the compositor must render on the same GPU that encodes. If it does not, every captured frame is copied across the PCIe bus before the encoder sees it, and the "zero-copy" path above is not zero-copy at all.
Check which GPU your panel is actually wired to:
for s in /sys/class/drm/card*-*/status; do
[ "$(cat "$s")" = connected ] && echo "$(basename "$(dirname "$s")")"
doneOn the reference machine the internal panel hangs off the iGPU while the
compositor was rendering on the dGPU — so frames crossed the bus twice, once to
reach the encoder and once to reach the screen. Pinning Hyprland to the iGPU
(AQ_DRM_DEVICES) removed both copies and let the dGPU runtime-suspend. Note
that AQ_DRM_DEVICES is colon-separated, so /dev/dri/by-path/ names cannot be
used in it, and cardN numbering follows module probe order — a udev SYMLINK+=
is the stable way to name a device.
Sub-20 ms glass to glass is still not achievable, and the 18.8 ms above is not a counter-example: that is host send → device render → host ack, which stops at the point the tablet's compositor accepts the frame. Panel scanout (7–16 ms, not measurable in software) sits after it, so glass to glass at 120 fps is roughly 26–35 ms. The display pipeline on the tablet dominates and is not ours to control. For calibration, scrcpy - the most optimised project in this space - measures 25–45 ms running the same pipeline in the opposite direction. This is fine for video, documents, terminals, and browsing; it is not fine for gaming or stylus work.
| Status | |
|---|---|
| Hyprland | Verified, including 0.56's Lua config parser (see below) |
| labwc | Works, contributed and used by its author, untested here - you create the headless output, moreland attaches to it (details) |
| Sway / other wlroots | Capture should work unchanged; output creation unimplemented |
| KDE Plasma (KWin) | Blocked, tested on KWin 6.7.4 - implements no ext-/wlr- capture protocol; needs a PipeWire backend |
| GNOME (Mutter) | Requires a portal/PipeWire capture backend; Mutter implements neither wlr nor ext capture protocols |
| AMD VA-API | Verified |
| Intel / NVIDIA VA-API | Plausible, untested - modifiers are probed at runtime |
| Android 10+ | Verified on 14; nothing vendor-specific required |
To check your own machine:
./scripts/moreland-doctor.shIt reports the compositor, the capture protocol, the VA-API encoder and the ADB link, and names whatever blocks you. Note that your distribution is not the deciding factor - the compositor is. Fedora or Debian running Hyprland should work; Arch running Plasma does not.
On a compositor that implements ext-image-copy-capture-v1, only one stage
is compositor-specific: creating the headless output. Capture uses that standard
protocol, encoding uses VA-API, transport uses ADB - so adding such a compositor
means implementing create/remove in
crates/daemon/src/output.rs and nothing else.
A compositor without the protocol - KDE, GNOME - is a much larger job: a
second, PipeWire-based capture backend.
Hyprland 0.56 can run a Lua config instead of the legacy .conf format, and
under it hyprctl keyword refuses to work - printing
keyword can't work with non-legacy parsers. Use eval. to stdout and exiting
0. Anything that checks only the exit status sees success.
That is how the daemon set the virtual output's mode, position and scale, so on
a Lua config all three were silently dropped and the output kept the
compositor's defaults: auto position, and auto scale, which on a headless
output (physical size 0x0) resolves to 2 - a monitor where everything is
twice the size. The daemon now detects that reply and reissues the rule through
hyprctl eval, which the Lua parser accepts. Genuine errors there exit non-zero
and are still caught.
The same applies to hyprctl dispatch: bare-word syntax is parsed as Lua and
no-ops. Use hyprctl eval 'hl.exec_cmd("...")' or
hyprctl dispatch 'hl.dsp.exec_cmd("...")'.
A static hl.monitor{} rule for the output is still worth keeping. Runtime
rules are discarded on config reload, and with no static entry the output falls
back to auto scale mid-session. A rule for an absent output is simply stored,
so it is harmless while the tablet is unplugged.
- No touch or pen input. The tablet is a display only.
zwlr_virtual_pointer_v1would add absolute-pointer input without root; true multi-touch needsuinput. - The app must stay foregrounded. Switching apps on the tablet stops the stream.
- No audio. Video only.
- Idle output drops to ~1 fps. Correct, not a bug: Hyprland does not render a static headless output, so a motionless screen costs almost nothing. It jumps straight back to the configured rate on damage. Worth knowing when benchmarking: measuring against an empty virtual output reports the idle rate, not the pipeline's.
- Mild softness from upscaling and H.264 on dark backgrounds. Raise
--bitrateor--max-widthif it bothers you. - Resizing the virtual output mid-session restarts the pipeline - the encoder and decoder are both configured for a fixed geometry.
Read before publishing or installing.
No network exposure. adb forward binds 127.0.0.1 only. Nothing listens
on an external interface, and no traffic leaves the machine or the USB cable.
The trust boundary is your local machine and your tablet. Two consequences on a shared or multi-user system:
- Any local process able to reach
127.0.0.1:27183can push frames to the tablet while the daemon is running. - Any app on the tablet can connect to the
localabstract:morelandsocket. Abstract Unix sockets carry no filesystem permissions.
Neither is exploitable for anything beyond drawing on the tablet's screen, and neither reaches back into the host. But this is a single-user desktop tool and is not hardened for a hostile local user.
The app requests no Android permissions at all - not even INTERNET. It can
draw to its own surface and nothing else.
Wire data is bounds-checked on both sides: magic and version on the stream header, and a frame-length cap on the device so a malformed length cannot drive a huge allocation.
Two unsafe blocks, both mapping buffers the kernel just gave us, both
commented with their invariants.
Enabling USB debugging is the real security decision here, and it is not specific to this project. An authorised ADB host has broad access to the device. Revoke authorisations when you are done: Developer options → Revoke USB debugging authorisations.
Build the APK yourself. Release builds are signed with the Android debug key, which is a shared, publicly-known key - fine for sideloading something you compiled, meaningless as provenance. Do not trust a prebuilt APK from anyone, including this repo.
Each stage is documented with what was built, what was measured, and what went wrong:
| Doc | |
|---|---|
| 00-system-survey.md | Hardware probing, transport evaluation, architecture decisions |
| 01-capture.md | Virtual output and zero-copy capture |
| 02-encode.md | VA-API encoding and tuning |
| 03-transport.md | Wire protocol and USB transport |
| 04-android-app.md | The tablet app |
| 05-daemon.md | Hotplug detection and the service |
| COMPATIBILITY.md | What other compositors and GPUs need |
| REVERT.md | How to undo everything |
systemctl --user disable --now moreland.service
rm -f ~/.local/bin/moreland ~/.config/systemd/user/moreland.service
adb uninstall com.moreland.displayFull inventory in docs/REVERT.md.
Especially wanted:
- Compositor backends - Sway is the smallest step (labwc is done); KWin the most requested
- A portal/PipeWire capture backend, which would make GNOME and KDE work at once
- Intel and NVIDIA reports, positive or negative
- Touch input via
zwlr_virtual_pointer_v1
Please include your compositor, GPU, driver version, and tablet model. A documented failure is more useful than silence.
Apache-2.0. See LICENSE.
