Self-healing headless, working JARVIS install fixes, public-safe docs

This bundles every fix we made debugging the first real install plus a
comprehensive troubleshooting reference. Working tree is now PII-safe for
public distribution: hostname-based default mode is driven by a HEADLESS_HOSTS
env var instead of a hardcoded literal; docs use placeholders for hostnames
and LAN IPs.

Self-healing headless management
- bin/sunshine-prestart.sh (new): runs as systemd ExecStartPre. Resolves the
  Hyprland instance signature from XDG_RUNTIME_DIR/hypr when systemd-user env
  didn't propagate it. Reduces to exactly one headless output by keeping the
  lowest-numbered HEADLESS-N and removing the rest. Rewrites the managed
  sunshine.conf's output_name line to match the surviving name — Hyprland's
  HEADLESS-N counter is monotonic and ignores the optional name argument to
  'output create headless', so without active sync output_name drifts off
  HEADLESS-1 after the first restart cycle.
- bin/sunshine-stream-do.sh: dropped the hardcoded MON=HEADLESS-1. Now
  discovers whatever HEADLESS-* exists via jq. Resize and workspace migration
  target the actual output.
- bin/sunshine-stream-undo.sh: reads the headless name from a state file the
  do-script wrote, with discovery fallback. Stops removing the output between
  sessions — the create/destroy race caused fatal startup encoder errors on
  the next Sunshine restart.
- files/headless-prestart.conf, files/sunshine.service: ExecStartPre now
  points at the new prestart script.
- lib/headless.sh: install_headless_hooks now installs all three scripts.
  New install_headless_prestart_dropin resolves the actual systemd unit name
  (sunshine.service vs app-dev.lizardbyte.app.Sunshine.service) and lands the
  drop-in under <unit>.service.d/.

Firewall detection
- lib/firewall.sh: _ufw_active now uses 'systemctl is-active ufw.service'
  instead of 'ufw status'. The latter requires root to read /etc/ufw state,
  so the unprivileged probe returned false and we silently skipped opening
  Sunshine's ports on hosts where ufw was actively dropping packets.

Service unit fallbacks
- lib/service.sh: ensure_sunshine_unit_present looks for sunshine.service in
  every systemd-user path first; falls back to the reverse-DNS AUR-source
  unit name; last resort drops a repo-provided fallback unit. systemctl
  reset-failed before each restart so a previous start-limit-hit doesn't
  immediately reject the new attempt.

Preflight
- lib/preflight.sh: new preflight_headless step that, only when STREAM_MODE
  is headless, surfaces missing hyprctl / jq / Hyprland reachability before
  install proceeds.

Public-safe defaults
- install.sh: streaming-mode default is now driven by HEADLESS_HOSTS env var
  (comma-separated, case-insensitive). Unset by default — every host gets
  mirror mode unless its hostname is listed or --headless is passed
  explicitly. Past versions hardcoded a specific hostname.
- README.md: replaced JARVIS-specific examples with HEADLESS_HOSTS prose.

Docs
- docs/TROUBLESHOOTING.md (new): comprehensive failure-mode reference. Every
  issue hit during the first end-to-end install, in order, with symptom →
  cause → fix → permanent prevention. Plus a "Custom keybinding to escape
  Moonlight" section and an outstanding-followups punch list (1Password
  black-rectangle workarounds, hypridle inhibit during stream, busiest-
  workspace auto-switch, jarvis.lan DNS, 1Password SSH agent timeouts).
This commit is contained in:
2026-05-18 16:52:41 -06:00
parent 4d2f050e33
commit 16e2465cf5
10 changed files with 659 additions and 24 deletions

View File

