# 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 ~/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 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://.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 `.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 ./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/ ```