Add headless streaming mode + Mac client + full docs

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
This commit is contained in:
2026-05-18 10:31:08 -06:00
parent d6b0919149
commit 171ade4ff1
11 changed files with 590 additions and 72 deletions

214
README.md
View File

@@ -1,14 +1,8 @@
# omarchy-moonlight
Idempotent install scripts that set up [Sunshine](https://github.com/LizardByte/Sunshine) (host) and [Moonlight](https://moonlight-stream.org/) (client) on Omarchy / Arch Linux / Hyprland / Wayland machines.
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.
Designed so the same script runs on:
- a primary NVIDIA desktop (host + client)
- a Framework AMD laptop (host + client)
- any other Omarchy box
After install, the machine can both stream out (via Sunshine) and view streams (via Moonlight). A Macbook can install Moonlight separately and connect to either Linux host.
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
@@ -20,69 +14,119 @@ cd ~/omarchy-moonlight
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).
2. Open <https://localhost:47990> and set a Sunshine username + password.
3. On a Moonlight client (Mac / phone / the other Linux box), add this host by LAN IP and enter the PIN that Sunshine's web UI shows during pairing.
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.
## What it does
## Streaming modes
In order, each step is "check, then act" — re-running is safe:
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.
1. **Preflight** — confirms Wayland session, GPU driver is loaded, `nvidia-drm.modeset=1` isn't explicitly disabled (would silently break KMS capture), pipewire-pulse is present for audio.
2. **Packages** — installs `sunshine-bin` (precompiled, fast) and `moonlight-qt` from the AUR via `yay`. Plus runtime helpers: `pipewire-pulse`, `vulkan-tools`, `libva-utils`. Use `--from-source` to build Sunshine from source instead.
| 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 (lets Sunshine 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. **Tuned config** — writes `~/.config/sunshine/sunshine.conf` for low-latency LAN streaming. Per-vendor encoder settings (NVENC P1+`ll`, VAAPI ultralowlatency, QuickSync veryfast). Marked with `# managed-by: omarchy-moonlight`; remove that marker to take ownership and the installer will never touch it again.
6. **Firewall** — opens Sunshine's ports on `firewalld` / `ufw` if either is active. Skips silently otherwise.
7. **Service** — enables `sunshine.service` under `systemd --user` and turns on `loginctl enable-linger` so the host is reachable without an active graphical login.
8. **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).
- 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 --no-autostart # install but don't enable the user service
./install.sh --no-firewall # skip firewall rules
./install.sh --no-moonlight # host-only (no client)
./install.sh --no-sunshine # client-only (no host)
./install.sh --no-config # leave Sunshine to generate its own default config
./install.sh --from-source # build Sunshine from source (slower; uses 'sunshine' AUR pkg)
./install.sh --doctor # run only the verification checks (no install)
./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
```
The doctor flag is the fastest way to debug a degraded install — it'll tell you exactly which piece (group, cap, udev, encoder, service, port) is broken.
Environment overrides:
### Tuned defaults written to sunshine.conf
| Variable | Effect |
|---|---|
| `SUNSHINE_PKG=sunshine` | Build from source (equivalent to `--from-source`). |
| `SUNSHINE_PKG=sunshine-bin` | Use the prebuilt AUR package (default). |
| Setting | Value | Why |
|---|---|---|
| `capture` | `kms` | Correct backend for Wayland; uses DRM directly. |
| `encoder` (NVIDIA) | `nvenc` + `nvenc_preset=p1`, `nvenc_tune=ll`, `nvenc_rc=cbr` | P1 minimizes encode latency; low-latency tune disables look-ahead; CBR keeps bitrate predictable over LAN. |
| `encoder` (AMD) | `vaapi` + `amd_usage=ultralowlatency`, `amd_rc=cbr` | Mirrors the NVIDIA choices on AMD's encoder. |
| `min_threads` | `4` | Helps keep up at high bitrates / 4K. |
| `audio_sink` | `pulse` | Captures from PipeWire's Pulse compat layer. |
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.
Anything else (resolution, bitrate, paired clients, app launchers) is set via the web UI.
## Tuned defaults
## Uninstall
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.
```bash
./uninstall.sh # remove packages + udev rule, keep user data
./uninstall.sh --purge # also delete ~/.config/sunshine
```
| 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. |
## How streaming works once it's set up
Per-vendor encoder picks:
- **Host (Sunshine) ports** (auto-opened if a firewall is active):
- TCP: `47984 47989 47990 48010`
- UDP: `47998 47999 48000 48010`
- **Pairing**: on first connect, Moonlight shows a PIN. Type it into Sunshine's web UI (<https://localhost:47990> → PIN tab) within a few seconds.
- **Capture mode**: this script configures KMS capture, which streams whatever is on the host's real monitor. A virtual-display mode (so streaming doesn't take over the desk) is a future addition — see `remote/` notes when it lands.
| 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
@@ -94,16 +138,47 @@ getcap "$(readlink -f "$(command -v sunshine)")" # should include cap_sys_a
id -nG | tr ' ' '\n' | grep -x input # confirm group membership
```
If Moonlight pairs but the stream is black:
Useful Sunshine ports (auto-opened if a firewall is active):
- Confirm you're in the `input` group **in a freshly logged-in session** (not just listed in `/etc/group`).
- Confirm `getcap` shows `cap_sys_admin` on the sunshine binary.
- Check `journalctl --user -u sunshine` for `KMS` / `DRM` errors.
- On NVIDIA: confirm the proprietary driver is active (`nvidia-smi`) and that `nvidia-drm.modeset=1` is in effect. If you have an older driver (≤555) and modeset isn't on, add `nvidia-drm.modeset=1` to your kernel cmdline and reboot.
- 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 will be added later via one of: Tailscale, WireGuard, or Cloudflare. See `remote/` (stub) when implemented.
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
@@ -112,15 +187,22 @@ 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 (driver, modeset, audio, session)
│ ├── 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)
│ ├── firewall.sh # ufw/firewalld detection + port opening
│ ├── service.sh # systemctl --user enable + loginctl enable-linger
── verify.sh # post-install checks (also reused by --doctor)
└── files/ # (reserved — drop-in config files if needed later)
│ ├── 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/
```