Files
Omarchy-Stream/docs/ARCHITECTURE.md
Levi Woodard ab23107300 Add runtime status checker + headless/X11 docs; distro-support refinements
- 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>
2026-06-02 22:57:03 +00:00

17 KiB
Raw Permalink Blame History

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.

  1. require_* sanity guards: not root, on Arch, yay present.
  2. detect_all sets $HOSTNAME_SHORT, $GPU_VENDOR, $SESSION_TYPE for the rest of the run.
  3. compute_stream_mode picks mirror or headless:
    • --mirror or --headless wins
    • Otherwise: hostname matches an entry in $HEADLESS_HOSTS (case-insensitive, comma-separated) → headless; everything else → mirror.
  4. preflight_all surfaces problems early (Wayland session? GPU driver responsive? nvidia-drm.modeset not explicitly disabled? pipewire-pulse installed? hyprctl+jq+Hyprland reachable if headless?). Mostly warn-only — install proceeds even with warnings.
  5. install_sunshine + install_gpu_encoder_packages:
    • Skip if sunshine or sunshine-bin is already installed AND ldd reports no missing libs.
    • Otherwise yay -S $SUNSHINE_PKG (default sunshine-bin). ldd-check; if anything is "not found", remove sunshine-bin+-debug and rebuild from source.
  6. Permissions: add user to input group, drop the uinput udev rule if missing, setcap cap_sys_admin+p on the sunshine binary (KMS capture needs this).
  7. Headless hooks (only in headless mode): install sunshine-stream-do.sh, sunshine-stream-undo.sh, sunshine-prestart.sh into ~/.local/share/omarchy-moonlight/bin/.
  8. write_sunshine_config: generate ~/.config/sunshine/sunshine.conf with a # managed-by: marker. Per-vendor encoder block + capture method per stream mode. Mirror mode writes capture = kms only; headless mode writes capture = wlr, output_name = HEADLESS-1, and a global_prep_cmd JSON referencing the installed hooks.
  9. 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's credentials/, 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.
  10. 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.
  11. Service: resolve which unit was packaged (sunshine.service or app-dev.lizardbyte.app.Sunshine.service), install the headless prestart drop-in if mode is headless, loginctl enable-linger, enable + restart.
  12. verify_install runs 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 §1213 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.
  • setcap is a no-op if the cap is already set.
  • loginctl enable-linger is a no-op if linger is already on.
  • systemctl --user enable doesn't fail on an already-enabled unit.
  • hyprctl output create headless runs 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