@@ -24,8 +24,8 @@ The installer picks between two capture strategies. The choice gets baked into `
| Mode | Capture backend | Stream resolution | Needs physical monitor? | Default on | | 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` | | **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 | Hostname `JARVIS` | | **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. Override with `--headless` or `--mirror` on any host.
@@ -44,7 +44,7 @@ Sunshine captures a Hyprland headless output. On each client connect, a `global_
On disconnect, `sunshine-stream-undo.sh` moves the workspace back to a real monitor (if one exists) and removes `HEADLESS-1`. 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. 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 ## What the installer does
@@ -69,7 +69,7 @@ Every step is idempotent. In order:
## Flags ## Flags
```text ```text
./install.sh # auto-detect mode (headless on JARVIS, mirror elsewhere) ./install.sh # mirror by default; set HEADLESS_HOSTS=myhost for hostname-based headless default
./install.sh --headless # force headless mode ./install.sh --headless # force headless mode
./install.sh --mirror # force mirror mode ./install.sh --mirror # force mirror mode
./install.sh --no-autostart # install but don't enable systemctl --user sunshine ./install.sh --no-autostart # install but don't enable systemctl --user sunshine

81
bin/sunshine-prestart.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Runs as a systemd ExecStartPre for the Sunshine service. Two jobs:
# 1. Make sure exactly one Hyprland headless output exists.
# 2. Sync sunshine.conf's `output_name` to whatever the headless output is
# currently named — Hyprland's HEADLESS-N counter doesn't reset across
# session restarts, so pinning to HEADLESS-1 drifts after the first
# remove/create cycle.
#
# Non-fatal at every step: a stale state can't worsen things by aborting here.
set -uo pipefail
log() { printf '[sunshine-prestart] %s\n' "$*" >&2; }
CONF="$HOME/.config/sunshine/sunshine.conf"
# Recover Hyprland's instance signature when the unit's env didn't propagate it.
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
for sig in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/hypr/*/; do
[[ -d "$sig" ]] || continue
export HYPRLAND_INSTANCE_SIGNATURE="$(basename "$sig")"
break
done
fi
if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
log "Hyprland not running; nothing to prepare."
exit 0
fi
if ! command -v hyprctl >/dev/null || ! command -v jq >/dev/null; then
log "hyprctl/jq missing; skipping prestart."
exit 0
fi
# Reduce to exactly one headless output. Hyprland's HEADLESS-N counter
# increments on every create and never decrements, so previous failed runs
# leave extras laying around. Remove all but the lowest-numbered one (most
# likely to be the one with workspaces bound to it).
mapfile -t headless_outputs < <(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' \
| sort -V)
existing="${headless_outputs[0]:-}"
if [[ -z "$existing" ]]; then
log "No headless output present; creating one"
hyprctl output create headless >/dev/null
for _ in 1 2 3 4 5; do
existing="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' \
| sort -V | head -1)"
[[ -n "$existing" ]] && break
sleep 0.1
done
elif [[ ${#headless_outputs[@]} -gt 1 ]]; then
log "Found ${#headless_outputs[@]} headless outputs; keeping $existing, removing the rest"
for extra in "${headless_outputs[@]:1}"; do
hyprctl output remove "$extra" >/dev/null 2>&1 || true
done
fi
if [[ -z "$existing" ]]; then
log "Failed to obtain a headless output; Sunshine will start without one."
exit 0
fi
log "Headless output present: $existing"
# Sync sunshine.conf's output_name. Only touch the file if it's our managed
# variant (has the management marker) AND the line has actually drifted.
if [[ -f "$CONF" ]] && grep -qF '# managed-by: omarchy-moonlight' "$CONF"; then
current="$(awk '/^output_name = / {print $3; exit}' "$CONF" 2>/dev/null || true)"
if [[ "$current" != "$existing" ]]; then
log "Updating sunshine.conf output_name: ${current:-(unset)} -> $existing"
if grep -q '^output_name = ' "$CONF"; then
sed -i "s|^output_name = .*|output_name = $existing|" "$CONF"
else
printf '\noutput_name = %s\n' "$existing" >> "$CONF"
fi
fi
fi
exit 0

View File

@@ -14,7 +14,6 @@ log() { printf '[sunshine-do] %s\n' "$*" >&2; }
WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}" WIDTH="${SUNSHINE_CLIENT_WIDTH:-1920}"
HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}" HEIGHT="${SUNSHINE_CLIENT_HEIGHT:-1080}"
FPS="${SUNSHINE_CLIENT_FPS:-60}" FPS="${SUNSHINE_CLIENT_FPS:-60}"
MON="HEADLESS-1"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless" STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
@@ -43,16 +42,26 @@ 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)" PREV_WS="$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.id // 1' || echo 1)"
echo "$PREV_WS" > "$STATE_DIR/prev-workspace-id" echo "$PREV_WS" > "$STATE_DIR/prev-workspace-id"
# Ensure headless exists. # Discover whatever headless output already exists. sunshine-prestart.sh is
if ! hyprctl monitors all -j 2>/dev/null | jq -e --arg m "$MON" '.[] | select(.name == $m)' >/dev/null; then # responsible for ensuring one exists and aligning sunshine.conf's output_name
log "Creating headless output $MON" # to its actual name (Hyprland's HEADLESS-N counter drifts across restarts).
MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
if [[ -z "$MON" ]]; then
log "No headless output found; creating one"
hyprctl output create headless >/dev/null 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 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 MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
[[ -n "$MON" ]] && break
sleep 0.1 sleep 0.1
done done
fi fi
if [[ -z "$MON" ]]; then
log "Failed to obtain a headless output; bailing."
exit 0
fi
echo "$MON" > "$STATE_DIR/headless-name"
# Resize headless to the client's resolution / framerate. # Resize headless to the client's resolution / framerate.
log "Sizing $MON${WIDTH}x${HEIGHT}@${FPS}" log "Sizing $MON${WIDTH}x${HEIGHT}@${FPS}"

View File

@@ -7,8 +7,9 @@ set -euo pipefail
log() { printf '[sunshine-undo] %s\n' "$*" >&2; } log() { printf '[sunshine-undo] %s\n' "$*" >&2; }
MON="HEADLESS-1"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless" STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/sunshine-headless"
# Headless name was captured by sunshine-stream-do.sh; fall back to discovery.
MON="$(cat "$STATE_DIR/headless-name" 2>/dev/null || true)"
if ! command -v hyprctl >/dev/null 2>&1; then if ! command -v hyprctl >/dev/null 2>&1; then
log "hyprctl not found; nothing to undo." log "hyprctl not found; nothing to undo."
@@ -29,6 +30,11 @@ fi
PREV_WS="$(cat "$STATE_DIR/prev-workspace-id" 2>/dev/null || echo 1)" PREV_WS="$(cat "$STATE_DIR/prev-workspace-id" 2>/dev/null || echo 1)"
if [[ -z "$MON" ]]; then
MON="$(hyprctl monitors -j 2>/dev/null \
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | head -1)"
fi
# Find a non-headless monitor to move the workspace back to. If there isn't one # 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 # (truly headless host with KVM detached), the workspace just lives on whatever
# Hyprland reassigns it to when we remove the output. # Hyprland reassigns it to when we remove the output.

514
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,514 @@
# Troubleshooting & lessons from the first end-to-end install
Every failure mode below was hit in practice during the first end-to-end install on a real Omarchy/NVIDIA/Wayland host. The fixes
landed in this repo so future installs (e.g. on the Framework) shouldn't repeat
them — this doc explains the *why* so you can recognize them if they come back
with a twist.
The "Real failures hit on first install" section is in roughly the order they
surfaced.
---
## Real failures hit on first install
### 1. `sunshine-bin` breaks on rolling Arch (library drift)
**Symptom**
```
sunshine: error while loading shared libraries: libicuuc.so.76:
cannot open shared object file: No such file or directory
```
Service hits the systemd start-limit after five rapid failures and won't try
again until `reset-failed`.
**Cause**
The AUR `sunshine-bin` package ships a binary built against whatever ICU was
current at AUR-build time (ICU 76). Arch had moved to ICU 78. Library SONAME is
hard-pinned at build time, so the binary fails to load any of its ICU calls.
This recurs whenever Arch bumps a major library and the `sunshine-bin`
maintainer hasn't rebuilt yet.
**Fix in this repo**
`lib/packages.sh``install_sunshine` now runs `ldd $(command -v sunshine)`
after install. If anything is "not found", removes `sunshine-bin` (and
`sunshine-bin-debug`) and rebuilds from source. The source-build linking pulls
in the *current* ICU, so it works as long as Arch ships any version.
`lib/service.sh``enable_sunshine_service` runs `systemctl --user
reset-failed` before each restart, so a previous failure doesn't preempt the
recovery.
**Manual recovery if you hit it on a non-installer flow**
```bash
sudo pacman -Rns --noconfirm sunshine-bin sunshine-bin-debug
yay -S --needed --noconfirm sunshine
sudo setcap cap_sys_admin+p "$(readlink -f "$(command -v sunshine)")"
systemctl --user reset-failed sunshine.service
```
### 2. Source build is slow (~10 min); cached pkg.tar.zst install collides on debug symbols
**Symptom**
```
error: failed to commit transaction (conflicting files)
sunshine-debug: /usr/lib/debug/usr/bin/sunshine.debug exists in filesystem
(owned by sunshine-bin-debug)
```
After a successful `makepkg`, `pacman -U` of the resulting `.pkg.tar.zst`
refuses to install.
**Cause**
`sunshine-bin` ships with a separate `sunshine-bin-debug` package for debug
symbols, owned by `sunshine-bin-debug`. The source build's
`sunshine-debug` package wants to put its symbols at the same path. Removing
`sunshine-bin` doesn't auto-remove its `-debug` sibling.
**Fix in this repo**
`lib/packages.sh` removes both `sunshine-bin` AND `sunshine-bin-debug` before
the source rebuild. `uninstall.sh` similarly drops all four variants
(`sunshine`, `sunshine-debug`, `sunshine-bin`, `sunshine-bin-debug`).
### 3. Source `sunshine` doesn't ship `sunshine.service`
**Symptom**
```
Failed to restart sunshine.service: Unit sunshine.service not found.
```
After replacing `sunshine-bin` with the source `sunshine` build, the service
that worked before is gone.
**Cause**
The AUR source PKGBUILD installs its systemd unit under a Flatpak-style
reverse-DNS filename: `app-dev.lizardbyte.app.Sunshine.service`. The
`sunshine-bin` variant ships `sunshine.service` directly.
**Fix in this repo**
`lib/service.sh``ensure_sunshine_unit_present` resolves the actual unit
name in three steps:
1. Look for `sunshine.service` in every systemd-user lookup path.
2. If not found, look for `app-dev.lizardbyte.app.Sunshine.service` and use
that name directly (we no longer try to symlink it as `sunshine.service`
— systemd refuses to `enable` linked unit files).
3. Last resort: install the repo's `files/sunshine.service` fallback unit.
The drop-in path (for the headless prestart hook) is computed against the
resolved unit name so `<unit-name>.service.d/` always lands in the right
place.
### 4. The fatal-but-misleading "Unable to find display or encoder during startup"
**Symptom**
```
[wlgrab] -------- Start of Wayland monitor list --------
[wlgrab] --------- End of Wayland monitor list ---------
Error: Unable to initialize capture method
...
Encoder [nvenc] failed
Encoder [vulkan] failed
Encoder [vaapi] failed
Encoder [software] failed
Fatal: Unable to find display or encoder during startup.
Fatal: Please ensure your manually chosen GPU and monitor are connected
```
Visible in the web UI as red banner warnings. Streaming may still appear to
work later because Sunshine re-probes at stream time, but the startup state is
broken.
**Cause**
In headless mode, `sunshine.conf` pins `output_name = HEADLESS-1` and
`capture = wlr`. At service start, Sunshine connects to Wayland, enumerates
outputs, and finds *nothing* — because the Hyprland headless output is created
by the per-stream prep-cmd, not at boot. With no output, every encoder probe
fails on platform-init.
**Fix in this repo**
`bin/sunshine-prestart.sh` runs as `ExecStartPre` for the Sunshine service. It
guarantees one (and exactly one) Hyprland headless output exists *before*
Sunshine probes encoders. It also rewrites `sunshine.conf`'s `output_name`
line to match whatever name Hyprland assigned the surviving headless (see
issue 6).
The drop-in lives at
`~/.config/systemd/user/<unit-name>.service.d/headless-prestart.conf`,
managed by `lib/headless.sh``install_headless_prestart_dropin`.
The `bin/sunshine-stream-undo.sh` was also changed to *keep* the headless
across stream sessions — create/destroy per stream raced with the startup
encoder probe.
### 5. Web UI 0.0.0.0 binding was open to the LAN
**Symptom**
By default Sunshine binds the web UI on `0.0.0.0:47990`. Anyone on your LAN
who can reach the host can hit the admin panel; only a username/password
guards it.
**Fix in this repo**
`lib/config.sh` writes `origin_web_ui_allowed = pc`. Sunshine then rejects
web-UI requests from any source other than localhost regardless of bind
address. Streaming/pairing on port 47989 stays LAN-accessible — only the
admin surface is locked down.
**Recommended access pattern (also documented inline in `sunshine.conf`):**
```bash
# From a client machine:
ssh -L 47990:127.0.0.1:47990 user@host
# Then browse to:
https://localhost:47990
```
The host cert SANs include `localhost` and `127.0.0.1` (see issue 11) so the
tunneled URL doesn't trigger hostname-mismatch warnings.
### 6. Hyprland headless counter never decrements
**Symptom**
After several restarts, `hyprctl monitors all` shows
`HEADLESS-4`, `HEADLESS-9`, `HEADLESS-10`, ...
`Selected monitor [] for streaming` in the journal — Sunshine can't match
`output_name = HEADLESS-1` against any existing output.
**Cause**
Hyprland's `hyprctl output create headless` ignores the optional name
argument (at least through 0.54.x) and auto-numbers as `HEADLESS-N`. The
counter is monotonic across the Hyprland session — removing `HEADLESS-1` and
re-creating gives you `HEADLESS-2`, not `HEADLESS-1`. Combine that with an
old version of the prep-cmd script that hardcoded `MON=HEADLESS-1` and
created a new output every connect, and you accumulate a herd.
**Fix in this repo**
Two-layer:
1. `bin/sunshine-prestart.sh` actively *removes* extras, keeping only the
lowest-numbered `HEADLESS-N`. Then it rewrites `sunshine.conf`'s
`output_name` to that surviving name. Self-healing.
2. `bin/sunshine-stream-do.sh` and `sunshine-stream-undo.sh` now discover
the existing headless name via `jq` instead of hardcoding `HEADLESS-1`.
Resize and workspace-migration target the actual output.
### 7. ufw was active but our detection missed it
**Symptom**
`ARP` between Mac and the host worked fine (layer 2 visible). TCP to port
47989 hung from the Mac. `nmap -p 47989 192.168.x.y` said "Host seems
down."
**Cause**
`lib/firewall.sh``_ufw_active` previously ran `ufw status | grep -q
"Status: active"`. `ufw status` requires root to read `/etc/ufw` state, so
an unprivileged check returned nothing → returned false → we silently
skipped opening Sunshine's ports. ufw was happily dropping inbound packets.
**Fix in this repo**
`_ufw_active` now uses `systemctl is-active ufw.service`. ufw runs as a
Type=oneshot unit with RemainAfterExit, so `is-active` reports `active`
while the rules are loaded — and the check works without sudo.
**One-shot LAN allow if you hit it**
```bash
for port in 47984 47989 47990 48010; do
sudo ufw allow from 192.168.x.0/24 to any port $port proto tcp
done
for port in 47998 47999 48000 48010; do
sudo ufw allow from 192.168.x.0/24 to any port $port proto udp
done
sudo ufw status verbose
```
### 8. Existing prep-cmd hooks were a stale version
**Symptom**
Headless output stayed at 1920x1080 regardless of what Moonlight requested.
`hyprctl monitors all` showed extra `HEADLESS-N` appearing on each
reconnect. Journal had no `[sunshine-do]` log lines.
**Cause**
`~/.local/share/omarchy-moonlight/bin/sunshine-stream-do.sh` was an early
version that hardcoded `MON=HEADLESS-1`. Each connect: didn't find
`HEADLESS-1`, ran `hyprctl output create headless` (spawned a new
unnamed one), then tried to resize the non-existent `HEADLESS-1` — silent
no-op. The repo's `bin/` had the discovery-based version, but it had never
been re-installed after the first install.
Sunshine's stderr capture also doesn't propagate prep-cmd output to the
journal, so the silent failures were invisible.
**Fix in this repo**
`lib/headless.sh``install_headless_hooks` now installs all three scripts
(do / undo / prestart) into `~/.local/share/omarchy-moonlight/bin/` and is
re-run on every `install.sh` invocation — re-installing keeps them in
lockstep with the repo.
For ad-hoc debugging of the prep-cmd, add a file-logging shim:
```bash
sed -i '/^set -euo pipefail/a exec > >(tee -a /tmp/sunshine-prepcmd.log) 2>&1' \
~/.local/share/omarchy-moonlight/bin/sunshine-stream-do.sh
```
then `cat /tmp/sunshine-prepcmd.log` after a reconnect to see exactly what
the script saw and what it tried to do.
### 9. omarchy-screensaver shows in the stream instead of your apps
**Symptom**
Stream connects, shows the omarchy screensaver. Workspace shows
`hasfullscreen: 1`, `lastwindowtitle: omarchy-screensaver`.
**Cause**
Omarchy's hypridle config runs the screensaver after the host's idle
threshold. Input arriving via Sunshine's `/dev/uinput` virtual devices
doesn't always reset hypridle's input-detection (depends on whether
hypridle treats uinput devices as activity sources). When you sit on the
Mac for a while, the host thinks the host is idle, fires the screensaver.
**Current workaround**
Wiggle the mouse / tap a key in the stream window — hypridle will register
the activity and dismiss the screensaver.
**Outstanding fix (not yet implemented):**
`sunshine-stream-do.sh` should issue `hyprctl dispatch
exec hyprctl reload` or `systemctl --user stop hypridle.service`-style
inhibition when a stream starts, and re-enable on stream stop via the
undo hook. Alternatively, add a `hypridle.conf` rule that excludes the
streaming session from idle counting. See follow-up #1 below.
### 10. Moonlight Mac defaults make it a roach motel
**Symptom**
After enabling "Capture system keyboard shortcuts: Always," all standard
Mac escapes (Cmd+Tab, Cmd+Q, even Cmd+Option+Esc) get forwarded to the
host. Mouse is also captured in game-mode. No clear way out.
**Causes & fixes (Mac-side settings)**
In Moonlight on the Mac → gear icon → Input section:
| Setting | Value | Why |
|---|---|---|
| Optimize mouse for remote desktop | **ON** | Absolute-position cursor; can mouse out of Moonlight onto other Mac apps |
| Capture system keyboard shortcuts | **Only in fullscreen** | Windowed-mode Mac shortcuts still work; capture kicks in only on explicit fullscreen |
| Swap Cmd and Ctrl keys | **OFF** | Keep `Cmd → Super` mapping for Hyprland binds |
**Emergency exit** that always works (macOS-level, can't be captured):
- Three-finger swipe up (Mission Control)
- `Cmd + Option + Esc` (Force Quit window) — usually
- `Ctrl + Shift + Q` (system logout dialog — steals focus from Moonlight, then cancel)
**Reliable custom exit shortcut** — see "Custom keybinding" section below.
### 11. Web UI cert hostname mismatch under SSH tunnel
**Symptom**
After bringing the SSH tunnel up (`ssh -L 47990:127.0.0.1:47990 ...`),
visiting `https://localhost:47990` shows a browser cert warning even
though the cert IS signed by the omarchy-stream CA.
**Cause**
The host cert's SAN list was originally only `<hostname>.lan` and the LAN
IP. `localhost` and `127.0.0.1` weren't valid for that cert, so the browser
correctly rejected the hostname.
**Fix in this repo**
`lib/certs.sh` now mints host certs with SANs:
```
DNS.1 = ${host_lc}.lan
DNS.2 = localhost
IP.1 = ${lan_ip}
IP.2 = 127.0.0.1
```
And the idempotency check in `fetch_and_install_certs` requires those SANs
— existing hosts re-mint on next install.
---
## Custom keybinding to escape Moonlight (Mac)
Three options. Option C is most reliable.
### A. macOS App Shortcut (5 min, no extra software)
System Settings → Keyboard → Keyboard Shortcuts → App Shortcuts → `+`:
- Application: *Moonlight Game Streaming*
- Menu Title: `Quit Moonlight Game Streaming` (or `Disconnect`)
- Keyboard Shortcut: e.g. `⌘⎋` (Cmd+Esc)
Requires Moonlight's "Capture system keyboard shortcuts" to be **Only in
fullscreen** so the shortcut reaches macOS in windowed mode.
### B. Karabiner-Elements (best for fullscreen)
Karabiner intercepts at HID, below the Moonlight app layer. The setup pasted
in the troubleshooting chat: bind `Right ⌘ + Esc``killall Moonlight`.
```json
{
"title": "Force-quit Moonlight",
"rules": [{
"description": "Right-Cmd + Escape → killall Moonlight",
"manipulators": [{
"type": "basic",
"from": {
"key_code": "escape",
"modifiers": { "mandatory": ["right_command"] }
},
"to": [{ "shell_command": "killall Moonlight" }]
}]
}]
}
```
### C. Stream Deck (since you have one)
System → Multimedia → Hotkey → send `⌃⌥⇧Q` (Moonlight's built-in quit-stream
combo) or System → Open with command `/usr/bin/killall Moonlight`. Stream
Deck input is HID; Moonlight can't capture it.
---
## Outstanding follow-ups
### 1. Inhibit hypridle / screensaver during stream
Currently the omarchy screensaver fires after the host's idle timeout even
when a stream is active. Either:
- Have `sunshine-stream-do.sh` send a `systemd-inhibit` lock or `hyprctl
dispatch global hyprlock:lock-off` (does that exist?) when a stream
starts, release in `sunshine-stream-undo.sh`.
- Or add an `unbind` for the input watcher in `~/.config/hypr/hypridle.conf`
when a `SUNSHINE_STREAMING=1` env marker is present.
**Outcome wanted:** no screensaver while any Moonlight client is connected.
### 2. Auto-switch to the workspace with the most windows on connect
Currently `sunshine-stream-do.sh` migrates the *active* workspace to the
headless. If active was empty (workspace 4) and the user's apps are on
workspace 1, they see nothing until pressing `Cmd+1`.
Two-line patch in `sunshine-stream-do.sh`:
```bash
TARGET_WS="$(hyprctl workspaces -j | jq -r 'sort_by(-.windows) | .[0].id')"
PREV_WS="$TARGET_WS" # instead of activeworkspace
```
Pick the busiest workspace and migrate that. User lands on their work.
### 3. 1Password window streams as a black rectangle
By design — 1Password masks its window from screen capture. Workarounds in
order of preference:
1. Launch 1Password with `--disable-gpu --no-sandbox` — software rendering
path may evade the anti-capture.
2. Launch with `--ozone-platform=x11` — XWayland surface captured
differently than native-Wayland surface.
3. Use the 1Password browser extension during the streamed session — the
extension's UI lives in Chromium, which captures normally.
4. Use `op` CLI for everything CLI-driven (cert pipeline already does this).
5. Unlock 1Password locally on the host before walking away; other apps that
talk to the 1Password agent (browser extension, `op` CLI, SSH signing
agent) work fine even though the desktop app's window can't be seen.
We haven't picked one yet — see chat for next steps.
### 4. mDNS / `your-host.lan` doesn't resolve
Even on the host, `getent hosts your-host.lan` returns nothing. To use the
friendly hostname instead of the LAN IP from clients, set up the DNS entry
in Unifi (the user's network gear). Until then, clients use `192.168.x.y`
directly.
### 5. Periodic 1Password SSH-agent timeout breaks git signing
During this install the 1P SSH agent went idle several times mid-session,
causing `git commit` to fail with `1Password: failed to fill whole buffer`.
Touching the 1Password desktop app revives it.
Possible mitigations:
- Set a longer SSH-agent timeout in 1Password app settings.
- Add a watchdog that warns if `ssh-add -l` fails for >N minutes.
- Disable commit signing for this repo specifically (not recommended; just
noting the option).
---
## Quick reference — the install sequence as it actually runs end-to-end
After `./install.sh` (with all the fixes above):
1. Preflight checks (GPU, modeset, audio, headless prereqs)
2. `yay -S sunshine-bin moonlight-qt pipewire-pulse vulkan-tools libva-utils jq`
3. If `sunshine-bin` has unresolved libs → rebuild from source (`sunshine`)
4. Add user to `input` group, install uinput udev rule, `setcap cap_sys_admin+p`
5. Write tuned `~/.config/sunshine/sunshine.conf`:
- `capture = wlr`, `output_name = HEADLESS-1` (will be re-synced by prestart)
- `encoder = nvenc` + low-latency NVENC params
- `global_prep_cmd` → discovery-based do/undo scripts
- `origin_web_ui_allowed = pc`
6. Install hook scripts (`do`, `undo`, `prestart`) into
`~/.local/share/omarchy-moonlight/bin/`
7. Install prestart drop-in into
`~/.config/systemd/user/<unit-name>.service.d/headless-prestart.conf`
8. Open Sunshine's LAN ports on whatever firewall is active
9. Enable the service, `loginctl enable-linger`, start
10. (1Password-CLI signed-in) Fetch the CA from 1Password, mint a host cert with
SANs for `<hostname>.lan`, LAN IP, `localhost`, `127.0.0.1`. Install CA into
`/etc/ca-certificates`.
11. Restart service so it picks up the new cert
12. Verify: cap_sys_admin set, service active on :47990, encoder reachable,
hooks present, CA in trust store
Manual one-time setup on each Moonlight client:
- Mac: `./client/install-macos.sh` (brew cask + CA add to System keychain)
- Other Linux host: same `./install.sh` puts the CA into its system trust store
- iOS/Android/Apple TV: install Moonlight from app store + import CA manually

View File

@@ -5,4 +5,8 @@
# non-fatal so Sunshine still starts if Hyprland isn't reachable. # non-fatal so Sunshine still starts if Hyprland isn't reachable.
[Service] [Service]
ExecStartPre=-/usr/bin/hyprctl output create headless # Idempotent: only creates a headless output if none exists yet, and rewrites
# sunshine.conf's output_name to match the actual name (Hyprland increments
# HEADLESS-N on every plain 'output create headless' and ignores the optional
# name argument in some versions).
ExecStartPre=-%h/.local/share/omarchy-moonlight/bin/sunshine-prestart.sh

View File

@@ -7,9 +7,9 @@ After=graphical-session.target
[Service] [Service]
Type=simple Type=simple
# Ensure the Hyprland headless output exists before Sunshine probes encoders. # Ensure exactly one Hyprland headless output exists and sunshine.conf points
# Non-fatal if Hyprland isn't reachable (e.g., service starts before login). # at its current name before Sunshine probes encoders. Non-fatal.
ExecStartPre=-/usr/bin/hyprctl output create headless ExecStartPre=-%h/.local/share/omarchy-moonlight/bin/sunshine-prestart.sh
ExecStart=/usr/bin/sunshine ExecStart=/usr/bin/sunshine
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s

View File

@@ -54,6 +54,9 @@ Environment overrides:
Set to 'sunshine' to build from source instead. Set to 'sunshine' to build from source instead.
OP_VAULT 1Password vault that holds the root CA (default: Private) OP_VAULT 1Password vault that holds the root CA (default: Private)
OP_CA_ITEM Item title in that vault (default: Omarchy-Stream Root CA) OP_CA_ITEM Item title in that vault (default: Omarchy-Stream Root CA)
HEADLESS_HOSTS Comma-separated hostnames that default to headless mode.
Unset by default; anything not listed defaults to mirror.
Override per-invocation with --headless or --mirror.
EOF EOF
} }
@@ -85,19 +88,27 @@ while [[ $# -gt 0 ]]; do
shift shift
done done
# Pick streaming mode: explicit flag wins; otherwise default to headless on # Pick streaming mode: explicit flag wins; otherwise hostnames listed in the
# JARVIS (the KVM-attached primary target) and mirror everywhere else. # HEADLESS_HOSTS env var (comma-separated, case-insensitive) default to headless
# mode, anything else defaults to mirror. Override per-invocation with
# --headless / --mirror.
#
# Example (in your shell rc or one-off):
# HEADLESS_HOSTS=mybox,otherbox ./install.sh
compute_stream_mode() { compute_stream_mode() {
if [[ -n "$MODE_OVERRIDE" ]]; then if [[ -n "$MODE_OVERRIDE" ]]; then
STREAM_MODE="$MODE_OVERRIDE" STREAM_MODE="$MODE_OVERRIDE"
return 0 return 0
fi fi
local host_lc="${HOSTNAME_SHORT,,}" local host_lc="${HOSTNAME_SHORT,,}"
if [[ "$host_lc" == "jarvis" ]]; then local IFS=','
for h in ${HEADLESS_HOSTS:-}; do
if [[ "$host_lc" == "${h,,}" ]]; then
STREAM_MODE="headless" STREAM_MODE="headless"
else return 0
STREAM_MODE="mirror"
fi fi
done
STREAM_MODE="mirror"
} }
main() { main() {

View File

@@ -8,7 +8,16 @@ SUNSHINE_TCP_PORTS=(47984 47989 47990 48010)
SUNSHINE_UDP_PORTS=(47998 47999 48000 48010) SUNSHINE_UDP_PORTS=(47998 47999 48000 48010)
_firewalld_active() { systemctl is-active --quiet firewalld 2>/dev/null; } _firewalld_active() { systemctl is-active --quiet firewalld 2>/dev/null; }
_ufw_active() { command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; }
# ufw is a Type=oneshot unit with RemainAfterExit=true. Its `ufw status`
# command requires root to read /etc/ufw state, so an unprivileged check
# returns nothing and silently misses an active firewall. Use systemd's
# unit state instead — it works without sudo.
_ufw_active() {
command -v ufw >/dev/null 2>&1 || return 1
systemctl is-active ufw.service >/dev/null 2>&1
}
_iptables_has_rules() { _iptables_has_rules() {
command -v iptables >/dev/null 2>&1 || return 1 command -v iptables >/dev/null 2>&1 || return 1
# Heuristic: more than the default 3 chains-with-no-rules output lines means rules exist. # Heuristic: more than the default 3 chains-with-no-rules output lines means rules exist.

View File

@@ -16,7 +16,8 @@ install_headless_hooks() {
mkdir -p "$HEADLESS_BIN_DIR" 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-do.sh" "$DO_SCRIPT"
install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-undo.sh" "$UNDO_SCRIPT" install -m 0755 "$SCRIPT_DIR/bin/sunshine-stream-undo.sh" "$UNDO_SCRIPT"
ok "Installed prep-cmd hooks to $HEADLESS_BIN_DIR" install -m 0755 "$SCRIPT_DIR/bin/sunshine-prestart.sh" "$HEADLESS_BIN_DIR/sunshine-prestart.sh"
ok "Installed prep-cmd + prestart hooks to $HEADLESS_BIN_DIR"
} }
# Install a systemd-user drop-in that pre-creates HEADLESS-1 before Sunshine # Install a systemd-user drop-in that pre-creates HEADLESS-1 before Sunshine