- status.sh: runtime health check (service state, boot wiring, display backend auto-detect, encoder, ports, web UI, /dev/uinput, pairing) ending in a g2g verdict or concrete TODO list
- docs: TROUBLESHOOTING §12 (headless graphical-session.target boot trap) + §13 (X11/NVENC path & stale wlr drop-in env conflict); ARCHITECTURE capture-backend comparison; FOLLOWUPS P3 (installer X11/Ubuntu support); README diagnostics pointer
- lib/{config,packages,permissions,service}.sh, files/sway-headless.service: in-progress Debian/Ubuntu + headless support refinements
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
Architecture
How the pieces fit together at install time and at stream time. Pair this
with TROUBLESHOOTING.md (what went wrong + why) and the top-level README.md
(how to use it).
Component map
┌─────────────────────────────────────┐
│ install.sh │
└──┬──────────────────────────────────┘
│ sources & orchestrates
┌──────────┬──────────┬────┼──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
common.sh detect.sh preflight packages permissions config certs
(logging, (GPU, (env (yay, (input grp, (writes (1P CA →
sudo, hostname, sanity setcap, uinput sunshine. host cert,
yay) session) checks) encoder) udev) conf) trust store)
│
├─→ headless.sh
│ (hook +
│ drop-in install)
│
├─→ firewall.sh
│ (ufw / firewalld
│ port opening)
│
└─→ service.sh
(unit detection
+linger+enable)
At runtime the moving parts are:
Moonlight client ───TCP/UDP─→ sunshine (Linux process)
│
┌───────── service ─────┤ on connect / disconnect
▼ ▼
hypridle Hyprland global_prep_cmd
(sleeps) ◄───────── hooks (do/undo)
│
▼
HEADLESS-N output
(resized per client)
Install-time flow
install.sh is intentionally linear and idempotent. Each step is "check, then
act" — re-running is safe.
require_*sanity guards: not root, on Arch,yaypresent.detect_allsets$HOSTNAME_SHORT,$GPU_VENDOR,$SESSION_TYPEfor the rest of the run.compute_stream_modepicksmirrororheadless:--mirroror--headlesswins- Otherwise: hostname matches an entry in
$HEADLESS_HOSTS(case-insensitive, comma-separated) → headless; everything else → mirror.
preflight_allsurfaces problems early (Wayland session? GPU driver responsive?nvidia-drm.modesetnot explicitly disabled?pipewire-pulseinstalled?hyprctl+jq+Hyprland reachable if headless?). Mostly warn-only — install proceeds even with warnings.install_sunshine+install_gpu_encoder_packages:- Skip if
sunshineorsunshine-binis already installed ANDlddreports no missing libs. - Otherwise
yay -S $SUNSHINE_PKG(defaultsunshine-bin).ldd-check; if anything is "not found", removesunshine-bin+-debugand rebuild from source.
- Skip if
- Permissions: add user to
inputgroup, drop the uinput udev rule if missing,setcap cap_sys_admin+pon the sunshine binary (KMS capture needs this). - Headless hooks (only in headless mode): install
sunshine-stream-do.sh,sunshine-stream-undo.sh,sunshine-prestart.shinto~/.local/share/omarchy-moonlight/bin/. write_sunshine_config: generate~/.config/sunshine/sunshine.confwith a# managed-by:marker. Per-vendor encoder block + capture method per stream mode. Mirror mode writescapture = kmsonly; headless mode writescapture = wlr,output_name = HEADLESS-1, and aglobal_prep_cmdJSON referencing the installed hooks.- Certs (default on): pull CA from
op://Private/Omarchy-Stream Root CA/{cert,key}, mint a host cert with SANs (<host>.lan, LAN IP,localhost,127.0.0.1), install host cert + key into Sunshine'scredentials/, install CA into/etc/ca-certificates/trust-source/anchors/,update-ca-trust extract. Idempotent — skips re-mint if the existing cert is signed by the current CA and has all expected SANs and is >30d from expiry. - Firewall: detect ufw / firewalld via systemd unit state. Open Sunshine's TCP (47984/47989/47990/48010) and UDP (47998/47999/48000/48010) ports if either is active.
- Service: resolve which unit was packaged (
sunshine.serviceorapp-dev.lizardbyte.app.Sunshine.service), install the headless prestart drop-in if mode is headless,loginctl enable-linger, enable + restart. verify_installruns the same checks as--doctor: cap_sys_admin set, group resolves, web UI listening on:47990, encoder reachable, hook scripts present, CA in trust store.
Runtime flow — headless mode
Mirror mode is simpler: Sunshine just captures the real DRM output via KMS, hooks aren't involved. Headless mode is where the orchestration lives.
Service start
systemd-user starts sunshine.service
│
├─ ExecStartPre=/bin/sleep 5 (packaged by AUR sunshine, gives the
│ graphical session time to come up)
│
├─ ExecStartPre=-${HOME}/.local/share/omarchy-moonlight/bin/sunshine-prestart.sh
│ │ (non-fatal: '-' prefix)
│ │
│ ├─ recovers $HYPRLAND_INSTANCE_SIGNATURE from $XDG_RUNTIME_DIR/hypr/
│ │ if it wasn't propagated by systemd-user env
│ │
│ ├─ sort -V the existing HEADLESS-* outputs (oldest first)
│ │
│ ├─ if zero exist → 'hyprctl output create headless'
│ ├─ if more than one exists → remove all but the lowest-numbered
│ │ (Hyprland's HEADLESS-N counter is monotonic; without active
│ │ dedupe extras accumulate forever)
│ │
│ └─ rewrite sunshine.conf's `output_name = ...` line to match the
│ surviving output's actual name (sed-in-place, idempotent)
│
└─ ExecStart=/usr/bin/sunshine
│
├─ reads sunshine.conf (now has correct output_name)
├─ enumerates Wayland outputs (finds HEADLESS-N)
├─ probes encoders against that output (nvenc / vaapi / etc.)
└─ binds :47984/:47989/:47990/:48010, advertises via Avahi
Client connect
Moonlight client opens a stream
│
├─ TCP handshake to sunshine on :47989
│
├─ Sunshine spawns global_prep_cmd's `do` script:
│ env: SUNSHINE_CLIENT_WIDTH, _HEIGHT, _FPS, _HOST_AUDIO, _HDR
│ cwd: sunshine's working directory
│
│ sunshine-stream-do.sh
│ │
│ ├─ recover $HYPRLAND_INSTANCE_SIGNATURE (defensive)
│ ├─ snapshot prior workspace + monitor state to
│ │ $XDG_RUNTIME_DIR/sunshine-headless/{prev-monitors.json,
│ │ prev-workspace-id,
│ │ headless-name}
│ ├─ discover the existing HEADLESS-* (no hardcoded name)
│ │ if none → 'hyprctl output create headless' and wait for it
│ ├─ 'hyprctl keyword monitor <name>,${W}x${H}@${FPS},auto,1'
│ ├─ 'hyprctl dispatch moveworkspacetomonitor <prev_ws> <name>'
│ └─ 'hyprctl dispatch focusmonitor <name>'
│
├─ Sunshine binds encoder to the (now resized) output
│
└─ stream begins
Client disconnect
Moonlight tears down the stream
│
├─ Sunshine runs global_prep_cmd's `undo` script:
│ sunshine-stream-undo.sh
│ │
│ ├─ read headless-name from state dir; fall back to discovery
│ ├─ find a non-headless monitor (if any are connected)
│ │ └─ 'hyprctl dispatch moveworkspacetomonitor <prev_ws> <real>'
│ │ └─ 'hyprctl dispatch focusmonitor <real>'
│ └─ DO NOT remove HEADLESS-N — it stays so Sunshine's next
│ startup encoder probe still has a surface to bind to.
│ (Create-then-destroy per session raced with Sunshine's
│ startup probe and tripped fatal errors.)
│
└─ clean state files
Headless capture backends: wlr vs X11/NVENC
The installer's headless mode assumes wlr (capture = wlr) on Hyprland —
that's the runtime flow diagrammed above. There is a second headless backend,
used on hosts that don't run Hyprland (e.g. Ubuntu) or that want a guaranteed
NVIDIA GL context for NVENC: X11 capture of a headless Xorg.
| wlr path (installer default) | X11/NVENC path (manual) | |
|---|---|---|
| Compositor | Hyprland (or sway-headless on a server) | headless Xorg on :0 |
sunshine.conf |
capture = wlr, output_name = HEADLESS-N |
capture = x11, output_name = 0 |
| Display unit | the Hyprland/sway session | xorg-headless.service (user unit) |
| Per-client resize | yes — global_prep_cmd do/undo hooks |
no — Xorg has a fixed MetaModes resolution |
| How the display is faked | wlroots headless output | NVIDIA ConnectedMonitor + ModeValidation (TwinView) |
| Service env drop-in | inherits Wayland env | pins DISPLAY=:0, XDG_SESSION_TYPE=x11 |
The X11/NVENC path is the systemd-service form of the upstream "Remote SSH
Headless Setup" guide. It trades per-client resolution adaptation (the wlr
path's main feature) for a simpler, compositor-free capture that the NVIDIA
driver accelerates directly. Input injection is identical for both — input
group + 60-sunshine.rules on /dev/uinput.
Both backends share the same boot caveat: on a headless host the Sunshine unit
must be wired into default.target, not graphical-session.target, or it
never auto-starts. See TROUBLESHOOTING.md §12–13 for the drop-ins and the
alias-merge gotcha that lets a stale wlr drop-in poison the X11 environment.
Cert pipeline
A separate one-time bootstrap creates the CA in 1Password. Every host then mints itself from that CA.
One-time bootstrap (run on any single machine)
scripts/cert-bootstrap.sh
│
├─ require op CLI signed in
├─ openssl genrsa 4096 → CA private key (in tmpfs)
├─ openssl req -new -x509 -days 3650 → CA cert (signed by itself)
├─ 'op item create --category "Secure Note" --vault Private
│ --title "Omarchy-Stream Root CA"
│ cert[text]=<ca-cert.pem>
│ key[concealed]=<ca-key.pem>'
└─ print op://Private/Omarchy-Stream Root CA/{cert,key} references
Per-host (built into install.sh)
fetch_and_install_certs (lib/certs.sh)
│
├─ op_require_signin (op whoami)
├─ stage CA in tmpfs ($XDG_RUNTIME_DIR/omarchy-certs.XXXX)
│ │
│ ├─ 'op read op://Private/Omarchy-Stream Root CA/cert' → ca-cert.pem
│ └─ 'op read op://Private/Omarchy-Stream Root CA/key' → ca-key.pem
│
├─ install CA into /etc/ca-certificates/trust-source/anchors/
│ omarchy-stream-ca.pem; update-ca-trust extract
│
├─ idempotency: existing host cert signed by current CA AND has all
│ expected SANs AND >30d from expiry → skip mint, exit
│
└─ openssl x509 -req → host cert (RSA 2048, 365 days)
-extensions v3_req
SANs:
DNS = <hostname>.lan
DNS = localhost
IP = <LAN-IP>
IP = 127.0.0.1
EKU = serverAuth, clientAuth
│
├─ install -m 0644 → ~/.config/sunshine/credentials/cacert.pem
└─ install -m 0600 → ~/.config/sunshine/credentials/cakey.pem
After the cert is replaced, all previously-paired Moonlight clients see a fingerprint mismatch and must re-pair once. Subsequent installs preserve the cert as long as the CA + SAN set hasn't changed.
State directories
Everything the system writes lives in well-known paths so it can be inspected or cleaned without code archaeology.
| Path | Owner | Contents |
|---|---|---|
~/.config/sunshine/ |
sunshine | runtime config + state (user-managed) |
~/.config/sunshine/sunshine.conf |
this installer (when marker present) | tuned config; managed-by marker on line 1 |
~/.config/sunshine/credentials/ |
this installer (cert step) | cacert.pem (host cert) + cakey.pem (private key) |
~/.local/share/omarchy-moonlight/bin/ |
this installer | prep-cmd hook scripts in stable location |
~/.config/systemd/user/<unit>.service.d/ |
this installer | headless prestart drop-in |
$XDG_RUNTIME_DIR/sunshine-headless/ |
sunshine-stream-do.sh at runtime | per-stream state, cleared on reboot |
/etc/ca-certificates/trust-source/anchors/ |
this installer (cert step) | the omarchy-stream Root CA, picked up by update-ca-trust |
Idempotency contract
Every script in this repo is designed to be re-run safely. The patterns:
pkg_installed X || yay_install X— only install missing packages.- "Managed-by marker" on generated config files — only rewrite the file if we wrote it last time (marker present) OR no file exists. User edits that remove the marker are durable.
- Cert minting skips when the on-disk cert matches CA + SANs + expiry.
setcapis a no-op if the cap is already set.loginctl enable-lingeris a no-op if linger is already on.systemctl --user enabledoesn't fail on an already-enabled unit.hyprctl output create headlessruns only when no headless output exists (the prestart script counts first).- The prestart script removes extras to converge on exactly one — this is the only "destructive" idempotency action, and it preserves the lowest-numbered output (most likely to have workspaces bound to it).
A ./install.sh re-run with no changes to environment should report a long
list of "Already installed" / "already present" lines and end with a clean
verify.
File map
omarchy-moonlight/
├── install.sh Orchestrator
├── uninstall.sh Reverse install (preserves user data by default)
├── status.sh Runtime health check (what's running + g2g verdict)
├── README.md User-facing install + usage
├── scripts/
│ └── cert-bootstrap.sh One-time CA generation + 1P upload
├── bin/ Repo source for runtime scripts
│ ├── sunshine-prestart.sh ExecStartPre: dedupe headless + sync conf
│ ├── sunshine-stream-do.sh prep-cmd `do`: resize + workspace migrate
│ └── sunshine-stream-undo.sh prep-cmd `undo`: workspace return
├── lib/ Installer library (sourced by install.sh)
│ ├── common.sh Logging, sudo helpers, idempotency primitives
│ ├── detect.sh GPU vendor, session type, hostname
│ ├── preflight.sh Pre-install sanity checks
│ ├── packages.sh yay -S + auto-fall-back from bin to source
│ ├── permissions.sh input group, uinput udev, cap_sys_admin
│ ├── config.sh Generates sunshine.conf
│ ├── certs.sh 1P-backed CA, host cert minting, trust install
│ ├── headless.sh Installs hook scripts + prestart drop-in
│ ├── firewall.sh ufw / firewalld port opening
│ ├── service.sh Unit detection, fallback, linger, enable
│ └── verify.sh Post-install checks (also reused by --doctor)
├── files/ Static files installed by lib functions
│ ├── headless-prestart.conf systemd drop-in template
│ └── sunshine.service Fallback unit if no AUR variant ships one
├── client/ Moonlight client side
│ ├── install-macos.sh brew install + add CA to System keychain
│ └── README.md Per-platform install + first-pair flow
└── docs/
├── ARCHITECTURE.md this file
├── TROUBLESHOOTING.md failure modes hit during install, with fixes
└── FOLLOWUPS.md outstanding work / known limitations