When sunshine-bin trips the ldd check and we switch to the source build, the prior install left sunshine-bin-debug behind. The source package includes its own sunshine-debug which collides on /usr/lib/debug/usr/bin/sunshine.debug, so pacman refuses the install. Remove both sunshine-bin and sunshine-bin-debug before yay -S sunshine. uninstall.sh similarly drops all four variants.
omarchy-moonlight
Idempotent bash installer that sets up Sunshine (host) and Moonlight (client) on Omarchy / Arch Linux / Hyprland / Wayland machines. Works across NVIDIA, AMD, and Intel hosts. A companion script handles the Mac client; iOS / Android / Apple TV clients are App Store installs.
The same script can leave a machine acting as host, client, or both. Re-running it is safe: every step is "check, then act."
Quick start
git clone <this-repo> ~/omarchy-moonlight
cd ~/omarchy-moonlight
./install.sh
Then:
- Log out and back in if you weren't already in the
inputgroup (the installer adds you; the group only takes effect on a fresh login).newgrp inputworks as a one-shell shortcut. - Open https://localhost:47990 and set a Sunshine username + password. The cert is self-signed; accept the warning.
- On a Moonlight client (Mac / phone / the other Linux box), add this host by LAN IP and enter the 4-digit PIN that Sunshine's web UI shows during pairing.
Streaming modes
The installer picks between two capture strategies. The choice gets baked into ~/.config/sunshine/sunshine.conf and (for headless) wires in two prep-cmd hook scripts.
| Mode | Capture backend | Stream resolution | Needs physical monitor? | Default on |
|---|---|---|---|---|
| Mirror | capture=kms (DRM) |
Whatever the host's monitor is set to | Yes (or a connected dummy plug) | All hosts except JARVIS |
| Headless | capture=wlr, output_name=HEADLESS-1 |
Adapts per-connection to each client's native resolution | No | Hostname JARVIS |
Override with --headless or --mirror on any host.
Mirror mode
Sunshine captures the real DRM display. Lowest latency, simplest mental model. The client sees exactly what's on the host monitor. Pick this when the host has a working display attached and you want a "remote screen" experience.
Headless mode
Sunshine captures a Hyprland headless output. On each client connect, a global_prep_cmd runs sunshine-stream-do.sh, which:
- Reads
SUNSHINE_CLIENT_WIDTH,SUNSHINE_CLIENT_HEIGHT,SUNSHINE_CLIENT_FPSfrom Sunshine's environment. - Ensures
HEADLESS-1exists, creating it viahyprctl output create headlessif missing. - Resizes it to the client's native resolution / framerate via
hyprctl keyword monitor. - Moves the currently active workspace onto
HEADLESS-1and focuses it, so existing windows appear in the stream.
On disconnect, sunshine-stream-undo.sh moves the workspace back to a real monitor (if one exists) and removes HEADLESS-1.
The result: different clients can connect at different resolutions and the host adapts per-connection. The host's physical monitor is optional — perfect for a KVM-attached machine whose monitor is often switched to another input. The hostname-JARVIS default exists because that's the canonical KVM-attached target; a laptop with its own screen is better off in mirror mode.
What the installer does
Every step is idempotent. In order:
- Preflight — confirms Wayland session, GPU driver is loaded,
nvidia-drm.modeset=1is not explicitly disabled (would silently break KMS capture),pipewire-pulseis present for audio. - Packages — installs
sunshine-bin(precompiled) andmoonlight-qtfrom the AUR viayay, plus runtime helpers:pipewire-pulse,vulkan-tools,libva-utils.--from-source(orSUNSHINE_PKG=sunshine) switches to the source build. - GPU encoder support:
- NVIDIA:
nvidia-utils,libva-nvidia-driver - AMD:
libva-mesa-driver,mesa-vdpau,vulkan-radeon - Intel:
intel-media-driver,vulkan-intel
- NVIDIA:
- Permissions:
- Adds you to the
inputgroup. - Drops
/etc/udev/rules.d/60-uinput.rulesif no equivalent rule exists (so Sunshine can use/dev/uinputfor virtual gamepad / keyboard / mouse). setcap cap_sys_admin+pon thesunshinebinary so KMS screen capture works without root.
- Adds you to the
- Headless hooks (headless mode only) — installs
sunshine-stream-do.shandsunshine-stream-undo.shto~/.local/share/omarchy-moonlight/bin/and references them as the Sunshineglobal_prep_cmd. - Tuned config — writes
~/.config/sunshine/sunshine.conffor low-latency LAN streaming, with per-vendor encoder picks. Marked with# managed-by: omarchy-moonlight; remove that marker to take ownership and the installer will never touch it again. - Firewall — opens Sunshine's ports on
firewalld/ufwif either is active. Skips silently otherwise. - Service — enables
sunshine.serviceundersystemctl --userand turns onloginctl enable-lingerso the host is reachable without an active graphical login. - Verify — runs the same checks as
--doctorto confirm everything's actually wired up (cap_sys_admin set, group resolved, web UI listening on:47990, encoder reachable, hooks present where expected).
Flags
./install.sh # auto-detect mode (headless on JARVIS, mirror elsewhere)
./install.sh --headless # force headless mode
./install.sh --mirror # force mirror mode
./install.sh --no-autostart # install but don't enable systemctl --user sunshine
./install.sh --no-firewall # skip firewall configuration
./install.sh --no-config # don't write a tuned sunshine.conf
./install.sh --no-sunshine # client-only (install Moonlight only)
./install.sh --no-moonlight # host-only
./install.sh --from-source # build sunshine from source (default uses sunshine-bin)
./install.sh --no-certs # skip the 1Password-backed cert step
./install.sh --force-certs # re-mint the host cert even if current
./install.sh --doctor # verification only (no install)
./scripts/cert-bootstrap.sh # one-time: generate root CA, push to 1Password
./uninstall.sh # remove packages and udev rule, keep user data
./uninstall.sh --purge # also delete ~/.config/sunshine
./uninstall.sh --keep-moonlight
./uninstall.sh --remove-ca-trust # also remove the omarchy-stream CA from system trust
Environment overrides:
| Variable | Effect |
|---|---|
SUNSHINE_PKG=sunshine |
Build from source (equivalent to --from-source). |
SUNSHINE_PKG=sunshine-bin |
Use the prebuilt AUR package (default). |
The --doctor flag is the fastest way to debug a degraded install — it reports exactly which piece (group, cap, udev, encoder, service, port, hooks) is broken.
Tuned defaults
Written to ~/.config/sunshine/sunshine.conf with a # managed-by: omarchy-moonlight marker on the first line. The installer only overwrites the file while that marker is present; delete the marker (or the line) to lock the file against future runs.
| Setting | Mirror value | Headless value | Why |
|---|---|---|---|
capture |
kms |
wlr |
KMS for real DRM displays; wlr for Hyprland headless outputs. |
output_name |
(unset) | HEADLESS-1 |
Pin wlr capture to the headless output the hooks manage. |
global_prep_cmd |
(unset) | do/undo pair |
Runs the headless hook scripts on client connect / disconnect. |
min_threads |
4 |
4 |
Helps keep up at high bitrates / 4K. |
audio_sink |
pulse |
pulse |
Captures from PipeWire's Pulse compat layer. |
Per-vendor encoder picks:
| Vendor | Encoder | Settings | Why |
|---|---|---|---|
| NVIDIA | nvenc |
nvenc_preset=p1, nvenc_tune=ll, nvenc_rc=cbr |
P1 minimizes encode latency; ll disables look-ahead; CBR keeps bitrate predictable over LAN. |
| AMD | vaapi |
amd_usage=ultralowlatency, amd_rc=cbr |
Mirrors the NVIDIA latency-first picks on AMD's encoder. |
| Intel | quicksync |
qsv_preset=veryfast |
Lowest-latency QuickSync preset. |
Everything else (bitrate, paired clients, app launchers) is set via the web UI.
Trusted TLS certs via 1Password
The installer can replace Sunshine's default self-signed cert with one minted from a private root CA whose key material lives in 1Password. Result: no more browser warning on https://<host>.lan:47990, and any tool that respects the system trust store (curl, openssl, browsers using NSS) trusts the host directly.
One-time bootstrap (run on any one machine)
./scripts/cert-bootstrap.sh
This:
- Generates a 4096-bit RSA root CA (10-year validity).
- Uploads it to 1Password as a Secure Note titled Omarchy-Stream Root CA in the Private vault, with two fields:
cert(text): the PEM cert.key(concealed): the PEM private key.
- Prints the
op://Private/Omarchy-Stream Root CA/{cert,key}references for confirmation.
Refuses to overwrite an existing CA item unless you pass --force — replacing the CA invalidates every host cert previously minted from it.
Per-host (built into install.sh)
./install.sh runs a cert step that:
- Reads the CA from 1Password (the
opCLI must be signed in,eval $(op signin)first). - Mints a host cert with SAN entries for
<hostname>.lanand the host's current LAN IP, signed by the CA, valid 365 days. - Writes the cert / key into
~/.config/sunshine/credentials/{cacert,cakey}.pem(the same paths Sunshine uses by default). - Installs the CA cert into
/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pemand runsupdate-ca-trust. - The next service restart picks up the new cert.
The step is idempotent: if the on-disk cert is signed by the current CA, has the right SANs, and isn't expiring within 30 days, it's left alone. Force a re-mint with --force-certs.
Skip the cert step entirely with --no-certs — Sunshine will fall back to generating its own self-signed cert as before.
Clients trust the CA too
| Client | How |
|---|---|
| Another Linux host | Same install.sh — the cert step installs the CA into /etc/ca-certificates regardless of whether the host runs Sunshine. |
| macOS | client/install-macos.sh fetches the CA from 1Password and runs security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain. |
| iOS / iPadOS | Email yourself the CA PEM, install as a profile, then enable full trust in Settings → General → About → Certificate Trust Settings. Documented in client/README.md. |
| Android | Settings → Security → Encryption & credentials → Install from storage → CA certificate. Documented in client/README.md. |
Cert replacement and Moonlight re-pairing
Sunshine uses one cert for both the web UI and the pairing handshake. Replacing the cert invalidates the fingerprint pinned by previously-paired Moonlight clients. After the first cert install on a host, re-pair every Moonlight client once via the web UI. After that, the cert is stable and re-runs of install.sh don't re-mint unless the cert is expiring.
Configuration
| Variable | Default | Effect |
|---|---|---|
OP_VAULT |
Private |
1Password vault that holds the CA item |
OP_CA_ITEM |
Omarchy-Stream Root CA |
Title of the CA item |
CERT_DAYS |
365 |
Host cert validity (days) |
FORCE_CERTS |
0 |
Set to 1 to re-mint even when the existing cert is current |
Clients
The host-side installer handles Linux clients via moonlight-qt. For everything else, see client/README.md for per-platform install plus the first-pair walkthrough.
| Platform | Install path |
|---|---|
| Linux (Arch / Omarchy) | Bundled — ./install.sh installs moonlight-qt unless --no-moonlight. |
| macOS | client/install-macos.sh (Moonlight via Homebrew cask). |
| iOS / iPadOS | App Store: Moonlight Game Streaming. |
| Android | Play Store: Moonlight Game Streaming. |
| Apple TV (tvOS) | App Store: Moonlight Game Streaming. |
Diagnostics
./install.sh --doctor # run all checks
systemctl --user status sunshine
journalctl --user -u sunshine -f
getcap "$(readlink -f "$(command -v sunshine)")" # should include cap_sys_admin
id -nG | tr ' ' '\n' | grep -x input # confirm group membership
Useful Sunshine ports (auto-opened if a firewall is active):
- TCP:
47984 47989 47990 48010 - UDP:
47998 47999 48000 48010
Headless mode internals
Hooks live at ~/.local/share/omarchy-moonlight/bin/ and are referenced from sunshine.conf as a global_prep_cmd pair.
| Script | Trigger | Responsibilities |
|---|---|---|
sunshine-stream-do.sh |
Client connects | Ensure HEADLESS-1 exists; resize it to ${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}@${SUNSHINE_CLIENT_FPS}; move active workspace onto it; focus it. |
sunshine-stream-undo.sh |
Client disconnects | Move workspace back to a real monitor (if any); remove HEADLESS-1. |
Environment variables Sunshine sets on each connect and the hooks consume:
SUNSHINE_CLIENT_WIDTH— client viewport width in pixels.SUNSHINE_CLIENT_HEIGHT— client viewport height in pixels.SUNSHINE_CLIENT_FPS— client target framerate.
The hooks defensively recover HYPRLAND_INSTANCE_SIGNATURE by scanning $XDG_RUNTIME_DIR/hypr/ if Sunshine's environment doesn't inherit it (rare, but possible under stripped-down user services). They also snapshot prior monitor state under $XDG_RUNTIME_DIR/sunshine-headless/ so undo can restore the previous workspace assignment. If Hyprland isn't running, the hooks log a message and exit cleanly — Sunshine still streams, just without per-client resizing.
Troubleshooting
Stream pairs but is black. Confirm three things:
- You're in the
inputgroup in a freshly logged-in session (not just listed in/etc/group— group membership is set at login).id -nGis the truth. getcapshowscap_sys_admin+pon the resolvedsunshinebinary.journalctl --user -u sunshinedoesn't showKMS/DRM/wlrerrors at stream start.
NVIDIA: capture starts then dies. Older proprietary drivers (≤555) need nvidia-drm.modeset=1 on the kernel command line. cat /sys/module/nvidia_drm/parameters/modeset should print Y. If not, add nvidia-drm.modeset=1 to your bootloader cmdline and reboot.
KVM / headless: client sees an old resolution. The headless hooks resize HEADLESS-1 per connect. If you see stale geometry, check ~/.local/share/omarchy-moonlight/bin/sunshine-stream-do.sh is referenced in sunshine.conf's global_prep_cmd and that hyprctl monitors all lists HEADLESS-1 during a stream.
Headless host, no monitor at all. Hyprland needs to be running for the hooks to do anything. On a truly headless box, run Hyprland under uwsm from a tty (or via systemctl --user start hyprland-session.target) so its IPC socket exists when Sunshine fires the prep command.
Moonlight can't find the host on the LAN. Confirm the firewall ports above are open. mDNS discovery is best-effort; adding the host manually by LAN IP is the reliable path.
Remote access (planned)
LAN-only for now. Remote access is planned via one of Tailscale, WireGuard, or Cloudflare Tunnel, depending on which gives the best UDP latency to mobile clients. Notes will land under remote/ when implemented.
Layout
omarchy-moonlight/
├── install.sh
├── uninstall.sh
├── README.md
├── bin/
│ ├── sunshine-stream-do.sh # prep-cmd hook: create/resize headless on connect
│ └── sunshine-stream-undo.sh # prep-cmd hook: tear down headless on disconnect
├── client/
│ ├── install-macos.sh # Moonlight via brew cask
│ └── README.md # per-platform install + first-pair flow
├── lib/
│ ├── common.sh # logging, sudo, idempotency helpers
│ ├── detect.sh # GPU vendor, session type, hostname
│ ├── preflight.sh # pre-install sanity checks
│ ├── packages.sh # yay -S sunshine-bin moonlight-qt + GPU encoders
│ ├── permissions.sh # input group, uinput udev, setcap cap_sys_admin
│ ├── config.sh # writes tuned sunshine.conf (managed-by marker)
│ ├── headless.sh # installs prep-cmd hooks to ~/.local/share/
│ ├── firewall.sh # ufw/firewalld detection + port opening
│ ├── service.sh # systemctl --user enable + enable-linger
│ └── verify.sh # post-install checks (also reused by --doctor)
└── files/ # drop-in config files referenced by lib/