Two new docs filling the gaps in the prior set: docs/ARCHITECTURE.md - Component map + runtime flow diagrams (install-time and per-stream). - Cert pipeline walk-through end-to-end (CA bootstrap, op:// references, per-host mint, idempotency conditions). - State directory inventory (where things write at runtime). - Idempotency contract — explicit rules every script in this repo follows. - Full file map of the repo. docs/FOLLOWUPS.md - Promoted the punch list out of the TROUBLESHOOTING.md trailing section. - Each item now has: symptom, current workaround, fix sketch (with the actual code change, not vague intent), and a complexity estimate. - Tracks: screensaver inhibit, busiest-workspace auto-switch (2-line patch), 1Password black-rectangle workarounds (untested), host.lan DNS (out-of-repo), 1P SSH-agent timeout, cert renewal timer, stale config keys, single-user assumption. README.md - New "Documentation" section between Clients and Diagnostics points at each of the three doc files plus client/README.md, with a one-line description for each so readers can navigate without spelunking.
15 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
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)
├── 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