Files
Omarchy-Stream/README.md
Levi Woodard ab23107300 Add runtime status checker + headless/X11 docs; distro-support refinements
- status.sh: runtime health check (service state, boot wiring, display backend auto-detect, encoder, ports, web UI, /dev/uinput, pairing) ending in a g2g verdict or concrete TODO list

- docs: TROUBLESHOOTING §12 (headless graphical-session.target boot trap) + §13 (X11/NVENC path & stale wlr drop-in env conflict); ARCHITECTURE capture-backend comparison; FOLLOWUPS P3 (installer X11/Ubuntu support); README diagnostics pointer

- lib/{config,packages,permissions,service}.sh, files/sway-headless.service: in-progress Debian/Ubuntu + headless support refinements

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:57:03 +00:00

287 lines
18 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) | Hostnames not listed in `HEADLESS_HOSTS` |
| **Headless** | `capture=wlr`, `output_name=HEADLESS-1` | Adapts per-connection to each client's native resolution | No | Hostnames listed in `HEADLESS_HOSTS` (unset by default) |
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. Set the `HEADLESS_HOSTS` env var (or just pass `--headless`) on the machines you want to default to headless; a laptop with its own visible screen is usually 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 # mirror by default; set HEADLESS_HOSTS=myhost for hostname-based headless default
./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)
```bash
./scripts/cert-bootstrap.sh
```
This:
1. Generates a 4096-bit RSA root CA (10-year validity).
2. 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.
3. 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:
1. Reads the CA from 1Password (the `op` CLI must be signed in, `eval $(op signin)` first).
2. Mints a host cert with SAN entries for `<hostname>.lan` and the host's current LAN IP, signed by the CA, valid 365 days.
3. Writes the cert / key into `~/.config/sunshine/credentials/{cacert,cakey}.pem` (the same paths Sunshine uses by default).
4. Installs the CA cert into `/etc/ca-certificates/trust-source/anchors/omarchy-stream-ca.pem` and runs `update-ca-trust`.
5. 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. |
## Documentation
| Doc | What it covers |
|---|---|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | How the pieces fit together at install time and at stream time; component map; runtime flow diagrams; idempotency contract; file map. |
| [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | Every failure mode hit during the first end-to-end install, in order, with symptom → cause → fix. Custom-keybinding setup for escaping Moonlight on macOS. |
| [`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md) | Outstanding work not yet on `main`: screensaver inhibit, busiest-workspace auto-switch, 1Password streaming workarounds, cert renewal automation, stale Sunshine config keys. |
| [`client/README.md`](client/README.md) | Per-platform Moonlight install and the first-pair walkthrough. |
## Diagnostics
```bash
./status.sh # runtime health check — is it g2g right now?
./install.sh --doctor # install-time correctness 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
```
`status.sh` walks what's actually *running* — service state, boot wiring,
display backend (auto-detects x11 / wlr / kms from your `sunshine.conf`),
encoder, ports, web UI, `/dev/uinput`, and pairing — then prints either
**g2g** or a concrete TODO list of what needs work (exit 1 if anything's
broken). Run it as the Sunshine user, or as root (`sudo ./status.sh`, which
auto-detects the user). `--doctor` is the install-time complement: it checks
the install is *correct*; `status.sh` checks the host is *up*.
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/
```