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

328 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```