# 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 (`.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 ,${W}x${H}@${FPS},auto,1' │ ├─ 'hyprctl dispatch moveworkspacetomonitor ' │ └─ 'hyprctl dispatch focusmonitor ' │ ├─ 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 ' │ │ └─ 'hyprctl dispatch focusmonitor ' │ └─ 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 ``` --- ## 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]= │ key[concealed]=' └─ 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 = .lan DNS = localhost 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/.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) ├── 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 ```