From 171ade4ff14005ee8f99e4ff8f8b7481f27e9b83 Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Mon, 18 May 2026 10:31:08 -0600 Subject: [PATCH] Add headless streaming mode + Mac client + full docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 214 +++++++++++++++++++++++++----------- bin/sunshine-stream-do.sh | 66 +++++++++++ bin/sunshine-stream-undo.sh | 51 +++++++++ client/README.md | 125 +++++++++++++++++++++ client/install-macos.sh | 61 ++++++++++ install.sh | 32 +++++- lib/config.sh | 26 ++++- lib/headless.sh | 20 ++++ lib/packages.sh | 2 +- lib/preflight.sh | 31 ++++++ lib/verify.sh | 34 ++++++ 11 files changed, 590 insertions(+), 72 deletions(-) create mode 100644 bin/sunshine-stream-do.sh create mode 100644 bin/sunshine-stream-undo.sh create mode 100644 client/README.md create mode 100755 client/install-macos.sh create mode 100644 lib/headless.sh diff --git a/README.md b/README.md index ede0211..eaee413 100644 --- a/README.md +++ b/README.md @@ -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 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 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 ( → 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/ ``` diff --git a/bin/sunshine-stream-do.sh b/bin/sunshine-stream-do.sh new file mode 100644 index 0000000..e867738 --- /dev/null +++ b/bin/sunshine-stream-do.sh @@ -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" diff --git a/bin/sunshine-stream-undo.sh b/bin/sunshine-stream-undo.sh new file mode 100644 index 0000000..3a41522 --- /dev/null +++ b/bin/sunshine-stream-undo.sh @@ -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" diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..c0c8f7f --- /dev/null +++ b/client/README.md @@ -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. diff --git a/client/install-macos.sh b/client/install-macos.sh new file mode 100755 index 0000000..85d7755 --- /dev/null +++ b/client/install-macos.sh @@ -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." diff --git a/install.sh b/install.sh index 427c1ce..568a797 100755 --- a/install.sh +++ b/install.sh @@ -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 <"$SUNSHINE_CONF" </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 +} diff --git a/lib/verify.sh b/lib/verify.sh index 3bd5bf5..00b0d0a 100644 --- a/lib/verify.sh +++ b/lib/verify.sh @@ -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