Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    A Headless Streaming Desktop: Sunshine, Moonlight and an HDMI Dummy Plug

    How I stream my Ubuntu desktop with Sunshine and Moonlight using an HDMI dummy plug: one-key home/away/dual modes, a dummy that resizes to each client, the Sunshine settings that matter, the issues I hit, and how not to lock yourself out.

    Growingtended planted 11 min read
    On this page

    My home PC (an RTX 3080 desktop running Ubuntu with GNOME on Wayland) is two things at once: the machine I sit at, and the machine I stream from when I’m on the sofa or away, using Sunshine on the PC and Moonlight on a laptop, with SSH from my phone as the back door (Home Dev Box over Tailscale). The awkward bit is the monitor. When I’m away, I want the main monitor off, but a GPU with no display attached has nothing to render, so there’s nothing to stream. When I’m home, I want the opposite: my real monitor on, and nothing rendering onto a screen nobody can see.

    This is how I set it up so that switching is one keystroke, the stream resizes itself to whatever device connects, and I can’t lock myself out.

    1. Get an HDMI dummy plug

    A dummy plug (also sold as an “EDID emulator” or “headless display adapter”) is a small dongle that plugs into a spare GPU port and tells the computer “I’m a monitor, and here are the resolutions I support”. The GPU renders a full desktop to it, and Sunshine streams that desktop.

    What to check before buying:

    • Which modes it advertises. Mine is an HDMI 1.4 plug: 4K, but only at 30 Hz, plus a handful of 60 Hz modes up to 1080p. Streaming 4K at 30 Hz looks sharp but feels sluggish. If you can, buy an HDMI 2.0 (or DisplayPort) plug that does 4K at 60 Hz and 1440p.
    • Your client’s aspect ratio. Many laptops are 16:10. If the plug only offers 16:9 modes you get black bars. Look for a plug whose mode list includes a 16:10 resolution such as 1680×1050 or 2560×1600.
    • Spare port. Use a port you don’t need for a real monitor.

    Once it’s plugged in, list what it really supports (GNOME on Wayland):

    gdctl show --modes

    2. Three modes: home, away, dual

    On Wayland, xrandr can’t change displays. GNOME’s display server, Mutter, owns them, and its command-line tool is gdctl. I wrapped it in a small script, monitor-mode, with three layouts:

    ModeScreensMoonlight streams
    homeMain monitor only (dummy off)Main monitor
    awayDummy only (main monitor off)Dummy
    dualMain monitor (primary) + dummy to its rightDummy

    The heart of it is just a gdctl set per layout. -P saves it, so it survives logging out:

    # home: main monitor only
    gdctl set -P -L -M DP-1 --mode 3840x1080@119.974 --primary
    # away: dummy plug only
    gdctl set -P -L -M HDMI-1 --mode 1920x1080@60.000 --primary
    # dual: both, dummy to the right of the main monitor
    gdctl set -P -L -M DP-1 --mode 3840x1080@119.974 --primary \
                 -L -M HDMI-1 --mode 1920x1080@60.000 --right-of DP-1

    Any monitor you leave out of a gdctl set is switched off, and GNOME moves its windows onto what’s left.

    Connecting sets it up for you. When a Moonlight client connects, Sunshine’s prep command decides the layout:

    Main monitor when you connectLayout during the streamClient sees
    On (home)dual: your desktop stays put, the dummy is added beside itThe dummy
    Switched off (away), or powered off at the buttonaway: dummy onlyThe dummy
    Already dualstays dualThe dummy

    Either way the dummy is sized to the client (section 3). When the stream ends, an automatic home → dual switch is undone, so you don’t come back to an invisible second desktop. A powered-off DisplayPort monitor can vanish from the system entirely, which the script treats like “off”.

    3. Make the stream fit the client

    The nicest part: the dummy resizes itself to whatever device connects. When a stream starts, Sunshine passes the client’s resolution and frame rate to “prep commands” as environment variables (SUNSHINE_CLIENT_WIDTH, SUNSHINE_CLIENT_HEIGHT, SUNSHINE_CLIENT_FPS). A global prep command runs for every app, so the script’s stream-start picks the best mode the dummy actually supports, and stream-end restores the saved layout when you disconnect.

    The ranking, in order:

    1. Fits inside the client’s screen (no downscaling).
    2. Same aspect ratio (no black bars).
    3. Keeps the client’s frame rate, up to 60 Hz.
    4. Biggest.

    With my 4K30-only plug that gives:

    ClientDummy mode chosenWhy
    2560×1600 @ 60 (16:10 laptop)1680×1050 @ 60Largest 16:10 mode that fits
    1920×1080 @ 601920×1080 @ 60Exact match
    3840×2160 @ 601920×1080 @ 604K is only 30 Hz on this plug; smoothness wins

    The per-stream change isn’t saved, so the dummy goes back to normal afterwards.

    Three refinements came from actually using it from a phone:

    • Phones need a bigger UI, not just fewer pixels. Sunshine tells you the client’s resolution but not its screen size. A 20:9 phone asks for about 2400×1080, the best fit is 1080p, and at 1× the desktop is microscopic on a 6-inch screen. So clients with a screen of about 2:1 or wider get a 1.5× scale (a 1280×720 logical desktop). Portrait clients are treated as landscape, because the dummy only has landscape modes.
    • Remember the client. stream-start saves the client’s resolution, and stream-end clears it. Switching to away or dual in the middle of a stream then sizes the dummy for that client, instead of jumping to the plug’s 4K default.
    • Prep commands only run when the app launches. Disconnecting and reconnecting leaves Sunshine’s Desktop app running, so the hook doesn’t fire again. When you change device, quit the session in Moonlight rather than just disconnecting, so the new client’s resolution is picked up. Every decision is written to ~/.cache/monitor-mode.log.

    Portrait for phones. A phone held upright wants a tall desktop. My first attempt rotated the dummy (gdctl set … -M HDMI-1 --mode 1920x1080@60.000 -t 90). The picture came out upright, but Sunshine maps absolute mouse and touch positions using the display’s unrotated size, so every tap landed skewed. The fix is a display that is natively portrait, and that took two pieces.

    1. A custom EDID, loaded at boot. I added 1080×1920 and 1080×2400 modes to a copy of the plug’s own EDID. NVIDIA’s driver ignores the kernel’s live edid_override. It only honours an EDID firmware file when the connector is also force-enabled, and only at boot:

      video=HDMI-A-1:e drm.edid_firmware=HDMI-A-1:edid/hdmi-dummy-portrait.bin

      That goes on the kernel command line, with the file in /lib/firmware/edid/ and an initramfs hook so it’s there when the driver loads. After a reboot the dummy lists both portrait modes, the script uses them with no rotation, and taps land where they should.

    2. A client that asks for portrait. Stock Moonlight locks streaming to landscape on (non-foldable) phones. It asked for 1920×1080, and Sunshine squeezed the tall desktop into a wide video, so the picture never turned with the phone. Artemis, a Moonlight fork, has a portrait mode and custom resolutions, and works with plain Sunshine (only extras like clipboard sync and automatic virtual displays need its companion host, Apollo). It requests a real 1080×1920 or 1080×2400 stream, and the script picks the dummy mode of exactly that size. A 20:9 phone gets a full-screen desktop with no bars.

    Making it automatic needed a phone signal. Sunshine doesn’t send the device type, and my phone’s Moonlight asks for 480p, which is the same 16:9 shape as a laptop. So a client counts as a phone if it asks for a listed resolution (854×480 for mine) or has a phone-shaped screen (about 1.95–2.3:1; ultrawide monitors are wider than that). A “Phone (portrait)” app in Sunshine is the explicit fallback, and you can pin it to the phone’s home screen.

    Lessons from the portrait detour. Rotating a display isn’t the same as having a portrait one: capture copes, but input mapping doesn’t. With NVIDIA, a custom EDID only counts if it’s there at boot on a forced-on connector. And before blaming the host, check what size the client actually asked for: the log line client 1920x1080 showed straight away that the phone, not the PC, was the problem.

    4. Sunshine settings

    ~/.config/sunshine/sunshine.conf:

    # Which display to stream. On Wayland Sunshine accepts the connector name and maps it
    # (it logs "Mapped 'HDMI-A-1' to kmsgrab monitor index …").
    output_name = HDMI-1
    
    # Size the dummy to each client, then restore it. Use an absolute path: Sunshine doesn't expand ~ here.
    global_prep_cmd = [{"do":"/home/you/.local/bin/monitor-mode stream-start","undo":"/home/you/.local/bin/monitor-mode stream-end"}]

    And a one-off capability so Sunshine can capture straight from the GPU (KMS capture). This is also what lets it capture the login screen:

    sudo setcap cap_sys_admin+p "$(readlink -f "$(which sunshine)")"

    Other things worth knowing:

    • Encoder: on an RTX 3080 Sunshine picks NVENC. H.264 and HEVC work; AV1 encoding needs a 40-series card, so the “av1_nvenc not supported” error at start-up is expected and harmless.
    • “portalgrab … Failed to create session”: Sunshine tries screen-sharing through the desktop portal first, then falls back to KMS. Also harmless.
    • Point output_name at the dummy and leave it there. Sunshine re-checks the displays at the start of every session, and when the dummy is switched off it logs “Couldn’t map ‘HDMI-1’ to a monitor index. Falling back to first monitor in list” and streams the main monitor instead. So home streams the main monitor, while dual and away stream the dummy, without ever restarting Sunshine.
    • Enable the service at login (systemctl --user enable on Sunshine’s unit). Mine was installed disabled, so after a reboot there was nothing for Moonlight to connect to.

    5. Shortcuts

    Four GNOME shortcuts, created by a small re-runnable script (monitor-shortcuts) that leaves my other custom shortcuts alone:

    ShortcutAction
    Super+Alt+HHome: main monitor only
    Super+Alt+AAway: dummy only
    Super+Alt+DDual
    Super+Alt+MToggle home/away

    The same four live on Super+Ctrl+H/A/B/M too (B for “both”, because Super+Ctrl+D is GNOME’s show-desktop), for keyboards whose Alt doesn’t arrive as a plain Left Alt; see issue 8.

    Each one pops a notification so you know it worked, and they work “blind”: if you switch the screen you’re looking at off by accident, the keyboard still works, so press Super+Alt+H to get it back.

    6. Issues I hit (so you don’t have to)

    1. xrandr silently does nothing on Wayland. My old Sunshine prep commands used it and had been failing for months. Use gdctl.

    2. The script worked in a terminal but not over SSH. An SSH login doesn’t know the address of your desktop’s D-Bus session, so gdctl can’t talk to GNOME, and it failed silently. Fix: in the script, default DBUS_SESSION_BUS_ADDRESS to unix:path=/run/user/$UID/bus.

    3. “command not found” in zsh. zsh caches the commands on your PATH; a new script isn’t seen in an already-open shell. Run rehash, or add zstyle ":completion:*" rehash true to .zshrc.

    4. Shortcuts didn’t fire over Moonlight from a Mac. On a Mac keyboard Super+Alt is Cmd+Option, and macOS keeps some of those combos (Cmd+Option+H hides other apps, Cmd+Option+M minimises, Cmd+Option+D toggles the Dock). Set Moonlight’s “capture system keyboard shortcuts” to Always, or pick combos macOS doesn’t use.

    5. Windows vanished in dual mode. The dummy is a real second desktop that nobody can see when you’re at home, so windows opened or dragged there disappear. Switching to home pulls them back; Super+Shift+←/→ moves a single window.

    6. Sunshine streamed the wrong screen, then “crashed”. By default it captures the first display, which is the main monitor whenever it’s on. My first fix set output_name per mode and restarted Sunshine on every switch. That dropped the stream each time, and after a few switches systemd hit the unit’s start limit (5 starts in 500 seconds) and left Sunshine stopped. The better fix is a fixed output_name plus Sunshine’s own fallback: no restarts at all.

    7. The login screen has its own monitor config. Because the dummy is always connected, it keeps a single layout for that combination of screens.

    8. My keyboard’s Alt wasn’t the Alt GNOME wanted. On a programmable keyboard (mine is a ZSA Voyager with layers and combos) a “modifier” may be Right Alt (AltGr), or released before the letter arrives. GNOME’s <Alt> means Left Alt held while the key goes down. I wrote a tiny script, keycheck, that reads raw key events below GNOME and prints each combo with the modifiers actually held and whether it would fire a shortcut. It showed Alt wasn’t arriving, so I added a second set of shortcuts on Super+Ctrl (Super+Ctrl+D is GNOME’s “show desktop”, so dual is Super+Ctrl+B).

    7. Don’t lock yourself out

    A headless machine you can’t see is a machine you can’t fix, so I set a few rules:

    • SSH is always the way back in. With Tailscale SSH the PC is reachable from my phone wherever I am, and monitor-mode works over SSH, so monitor-mode home or away fixes any bad layout.
    • The PC never sleeps. Mask the sleep targets (systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target), otherwise the login screen quietly suspends after a reboot.
    • The login screen shows both displays. monitor-mode sync-login writes a layout with the main monitor and the dummy on, so the login screen is visible at the desk and streamable when away. If it’s ever wrong, switch the login screen itself over SSH: monitor-mode --greeter away.
    • Never script “everything off”. Every mode leaves exactly one primary display on.
    • Only go headless when someone’s watching. away refuses to switch the main monitor off unless a Moonlight client is actually connected: Sunshine logs CLIENT CONNECTED / CLIENT DISCONNECTED, and the script checks the last one in the current log. Otherwise it pops a notification (“No Moonlight client connected: keeping the main monitor on”) and does nothing. --force overrides it for the rare deliberate case.
    • Don’t restart Sunshine casually. A restart drops the stream, and too many in a few minutes trips systemd’s start limit and leaves it stopped (systemctl --user reset-failed then start brings it back).
    • Power cuts: set the BIOS to “restore on AC power loss”, so it comes back on by itself.

    When all else fails, the fallback is the same short list from my phone:

    ssh home-pc
    monitor-mode status            # what's on, and what Sunshine is streaming
    monitor-mode away              # dummy on, main monitor off
    monitor-mode --greeter away    # if you're stuck at the login screen

    Where it lives

    Everything is in my dotfiles: monitor-mode and monitor-shortcuts in bin/, the zsh fix in .zshrc, and reference copies of the Sunshine config. The Sunshine files are reference copies rather than stowed symlinks, because Sunshine and the script rewrite them (Dotfiles: An Environment That Follows Me). With an always-on machine, Tailscale SSH and this, the setup really does follow me: desk, sofa or away, it’s one keystroke, or one command from my phone.

    • #sunshine
    • #moonlight
    • #game-streaming
    • #remote-desktop
    • #wayland
    • #gnome
    • #linux
    • #dotfiles
    • #tailscale

    Nothing links here yet — it’s a fresh shoot.

    ↪ Links to (2)