Files
Omarchy-Stream/README.md
Levi Woodard 171ade4ff1 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
2026-05-18 10:31:08 -06:00

13 KiB

omarchy-moonlight

Idempotent bash installer that sets up Sunshine (host) and Moonlight (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

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) 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 (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

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

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.

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

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

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/