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/
```

66
bin/sunshine-stream-do.sh Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Invoked by Sunshine as a stream-start hook (global_prep_cmd `do`).
# Creates/resizes a Hyprland headless output to match the connecting
# Moonlight client's resolution, and moves the active workspace onto it
# so the user's existing windows are visible on the stream.
#
# Sunshine env vars set on connect:
# SUNSHINE_CLIENT_WIDTH, SUNSHINE_CLIENT_HEIGHT, SUNSHINE_CLIENT_FPS
set -euo pipefail
log() { printf '[sunshine-do] %s\n' "$*" >&2; }
WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}"
HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}"
FPS="${SUNSHINE_CLIENT_FPS:-60}"
MON="HEADLESS-1"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
mkdir -p "$STATE_DIR"
if ! command -v hyprctl >/dev/null 2>&1; then
log "hyprctl not found; cannot configure headless. Stream will use whatever output Sunshine selects."
exit 0
fi
# Recover Hyprland signature if it wasn't inherited (defensive — UWSM exports it,
# but a stray service environment could miss it).
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
for sig_dir in "${XDG_RUNTIME_DIR:-/tmp}"/hypr/*/; do
[[ -d "$sig_dir" ]] || continue
export HYPRLAND_INSTANCE_SIGNATURE="$(basename "$sig_dir")"
log "Discovered HYPRLAND_INSTANCE_SIGNATURE=$HYPRLAND_INSTANCE_SIGNATURE"
break
done
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
log "Hyprland not running; nothing to configure."
exit 0
fi
fi
# Snapshot prior state so undo can restore.
hyprctl monitors -j > "$STATE_DIR/prev-monitors.json" 2>/dev/null || true
PREV_WS="$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.id // 1' || echo 1)"
echo "$PREV_WS" > "$STATE_DIR/prev-workspace-id"
# Ensure headless exists.
if ! hyprctl monitors all -j 2>/dev/null | jq -e --arg m "$MON" '.[] | select(.name == $m)' >/dev/null; then
log "Creating headless output $MON"
hyprctl output create headless >/dev/null
# Brief settle so Hyprland registers the new output before we configure it.
for _ in 1 2 3 4 5; do
hyprctl monitors all -j | jq -e --arg m "$MON" '.[] | select(.name == $m)' >/dev/null 2>&1 && break
sleep 0.1
done
fi
# Resize headless to the client's resolution / framerate.
log "Sizing $MON${WIDTH}x${HEIGHT}@${FPS}"
hyprctl keyword monitor "$MON,${WIDTH}x${HEIGHT}@${FPS},auto,1" >/dev/null
# Move the active workspace onto the headless so existing windows appear in the stream.
log "Moving workspace $PREV_WS$MON, focusing it"
hyprctl dispatch moveworkspacetomonitor "$PREV_WS $MON" >/dev/null || true
hyprctl dispatch focusmonitor "$MON" >/dev/null || true
log "Stream ready: ${WIDTH}x${HEIGHT}@${FPS} on $MON"

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Invoked by Sunshine as a stream-stop hook (global_prep_cmd `undo`).
# Moves the previously-active workspace back to a real monitor (if any
# exist) and tears down the headless output created by sunshine-stream-do.sh.
set -euo pipefail
log() { printf '[sunshine-undo] %s\n' "$*" >&2; }
MON="HEADLESS-1"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
if ! command -v hyprctl >/dev/null 2>&1; then
log "hyprctl not found; nothing to undo."
exit 0
fi
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
for sig_dir in "${XDG_RUNTIME_DIR:-/tmp}"/hypr/*/; do
[[ -d "$sig_dir" ]] || continue
export HYPRLAND_INSTANCE_SIGNATURE="$(basename "$sig_dir")"
break
done
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
log "Hyprland not running; nothing to undo."
exit 0
fi
fi
PREV_WS="$(cat "$STATE_DIR/prev-workspace-id" 2>/dev/null || echo 1)"
# Find a non-headless monitor to move the workspace back to. If there isn't one
# (truly headless host with KVM detached), the workspace just lives on whatever
# Hyprland reassigns it to when we remove the output.
REAL_MON="$(hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.name | test("^HEADLESS") | not) | .name' | head -n1)"
if [[ -n "$REAL_MON" ]]; then
log "Returning workspace $PREV_WS$REAL_MON"
hyprctl dispatch moveworkspacetomonitor "$PREV_WS $REAL_MON" >/dev/null || true
hyprctl dispatch focusmonitor "$REAL_MON" >/dev/null || true
else
log "No real monitor connected; leaving workspace assignment to Hyprland defaults."
fi
if hyprctl monitors all -j 2>/dev/null | jq -e --arg m "$MON" '.[] | select(.name == $m)' >/dev/null; then
log "Removing $MON"
hyprctl output remove "$MON" >/dev/null || true
fi
# Clean state files but keep the directory for the next run.
rm -f "$STATE_DIR/prev-monitors.json" "$STATE_DIR/prev-workspace-id"
log "Stream teardown complete"

125
client/README.md Normal file
View File

@@ -0,0 +1,125 @@
# Moonlight clients
This directory covers installing Moonlight on the devices that connect to your
Sunshine host. The host side (Sunshine on Linux) is documented in the
[top-level README](../README.md).
## Install
### macOS
From a Mac that has this repo checked out:
```sh
./client/install-macos.sh
```
The script verifies Homebrew is present and runs `brew install --cask moonlight`.
It will not install Homebrew for you.
Manual equivalent:
```sh
brew install --cask moonlight
```
Fallback (no Homebrew): download a signed DMG from the official releases page
and drag `Moonlight.app` into `/Applications`:
- https://github.com/moonlight-stream/moonlight-qt/releases
### Android
Install "Moonlight Game Streaming" from the Play Store:
- https://play.google.com/store/apps/details?id=com.limelight
### iOS / iPadOS
Install "Moonlight Game Streaming" from the App Store:
- https://apps.apple.com/us/app/moonlight-game-streaming/id1000551566
### Apple TV
Same listing on the tvOS App Store. Search "Moonlight Game Streaming" on the
Apple TV itself, or use the App Store link above from an iOS device signed in
to the same Apple ID.
### Steam Deck / Linux
Use `moonlight-qt` from your distro's package manager (Flatpak on Steam Deck,
`pacman`/`apt` elsewhere). It is the same Qt-based client as macOS and Windows.
## First pair
The pairing flow is the same on every client.
1. Confirm the host is healthy. On the host machine:
```sh
./install.sh --doctor
```
Sunshine must be running and reachable on the LAN.
2. On the client, open Moonlight. The host should appear automatically via
mDNS as long as the client is on the same LAN. If it does not appear, use
"Add Host Manually" and enter the host's LAN IP address.
3. Click or tap the host. Moonlight displays a 4-digit PIN.
4. On the host machine, open the Sunshine web UI:
```
https://localhost:47990
```
Accept the self-signed certificate. Log in (credentials are set the first
time you visit). Go to the PIN tab, enter the 4-digit PIN from the client,
and submit. Pairing typically completes within about 5 seconds.
5. Back in Moonlight, the host now shows as "Paired". Click it to see the
available apps. The default app is "Desktop", which is either a full-screen
mirror of the host's display or a headless virtual display, depending on
how the host is configured.
## Streaming notes
- **Resolution.** Moonlight lets you pick a target stream resolution per host.
On a headless-configured host, that resolution is what the host actually
renders at. On a mirror-configured host, the host renders at its native
resolution and Moonlight downscales on the client.
- **Bitrate.** Moonlight's defaults are conservative for general internet use.
For LAN streaming, bump it to 50-100 Mbps.
- **Audio.** By default, Moonlight captures audio from the host and plays it
on the client. If you want the host's speakers/monitor to keep playing
audio while you stream, enable "Play audio on host" in Moonlight's stream
settings.
## Troubleshooting
- **"Host not found" / host does not appear automatically.** Confirm both
machines are on the same LAN/VLAN. mDNS does not traverse VLANs without an
mDNS reflector. Fall back to "Add Host Manually" with the host's IP.
- **Pairing PIN does not work.** Two common causes: the Sunshine web UI
session has timed out (log in again and re-enter the PIN), or the PIN is
being entered into the wrong host (multiple Sunshine instances on the LAN).
Cancel and re-trigger pair from the client to get a fresh PIN.
- **Black screen after pairing.** This is a host-side problem. On the host:
```sh
./install.sh --doctor
```
- **Mac cannot reach `brew`.** Homebrew is not installed (or not on `PATH`).
Install it per https://brew.sh, open a fresh shell, then re-run
`./client/install-macos.sh`.
- **Android sees the host but cannot connect.** The Android device is
probably on a guest WiFi SSID that is isolated from the main LAN. Move it
to the main LAN, or use "Add Host Manually" with the host IP.

61
client/install-macos.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Install Moonlight on macOS via Homebrew cask.
# Standalone: does not source lib/common.sh (intended to run on a Mac that
# may not have the full repo checked out yet).
set -euo pipefail
if [[ -t 1 ]]; then
BOLD=$'\033[1m'
RED=$'\033[31m'
GREEN=$'\033[32m'
YELLOW=$'\033[33m'
BLUE=$'\033[34m'
RESET=$'\033[0m'
else
BOLD="" RED="" GREEN="" YELLOW="" BLUE="" RESET=""
fi
step() { printf '\n%s==>%s %s%s%s\n' "$BLUE" "$RESET" "$BOLD" "$*" "$RESET"; }
info() { printf ' %s\n' "$*"; }
ok() { printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$*"; }
warn() { printf ' %s!%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
err() { printf ' %s✗%s %s\n' "$RED" "$RESET" "$*" >&2; }
# Refuse to run anywhere but macOS.
if [[ "$(uname -s)" != "Darwin" ]]; then
err "This script only runs on macOS (Darwin). Detected: $(uname -s)"
exit 1
fi
step "Checking for Homebrew"
if ! command -v brew >/dev/null 2>&1; then
err "Homebrew is not installed."
info "Install it with the official one-liner, then re-run this script:"
info ""
info ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
info ""
info "See https://brew.sh for details."
exit 1
fi
ok "Homebrew found: $(command -v brew)"
step "Installing Moonlight (brew cask)"
# brew install --cask is idempotent: re-running on an already-installed cask
# is a no-op and exits 0.
if brew install --cask moonlight; then
ok "Moonlight installed (or already present)."
else
err "brew install --cask moonlight failed."
warn "Try: brew update && brew install --cask moonlight"
exit 1
fi
step "Next steps"
info "App location: /Applications/Moonlight.app"
info "Launch: open -a Moonlight"
info ""
info "Pair this Mac with your Sunshine host by following the walkthrough in:"
info " client/README.md"
info ""
ok "Done."

View File

@@ -24,6 +24,8 @@ source "$SCRIPT_DIR/lib/firewall.sh"
source "$SCRIPT_DIR/lib/service.sh"
# shellcheck source=lib/verify.sh
source "$SCRIPT_DIR/lib/verify.sh"
# shellcheck source=lib/headless.sh
source "$SCRIPT_DIR/lib/headless.sh"
usage() {
cat <<EOF
@@ -38,6 +40,8 @@ Options:
--no-sunshine Don't install sunshine (client-only setup)
--no-config Don't write a tuned sunshine.conf
--from-source Build Sunshine from source (equivalent to SUNSHINE_PKG=sunshine)
--headless Force headless streaming mode (wlr capture of HEADLESS-1)
--mirror Force mirror mode (KMS capture of the real display)
--doctor Run only the post-install verification checks
-h, --help Show this help
@@ -53,6 +57,7 @@ INSTALL_SUNSHINE=1
INSTALL_MOONLIGHT=1
WRITE_CONFIG=1
DOCTOR_ONLY=0
MODE_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -62,6 +67,8 @@ while [[ $# -gt 0 ]]; do
--no-sunshine) INSTALL_SUNSHINE=0 ;;
--no-config) WRITE_CONFIG=0 ;;
--from-source) export SUNSHINE_PKG=sunshine ;;
--headless) MODE_OVERRIDE="headless" ;;
--mirror) MODE_OVERRIDE="mirror" ;;
--doctor) DOCTOR_ONLY=1 ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown option: $1"; usage; exit 2 ;;
@@ -69,6 +76,21 @@ while [[ $# -gt 0 ]]; do
shift
done
# Pick streaming mode: explicit flag wins; otherwise default to headless on
# JARVIS (the KVM-attached primary target) and mirror everywhere else.
compute_stream_mode() {
if [[ -n "$MODE_OVERRIDE" ]]; then
STREAM_MODE="$MODE_OVERRIDE"
return 0
fi
local host_lc="${HOSTNAME_SHORT,,}"
if [[ "$host_lc" == "jarvis" ]]; then
STREAM_MODE="headless"
else
STREAM_MODE="mirror"
fi
}
main() {
require_not_root
require_arch
@@ -76,7 +98,10 @@ main() {
step "Detecting system"
detect_all
compute_stream_mode
export STREAM_MODE
info "Host: $HOSTNAME_SHORT GPU: $GPU_VENDOR Session: $SESSION_TYPE"
info "Mode: $STREAM_MODE"
if [[ $DOCTOR_ONLY -eq 1 ]]; then
verify_install
@@ -96,9 +121,14 @@ main() {
ensure_uinput_udev_rule
set_sunshine_capabilities
if [[ "$STREAM_MODE" == "headless" ]]; then
step "Installing headless prep-cmd hooks"
install_headless_hooks
fi
if [[ $WRITE_CONFIG -eq 1 ]]; then
step "Writing tuned sunshine.conf"
write_sunshine_config
write_sunshine_config "$STREAM_MODE"
else
info "Skipping sunshine.conf (--no-config)"
fi

View File

@@ -7,6 +7,7 @@ SUNSHINE_CONF="$SUNSHINE_CONF_DIR/sunshine.conf"
MANAGED_MARKER="# managed-by: omarchy-moonlight"
write_sunshine_config() {
local mode="${1:-mirror}"
mkdir -p "$SUNSHINE_CONF_DIR"
if [[ -f "$SUNSHINE_CONF" ]] && ! grep -qF "$MANAGED_MARKER" "$SUNSHINE_CONF"; then
@@ -31,19 +32,36 @@ write_sunshine_config() {
;;
esac
info "Writing tuned $SUNSHINE_CONF (GPU: $GPU_VENDOR)"
local capture_block
case "$mode" in
headless)
# wlr capture of the Hyprland headless output that prep-cmd hooks create.
# global_prep_cmd is parsed as a JSON array by Sunshine, so keep it on one line.
capture_block="# Capture: wlr — captures a Hyprland headless output created on stream start.
capture = wlr
output_name = HEADLESS-1
# Hooks that create/resize HEADLESS-1 on stream start and remove it on stop.
global_prep_cmd = [{\"do\":\"${DO_SCRIPT}\",\"undo\":\"${UNDO_SCRIPT}\"}]"
;;
mirror|*)
capture_block="# Capture: KMS (DRM) — the right choice on Wayland.
capture = kms"
;;
esac
info "Writing tuned $SUNSHINE_CONF (GPU: $GPU_VENDOR, mode: $mode)"
cat >"$SUNSHINE_CONF" <<EOF
$MANAGED_MARKER
# Generated by omarchy-moonlight install.sh on $(date -Iseconds)
# Delete this marker line to take ownership; the installer will then leave the file alone.
#
# Tuned for low-latency LAN streaming on Hyprland/Wayland with KMS capture.
# Tuned for low-latency LAN streaming on Hyprland/Wayland.
# Tune further via the web UI at https://localhost:47990
sunshine_name = ${HOSTNAME_SHORT}
# Capture: KMS (DRM) — the right choice on Wayland.
capture = kms
$capture_block
# Encoder tuned for $GPU_VENDOR
$encoder_block

20
lib/headless.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Set up headless streaming mode: install prep-cmd hook scripts into a stable
# location and ensure they're executable. The actual sunshine.conf entries
# (capture=wlr, output_name=HEADLESS-1, global_prep_cmd=[...]) are written
# by lib/config.sh.
HEADLESS_BIN_DIR="$HOME/.local/share/omarchy-moonlight/bin"
DO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-do.sh"
UNDO_SCRIPT="$HEADLESS_BIN_DIR/sunshine-stream-undo.sh"
export DO_SCRIPT UNDO_SCRIPT
install_headless_hooks() {
# Install hook scripts to ~/.local/share so they don't disappear if the
# repo gets moved or deleted. Sunshine's config will reference these stable paths.
mkdir -p "$HEADLESS_BIN_DIR"
install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-do.sh" "$DO_SCRIPT"
install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-undo.sh" "$UNDO_SCRIPT"
ok "Installed prep-cmd hooks to $HEADLESS_BIN_DIR"
}

View File

@@ -9,7 +9,7 @@
install_sunshine() {
# Ensure runtime deps useful for capture/diagnostics across vendors.
yay_install pipewire-pulse vulkan-tools libva-utils
yay_install pipewire-pulse vulkan-tools libva-utils jq
yay_install "$SUNSHINE_PKG"
}

View File

@@ -8,6 +8,7 @@ preflight_all() {
preflight_gpu
preflight_kms_modeset
preflight_audio
preflight_headless
}
preflight_session() {
@@ -96,3 +97,33 @@ preflight_audio() {
warn "pipewire-pulse is not installed. Audio capture may not work until it is."
fi
}
preflight_headless() {
# Only relevant in headless mode. Checks are non-fatal: install can proceed
# even if Hyprland isn't reachable right now (hooks just won't function until
# the user logs into Hyprland on the host).
[[ "${STREAM_MODE:-}" == "headless" ]] || return 0
if command -v hyprctl >/dev/null 2>&1; then
ok "hyprctl on PATH"
else
warn "hyprctl not found. Headless prep-cmd hooks will fail until Hyprland is installed and reachable."
fi
if pkg_installed jq; then
ok "jq installed (prep-cmd hooks have their parser)"
else
info "jq not installed yet — will be installed in the packages step."
fi
if [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
ok "Hyprland instance signature present in environment"
else
local rt="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
if compgen -G "$rt/hypr/*/" >/dev/null 2>&1; then
ok "Hyprland runtime directory found under $rt/hypr/"
else
warn "Hyprland does not appear to be running. Install will proceed; hooks will only work once you log into Hyprland on the host."
fi
fi
}

View File

@@ -67,6 +67,40 @@ verify_install() {
;;
esac
if [[ "${STREAM_MODE:-}" == "headless" ]]; then
local do_script="$HOME/.local/share/omarchy-moonlight/bin/sunshine-stream-do.sh"
local undo_script="$HOME/.local/share/omarchy-moonlight/bin/sunshine-stream-undo.sh"
local conf="$HOME/.config/sunshine/sunshine.conf"
if [[ -x "$do_script" ]]; then
ok "headless do-hook present and executable"
else
err "headless do-hook missing or not executable: $do_script"
VERIFY_FAILURES=$((VERIFY_FAILURES + 1))
fi
if [[ -x "$undo_script" ]]; then
ok "headless undo-hook present and executable"
else
err "headless undo-hook missing or not executable: $undo_script"
VERIFY_FAILURES=$((VERIFY_FAILURES + 1))
fi
if [[ -f "$conf" ]] && grep -q '^capture = wlr' "$conf"; then
ok "sunshine.conf has capture = wlr"
else
err "sunshine.conf missing 'capture = wlr'"
VERIFY_FAILURES=$((VERIFY_FAILURES + 1))
fi
if [[ -f "$conf" ]] && grep -q '^global_prep_cmd' "$conf"; then
ok "sunshine.conf has global_prep_cmd"
else
err "sunshine.conf missing 'global_prep_cmd'"
VERIFY_FAILURES=$((VERIFY_FAILURES + 1))
fi
fi
if systemctl --user is-active --quiet sunshine.service; then
ok "sunshine.service is active"
if ss -ltn 2>/dev/null | grep -q ':47990 '; then