Remote access setup for Hyprland (Wayland) using Sunshine as the server and Moonlight / Artemis as the client.
Replicates Apollo-style virtual display behavior on Linux: a persistent headless monitor lives alongside your physical display and becomes the remote session when a client connects. Local workspaces are pinned to the physical monitor so they don't accidentally land on the invisible headless.
| Scenario | Works? |
|---|---|
| Hyprland compositor (Wayland) | ✅ |
| NVIDIA GPU with proprietary driver | ✅ (nvenc — H.264/HEVC/AV1) |
| AMD/Intel GPU | ✅ (change encoder=nvenc to encoder=vaapi) |
| Windows open on the remote client, not the physical monitor | ✅ |
| Physical monitor turns off during remote session | ✅ |
| Simultaneous remote access without disturbing your physical session | ✅ |
| Local windows never accidentally open on the invisible virtual display | ✅ (workspace pinning) |
| X11 / other compositors | ❌ (wlroots/Hyprland only) |
At session start: sunshine-start.sh (Hyprland exec-once) creates a persistent headless monitor, pins workspaces 1-10 to your physical display, dedicates workspace 11 to the headless, writes the headless's name into sunshine.conf, and then exec's Sunshine. The monitor lives for the whole Hyprland session.
┌──────────────────────────────────────────────────────────┐
│ Hyprland │
│ │
│ DP-1 (physical monitor) HEADLESS-N (virtual) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ workspaces 1-10 │ │ workspace 11 │ │
│ │ your windows │ │ (empty, ready) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Sunshine: capturing HEADLESS-N (cached at startup) │
└──────────────────────────────────────────────────────────┘
Client connects: sunshine-connect.sh migrates workspaces 1-10 from DP-1 onto HEADLESS-N, turns off the physical monitor, and pauses hypridle.
┌──────────────────────────────────────────────────────────┐
│ Hyprland │
│ │
│ DP-1 (off / DPMS) HEADLESS-N (virtual) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ blank │ │ workspaces 1-10 │ ◄── Sunshine captures
│ │ │ │ your windows │ │
│ └──────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────┘
│
▼ stream (nvenc/vaapi)
Moonlight / Artemis (Android, iOS, Windows, TV)
Client disconnects: sunshine-disconnect.sh migrates workspaces back to DP-1, turns it on, resumes hypridle. The headless monitor stays put — ready for the next connection without requiring Sunshine to re-read its config.
Sunshine's wlr-capture backend reads output_name once at process startup and caches it for the process lifetime. SIGHUP and the HTTP API do not refresh the cached value. So if the headless monitor is created on-demand by the connect hook, Sunshine still looks for the monitor name it loaded at startup (which no longer exists) and silently falls back to capturing the first available output — typically your physical display, producing a black/wrong frame on the remote client.
The persistent headless avoids that race entirely: the monitor exists and its name is in sunshine.conf before Sunshine starts, so Sunshine's cached value is always correct.
Hyprland 0.55 introduced a new Lua-based config provider (hl.monitor, hl.workspace_rule, hl.dsp.*) alongside the classic keyword syntax. The scripts now auto-detect which provider is active at startup and emit the matching commands — no configuration needed. Older Hyprland releases keep using the classic hyprctl keyword / dispatch dpms calls exactly as before, so existing setups are unaffected.
The Lua path is still experimental. If you're on 0.55+ and a workspace lands on the virtual display when it shouldn't, that's a known rough edge being ironed out — fall back is automatic on older versions.
Thanks to @X-dark for the 0.55+ port.
- Hyprland (any recent version) — distro-agnostic
python3(used to detect the dynamic headless monitor name)- Sunshine package available for your distro
- NVIDIA, AMD, or Intel GPU with hardware encoding support
- Optional:
hyprlock/hypridle— handled automatically if installed
The
install.shscript assumes an Arch-based distro (usesparuoryayto installsunshine-binfrom AUR). On Debian/Ubuntu/Fedora, install Sunshine manually from LizardByte's releases and follow the Manual setup section below.
git clone https://github.com/jhonsnake/sunshine-hyprland-virtual-display
cd sunshine-hyprland-virtual-display
bash scripts/install.shThe script will:
- Install
sunshine-binfrom AUR - Copy scripts to
~/.local/bin/ - Copy
sunshine.confto~/.config/sunshine/ - Open required ports in UFW (if active)
- Add
exec-onceto your Hyprland config
cp scripts/sunshine-start.sh ~/.local/bin/
cp scripts/sunshine-connect.sh ~/.local/bin/
cp scripts/sunshine-disconnect.sh ~/.local/bin/
chmod +x ~/.local/bin/sunshine-*.shmkdir -p ~/.config/sunshine
cp .config/sunshine/sunshine.conf ~/.config/sunshine/For AMD/Intel change
encoder=nvenctoencoder=vaapi
Add to ~/.config/hypr/hyprland.conf or userprefs.conf:
exec-once = ~/.local/bin/sunshine-start.shsudo ufw allow 47984/tcp comment "Sunshine HTTPS"
sudo ufw allow 47989/tcp comment "Sunshine HTTP"
sudo ufw allow 47990/tcp comment "Sunshine Web UI"
sudo ufw allow 48010/tcp comment "Sunshine RTSP"
sudo ufw allow 47998/udp comment "Sunshine Video"
sudo ufw allow 47999/udp comment "Sunshine Control"
sudo ufw allow 48000/udp comment "Sunshine Audio"
sudo ufw allow 48002/udp comment "Sunshine Mic"- Log into Hyprland — the headless monitor is created and Sunshine starts automatically
- Open
https://localhost:47990in your browser and create a username + password - In Moonlight or Artemis add your local IP as a new host
- On first connect a 4-digit PIN appears — enter it in the Pin tab of the web panel
- Done — your workspaces move from DP-1 to the headless monitor and stream to the remote client
sunshine-hyprland-virtual-display/
├── scripts/
│ ├── install.sh # Automatic installer
│ ├── sunshine-start.sh # Creates HEADLESS, pins workspaces, writes output_name, launches Sunshine
│ ├── sunshine-connect.sh # On connect: migrates ws 1-10 -> HEADLESS, turns off DP-1, pauses hypridle (self-heals if HEADLESS missing)
│ ├── sunshine-disconnect.sh # On disconnect: restores ws, turns on DP-1, resumes hypridle
│ └── sunshine-after-sleep.sh # Runs from hypridle after_sleep_cmd — fixes black screen after S3 resume
└── .config/
└── sunshine/
└── sunshine.conf # Sunshine config (capture, encoder, global_prep_cmd, output_name placeholder)
Default is 1920x1080@60. To change it, edit sunshine-start.sh:
hyprctl keyword monitor "$HEADLESS,1920x1080@60,9999x0,1"
# ^^^^^^^^^^^^ change thissunshine-start.sh pins:
- Workspaces 1-10 →
DP-1(your physical monitor) - Workspace 11 →
HEADLESS-N(dedicated "remote" workspace, persistent)
If you use workspace numbers above 10 locally, edit the for ws in 1 2 3 4 5 6 7 8 9 10; loop in sunshine-start.sh to include them, and change 11 to your "remote" workspace number.
Client sees the physical monitor instead of the headless one (or a black frame)
Sunshine cached the wrong output_name. This happens if Sunshine was already running when the headless was created. Fix: pkill sunshine then re-run ~/.local/bin/sunshine-start.sh & (or log out and back in). Verify with: grep output_name ~/.config/sunshine/sunshine.conf matches the active HEADLESS in hyprctl monitors.
Client sees an empty desktop (no windows)
sunshine-connect.sh didn't migrate workspaces. Check ~/.local/share/sunshine-headless.log for errors and confirm global_prep_cmd is set in sunshine.conf.
Client sees a solid black frame after the PC came back from suspend (S3)
After resume, Hyprland's virtual HEADLESS output exists but its scanout buffer is frozen — Sunshine keeps streaming it but the remote sees black. The fix is scripts/sunshine-after-sleep.sh wired to hypridle's after_sleep_cmd. The installer adds it automatically; for manual setup add this inside the general { } block of ~/.config/hypr/hypridle.conf:
general {
after_sleep_cmd = ~/.local/bin/sunshine-after-sleep.sh
}Then restart hypridle (pkill -x hypridle && setsid nohup hypridle &). Verify with grep after_sleep ~/.local/share/sunshine-headless.log after the next resume.
As a second line of defense, sunshine-connect.sh is self-healing: if HEADLESS is gone at connect time (rare — usually means Hyprland tore it down on resume) it recreates the monitor, rewrites output_name, and detach-restarts Sunshine. The client briefly disconnects and reconnects cleanly.
Physical monitor stays off after disconnecting
Run manually: hyprctl dispatch dpms on DP-1
Cannot connect from the local network
Check firewall with sudo ufw status | grep -i sunshine. If nothing shows, run step 4 of the manual setup.
AMD/Intel: no image or encoder failure
Change encoder=nvenc to encoder=vaapi in ~/.config/sunshine/sunshine.conf.
"Oopsie daisy, lockscreen app died" on the physical screen
Something SIGKILL'd hyprlock while an ext-session-lock was active. To recover: switch to a TTY (Ctrl+Alt+F3), log in, and run:
hyprctl --instance 0 'keyword misc:allow_session_lock_restore 1'
killall -9 hyprlock
hyprctl --instance 0 'dispatch exec hyprlock'To prevent it: never pkill hyprlock from a script; use loginctl unlock-session instead, and consider setting misc { allow_session_lock_restore = true } in your Hyprland config as a safety net.
hyprlock or hypridle not installed
Harmless — the pkill and loginctl calls are no-ops if those processes/sessions don't exist.