Headless mode (new) — for KVM-attached hosts streaming to disconnected clients - --headless / --mirror flags; default headless on hostname JARVIS, mirror elsewhere - New lib/headless.sh installs prep-cmd hooks to ~/.local/share/omarchy-moonlight/bin - bin/sunshine-stream-do.sh creates/resizes a Hyprland HEADLESS-1 output to the connecting client's resolution and migrates the active workspace onto it - bin/sunshine-stream-undo.sh tears down the headless output on disconnect and returns the workspace to a non-headless monitor when one is available - lib/config.sh writes capture=wlr, output_name=HEADLESS-1, and the JSON global_prep_cmd entry referencing the installed hook paths - lib/preflight.sh adds a preflight_headless step that checks hyprctl, jq, and a running Hyprland session (warn-only, install can proceed) - lib/verify.sh adds checks for the hook scripts and the wlr/global_prep_cmd config lines Mac client - client/install-macos.sh: Darwin guard, Homebrew presence check, brew cask install of Moonlight, idempotent - client/README.md: per-platform install (macOS / Android / iOS / Apple TV / Linux + Steam Deck) and the five-step first-pair walkthrough Other - jq added to the helper install set in lib/packages.sh (hooks parse Hyprland JSON output) - README.md rewritten to cover both modes, the new flags, the tuned defaults per mode + per vendor, the headless internals, and the client pointer
209 lines
13 KiB
Markdown
209 lines
13 KiB
Markdown
# omarchy-moonlight
|
|
|
|
Idempotent bash installer that sets up [Sunshine](https://github.com/LizardByte/Sunshine) (host) and [Moonlight](https://moonlight-stream.org/) (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
|
|
|
|
```bash
|
|
git clone <this-repo> ~/omarchy-moonlight
|
|
cd ~/omarchy-moonlight
|
|
./install.sh
|
|
```
|
|
|
|
Then:
|
|
|
|
1. **Log out and back in** if you weren't already in the `input` group (the installer adds you; the group only takes effect on a fresh login). `newgrp input` works as a one-shell shortcut.
|
|
2. Open <https://localhost:47990> and set a Sunshine username + password. The cert is self-signed; accept the warning.
|
|
3. 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:
|
|
|
|
1. Reads `SUNSHINE_CLIENT_WIDTH`, `SUNSHINE_CLIENT_HEIGHT`, `SUNSHINE_CLIENT_FPS` from Sunshine's environment.
|
|
2. Ensures `HEADLESS-1` exists, creating it via `hyprctl output create headless` if missing.
|
|
3. Resizes it to the client's native resolution / framerate via `hyprctl keyword monitor`.
|
|
4. Moves the currently active workspace onto `HEADLESS-1` and 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:
|
|
|
|
1. **Preflight** — confirms Wayland session, GPU driver is loaded, `nvidia-drm.modeset=1` is not explicitly disabled (would silently break KMS capture), `pipewire-pulse` is present for audio.
|
|
2. **Packages** — installs `sunshine-bin` (precompiled) and `moonlight-qt` from the AUR via `yay`, plus runtime helpers: `pipewire-pulse`, `vulkan-tools`, `libva-utils`. `--from-source` (or `SUNSHINE_PKG=sunshine`) switches to the source build.
|
|
3. **GPU encoder support**:
|
|
- NVIDIA: `nvidia-utils`, `libva-nvidia-driver`
|
|
- AMD: `libva-mesa-driver`, `mesa-vdpau`, `vulkan-radeon`
|
|
- Intel: `intel-media-driver`, `vulkan-intel`
|
|
4. **Permissions**:
|
|
- Adds you to the `input` group.
|
|
- Drops `/etc/udev/rules.d/60-uinput.rules` if no equivalent rule exists (so Sunshine can use `/dev/uinput` for virtual gamepad / keyboard / mouse).
|
|
- `setcap cap_sys_admin+p` on the `sunshine` binary so KMS screen capture works without root.
|
|
5. **Headless hooks** (headless mode only) — installs `sunshine-stream-do.sh` and `sunshine-stream-undo.sh` to `~/.local/share/omarchy-moonlight/bin/` and references them as the Sunshine `global_prep_cmd`.
|
|
6. **Tuned config** — writes `~/.config/sunshine/sunshine.conf` for 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.
|
|
7. **Firewall** — opens Sunshine's ports on `firewalld` / `ufw` if either is active. Skips silently otherwise.
|
|
8. **Service** — enables `sunshine.service` under `systemctl --user` and turns on `loginctl enable-linger` so the host is reachable without an active graphical login.
|
|
9. **Verify** — runs the same checks as `--doctor` to confirm everything's actually wired up (cap_sys_admin set, group resolved, web UI listening on `:47990`, encoder reachable, hooks present where expected).
|
|
|
|
## Flags
|
|
|
|
```text
|
|
./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 --doctor # verification only (no install)
|
|
|
|
./uninstall.sh # remove packages and udev rule, keep user data
|
|
./uninstall.sh --purge # also delete ~/.config/sunshine
|
|
./uninstall.sh --keep-moonlight
|
|
```
|
|
|
|
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.
|
|
|
|
## 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
|
|
|
|
```bash
|
|
./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:
|
|
|
|
1. You're in the `input` group in a freshly logged-in session (not just listed in `/etc/group` — group membership is set at login). `id -nG` is the truth.
|
|
2. `getcap` shows `cap_sys_admin+p` on the resolved `sunshine` binary.
|
|
3. `journalctl --user -u sunshine` doesn't show `KMS` / `DRM` / `wlr` errors 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
|
|
|
|
```text
|
|
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/
|
|
```
|