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>
This commit is contained in:
11
README.md
11
README.md
@@ -200,13 +200,22 @@ The host-side installer handles Linux clients via `moonlight-qt`. For everything
|
||||
## Diagnostics
|
||||
|
||||
```bash
|
||||
./install.sh --doctor # run all checks
|
||||
./status.sh # runtime health check — is it g2g right now?
|
||||
./install.sh --doctor # install-time correctness checks
|
||||
systemctl --user status sunshine
|
||||
journalctl --user -u sunshine -f
|
||||
getcap "$(readlink -f "$(command -v sunshine)")" # should include cap_sys_admin
|
||||
id -nG | tr ' ' '\n' | grep -x input # confirm group membership
|
||||
```
|
||||
|
||||
`status.sh` walks what's actually *running* — service state, boot wiring,
|
||||
display backend (auto-detects x11 / wlr / kms from your `sunshine.conf`),
|
||||
encoder, ports, web UI, `/dev/uinput`, and pairing — then prints either
|
||||
**g2g** or a concrete TODO list of what needs work (exit 1 if anything's
|
||||
broken). Run it as the Sunshine user, or as root (`sudo ./status.sh`, which
|
||||
auto-detects the user). `--doctor` is the install-time complement: it checks
|
||||
the install is *correct*; `status.sh` checks the host is *up*.
|
||||
|
||||
Useful Sunshine ports (auto-opened if a firewall is active):
|
||||
|
||||
- TCP: `47984 47989 47990 48010`
|
||||
|
||||
@@ -162,6 +162,35 @@ Moonlight tears down the stream
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -265,6 +294,7 @@ verify.
|
||||
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
|
||||
|
||||
@@ -220,6 +220,38 @@ that prefix. Substantial work; not justified without a real second user.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Installer support for the X11/NVENC (non-Hyprland) headless path
|
||||
|
||||
**Symptom**: `install.sh` only knows the Arch + Hyprland + wlr world. At least
|
||||
one real deployment is an **Ubuntu host running the X11/NVENC path** (headless
|
||||
Xorg on `:0`, `capture = x11`, NVIDIA TwinView virtual display) — set up
|
||||
entirely by hand. None of it is reproducible from the repo: not the
|
||||
`xorg-headless.service` unit, not the X11 `sunshine.conf`, not the
|
||||
`DISPLAY=:0` service drop-in, not the `default.target` boot wiring (see
|
||||
TROUBLESHOOTING.md §12–13).
|
||||
|
||||
**Current workaround**: configure those hosts manually using the recipes now
|
||||
documented in ARCHITECTURE.md ("Headless capture backends") and
|
||||
TROUBLESHOOTING.md §13.
|
||||
|
||||
**Fix sketch**:
|
||||
|
||||
- A `--backend x11|wlr` flag (or auto-detect: Hyprland reachable → wlr,
|
||||
NVIDIA + no Wayland compositor → x11).
|
||||
- `lib/config.sh`: emit the X11 conf variant when backend is x11.
|
||||
- Ship an `xorg-headless.service` + `xorg-headless.conf` template (the
|
||||
`ConnectedMonitor`/`ModeValidation` block is GPU-output-specific — needs
|
||||
`xrandr` detection or a prompt).
|
||||
- `lib/service.sh`: install the `default.target` boot drop-in for headless
|
||||
hosts regardless of backend, and the `DISPLAY=:0` env drop-in for x11.
|
||||
- Debian/Ubuntu package install path (`apt` + the `.deb`), since `yay`/AUR
|
||||
don't exist there. This is a larger lift than the flag itself.
|
||||
|
||||
**Complexity**: medium-high. The capture-backend split is moderate; full
|
||||
Debian packaging support is the bulk of the work.
|
||||
|
||||
---
|
||||
|
||||
## Not on the list (intentionally)
|
||||
|
||||
- **TLS for the stream itself.** Sunshine and Moonlight handle this with
|
||||
|
||||
@@ -364,6 +364,120 @@ IP.2 = 127.0.0.1
|
||||
And the idempotency check in `fetch_and_install_certs` requires those SANs
|
||||
— existing hosts re-mint on next install.
|
||||
|
||||
### 12. Headless host: service never auto-starts after boot (`graphical-session.target` never activates)
|
||||
|
||||
**Symptom**
|
||||
|
||||
Sunshine works right after install, and works again if you `systemctl --user
|
||||
start` it by hand, but never comes up on its own after a reboot. `systemctl
|
||||
--user status` shows the unit `enabled` yet `inactive (dead)` — *not* failed,
|
||||
just never started. Ports 47984/47989/47990 aren't listening and nothing is
|
||||
in the journal because the unit was never invoked.
|
||||
|
||||
**Cause**
|
||||
|
||||
The packaged unit (`sunshine.service`, or `app-dev.lizardbyte.app.Sunshine.service`)
|
||||
is wired `WantedBy=graphical-session.target` and `After=graphical-session.target`.
|
||||
On a desktop, logging in activates `graphical-session.target`, which pulls
|
||||
Sunshine up. A **headless host has no graphical login session**, so
|
||||
`graphical-session.target` never activates — even with `loginctl enable-linger`
|
||||
on and a headless display server (Xorg/sway) already running. The installer's
|
||||
one-time `systemctl --user start` is why it appears to work at install time;
|
||||
the wiring just never fires again at boot.
|
||||
|
||||
This bites any headless deployment (KVM box, server with a dummy/virtual
|
||||
display, the X11/NVENC path in §13). It does *not* bite a laptop/desktop that
|
||||
actually logs into Hyprland.
|
||||
|
||||
**Fix**
|
||||
|
||||
Add a drop-in that wires the unit into a target the lingering user-manager
|
||||
actually reaches (`default.target`) and orders it after whatever provides the
|
||||
display:
|
||||
|
||||
```bash
|
||||
# adjust the unit name to whichever one is installed (see issue #3)
|
||||
UNIT=app-dev.lizardbyte.app.Sunshine.service
|
||||
mkdir -p ~/.config/systemd/user/$UNIT.d
|
||||
cat > ~/.config/systemd/user/$UNIT.d/headless-boot.conf <<'EOF'
|
||||
[Unit]
|
||||
# Order after the headless display unit (xorg-headless.service for the X11
|
||||
# path, sway-headless.service for the wlr path).
|
||||
Requires=xorg-headless.service
|
||||
After=xorg-headless.service
|
||||
|
||||
[Install]
|
||||
# graphical-session.target never activates on a headless host; default.target
|
||||
# is reached by the lingering user manager, so this is what makes boot work.
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user reenable $UNIT # recreates default.target.wants symlink
|
||||
systemctl --user restart $UNIT
|
||||
```
|
||||
|
||||
Confirm the load-bearing symlink exists:
|
||||
`ls ~/.config/systemd/user/default.target.wants/ | grep -i sunshine`. The real
|
||||
proof is a reboot — `enabled` alone isn't enough on a headless box.
|
||||
|
||||
> Note: dependency keys (`Requires=`, `After=`) belong in the `[Unit]` section.
|
||||
> A drop-in that puts them in `[Service]` is silently ignored — systemd logs
|
||||
> `Unknown key name 'Requires' in section 'Service', ignoring` in
|
||||
> `systemctl status`, and the ordering never takes effect.
|
||||
|
||||
### 13. X11/NVENC headless path (Ubuntu / non-Hyprland hosts)
|
||||
|
||||
**Symptom / context**
|
||||
|
||||
This repo's headless mode assumes Hyprland + `capture = wlr`. On a host that
|
||||
isn't running Hyprland (e.g. an Ubuntu box, or one where you want NVIDIA NVENC
|
||||
with a guaranteed GL context), the wlr path has nothing to capture and every
|
||||
encoder probe fails at startup (same red banner as issue #4).
|
||||
|
||||
**The X11/NVENC alternative (as deployed on at least one host)**
|
||||
|
||||
Instead of a wlroots compositor, run a **headless Xorg on `:0`** and have
|
||||
Sunshine grab the X root window. This is the systemd-service equivalent of the
|
||||
upstream "Remote SSH Headless Setup" guide's TwinView trick — NVIDIA Xorg is
|
||||
told a monitor is connected so it produces a hardware-accelerated virtual
|
||||
display.
|
||||
|
||||
- A `xorg-headless.service` (user unit) runs
|
||||
`Xorg :0 -config xorg-headless.conf …` with an NVIDIA `ConnectedMonitor` +
|
||||
`ModeValidation` block (see the upstream guide). It's the headless display
|
||||
the Sunshine unit orders against in issue #12.
|
||||
- `~/.config/sunshine/sunshine.conf` uses `capture = x11`, `output_name = 0`,
|
||||
`encoder = nvenc`. (Hand-edited — delete the `# managed-by:` marker line so
|
||||
the Arch installer leaves it alone.)
|
||||
- A service drop-in pins the X11 environment so Sunshine doesn't auto-probe
|
||||
Wayland:
|
||||
```ini
|
||||
[Service]
|
||||
Environment=
|
||||
Environment=DISPLAY=:0
|
||||
Environment=XDG_SESSION_TYPE=x11
|
||||
UnsetEnvironment=WAYLAND_DISPLAY XDG_SESSION_TYPE
|
||||
```
|
||||
- Input injection works the normal way — user in the `input` group +
|
||||
`60-sunshine.rules` udev rule on `/dev/uinput`. The upstream guide's
|
||||
`chown`-via-passwordless-sudo `/dev/uinput` workaround is **not needed**;
|
||||
it only applies when you SSH in without group membership.
|
||||
|
||||
**Gotcha: leftover wlr drop-ins poison the X11 env.** If a host was first set
|
||||
up for the wlr path and later migrated to X11, a stale
|
||||
`sunshine.service.d/sway-headless.conf` drop-in can linger. Because
|
||||
`sunshine.service` is an *alias* of `app-dev.lizardbyte.app.Sunshine.service`,
|
||||
systemd merges drop-ins from **both** name directories, so that leftover keeps
|
||||
injecting `XDG_SESSION_TYPE=wayland`, `WAYLAND_DISPLAY=wayland-1`, and a hard
|
||||
`Requires=sway-headless.service` (a dead unit). It "works" only because
|
||||
`capture = x11` is explicit in the conf, but at boot it tries to pull in the
|
||||
dead sway unit. Delete the stale drop-in and verify the effective env:
|
||||
|
||||
```bash
|
||||
systemctl --user show $UNIT -p Environment -p Requires -p After
|
||||
# should show DISPLAY=:0 + XDG_SESSION_TYPE=x11, xorg-headless.service, no wayland/sway
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom keybinding to escape Moonlight (Mac)
|
||||
|
||||
@@ -10,11 +10,21 @@ Type=simple
|
||||
|
||||
# Tell wlroots to use the headless backend (no DRM master needed) and skip
|
||||
# libinput device probing — there are no input devices on a real headless box.
|
||||
# The Vulkan renderer is preferred on NVIDIA + handles NVENC capture cleanly;
|
||||
# wlroots falls back to GLES2 automatically if Vulkan isn't usable.
|
||||
#
|
||||
# GLES2 renderer: the Vulkan backend strictly requires
|
||||
# VK_EXT_external_memory_dma_buf, which the proprietary NVIDIA driver
|
||||
# doesn't expose on every build (esp. data-center / -server branches). GLES2
|
||||
# is universally available and Sunshine's capture path uses its own dma-buf
|
||||
# flow, so we don't lose hardware acceleration.
|
||||
Environment=WLR_BACKENDS=headless
|
||||
Environment=WLR_LIBINPUT_NO_DEVICES=1
|
||||
Environment=WLR_RENDERER=vulkan
|
||||
# Pixman (software) renderer. The GLES2 path needs a GBM-allocated framebuffer,
|
||||
# which fails on the proprietary NVIDIA driver: its GBM bridge doesn't permit
|
||||
# unprivileged CREATE_DUMB even on render nodes (Mesa drivers do). The trade-
|
||||
# off: Sunshine's wlr-screencopy then takes the shm path and uses libx264
|
||||
# software encoding. For NVENC on NVIDIA cloud GPUs, sunshine should be
|
||||
# pointed at a virtual KMS connector (vkms or Xorg+Dummy) instead of wlroots.
|
||||
Environment=WLR_RENDERER=pixman
|
||||
Environment=XDG_SESSION_TYPE=wayland
|
||||
|
||||
ExecStart=/usr/bin/sway --config %h/.config/sway/config-headless --unsupported-gpu
|
||||
|
||||
@@ -70,10 +70,10 @@ $encoder_block
|
||||
min_threads = 4
|
||||
|
||||
# Audio sink is intentionally left unset so Sunshine auto-detects the default
|
||||
# PulseAudio/PipeWire sink and creates its virtual `sink-sunshine-stereo`.
|
||||
# PulseAudio/PipeWire sink and creates its virtual 'sink-sunshine-stereo'.
|
||||
# Hard-coding audio_sink = pulse here breaks capture: Sunshine treats it as a
|
||||
# literal sink name, can't resolve its monitor source, and pa_simple_new()
|
||||
# fails with "Invalid argument" → no audio in the stream.
|
||||
# fails with "Invalid argument" -> no audio in the stream.
|
||||
|
||||
# Keyboard / mouse / gamepad pass-through via /dev/uinput.
|
||||
# (Requires user to be in the 'input' group; install.sh handles this.)
|
||||
|
||||
@@ -108,11 +108,11 @@ _ubuntu_sunshine_deb_url() {
|
||||
}
|
||||
|
||||
_install_sunshine_debian() {
|
||||
# Universal runtime deps. libva-utils gives `vainfo`; jq is used by hooks.
|
||||
# Universal runtime deps. vainfo is what Ubuntu calls libva-utils on Arch.
|
||||
# pipewire-pulse is the Ubuntu 24.04+ default audio path; on older releases
|
||||
# `pulseaudio-utils` works too — we don't force the codename split since
|
||||
# sunshine just needs *a* PulseAudio API endpoint.
|
||||
pkg_install jq vulkan-tools libva-utils curl ca-certificates
|
||||
pkg_install jq vulkan-tools vainfo curl ca-certificates
|
||||
|
||||
if pkg_installed sunshine; then
|
||||
ok "sunshine already installed (dpkg)"
|
||||
@@ -198,15 +198,36 @@ install_gpu_encoder_packages() {
|
||||
yay_install intel-media-driver vulkan-intel
|
||||
;;
|
||||
debian:nvidia)
|
||||
# On Ubuntu, the proprietary driver is usually already installed via
|
||||
# `ubuntu-drivers autoinstall` or the Server install path. Don't force a
|
||||
# specific nvidia-* version — they vary by release / driver branch.
|
||||
# Pull only the userspace VAAPI bridge if available; harmless if missing.
|
||||
pkg_install libnvidia-encode-no-dkms 2>/dev/null \
|
||||
|| pkg_install libnvidia-encode-575 2>/dev/null \
|
||||
|| pkg_install libnvidia-encode-565 2>/dev/null \
|
||||
|| pkg_install libnvidia-encode-560 2>/dev/null \
|
||||
|| info "NVENC userspace library not found via a known package name — relying on the existing driver install."
|
||||
# Ubuntu's nvidia-driver-NNN(-server) metapackage pulls in the matching
|
||||
# libnvidia-encode-NNN(-server) as a dependency, so NVENC is normally
|
||||
# already present. Only intervene if it's missing — and derive the right
|
||||
# package name from the loaded driver's major version instead of
|
||||
# guessing.
|
||||
if dpkg-query -W -f='${Status}\n' 'libnvidia-encode-*' 2>/dev/null \
|
||||
| grep -q '^install ok installed$'; then
|
||||
ok "NVENC runtime library already installed via the driver metapackage"
|
||||
else
|
||||
local drv_major drv_full
|
||||
drv_full="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader,nounits 2>/dev/null | head -n1)"
|
||||
drv_major="${drv_full%%.*}"
|
||||
if [[ -z "$drv_major" ]]; then
|
||||
warn "Could not detect NVIDIA driver version; NVENC may be missing."
|
||||
else
|
||||
# Try -server first (cloud GPUs usually run server branches), then the
|
||||
# consumer branch. Stop at the first one apt knows about.
|
||||
local picked=""
|
||||
for cand in "libnvidia-encode-${drv_major}-server" "libnvidia-encode-${drv_major}"; do
|
||||
if apt-cache show "$cand" >/dev/null 2>&1; then
|
||||
picked="$cand"; break
|
||||
fi
|
||||
done
|
||||
if [[ -n "$picked" ]]; then
|
||||
pkg_install "$picked"
|
||||
else
|
||||
warn "No libnvidia-encode-${drv_major}* package in apt — install it manually if NVENC fails."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
debian:amd)
|
||||
pkg_install mesa-va-drivers mesa-vulkan-drivers vainfo
|
||||
|
||||
@@ -8,13 +8,33 @@ UINPUT_RULE_PATH="/etc/udev/rules.d/60-uinput.rules"
|
||||
UINPUT_RULE_CONTENT='KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", OWNER="root", GROUP="input", MODE="0660"'
|
||||
|
||||
ensure_input_group() {
|
||||
if id -nG "$USER" | tr ' ' '\n' | grep -qx input; then
|
||||
ok "User '$USER' already in 'input' group"
|
||||
_ensure_user_in_group input
|
||||
# On Debian/Ubuntu, /dev/dri/renderD* nodes are mode 0660 owned by
|
||||
# root:render, and /dev/dri/card* are root:video. Sway's wlroots renderer
|
||||
# needs the render node (Vulkan/GLES FD); KMS capture needs the card node.
|
||||
# Arch typically grants both via udev tag=uaccess for the logged-in seat,
|
||||
# so we only add explicit memberships on Debian.
|
||||
if [[ "${DISTRO:-}" == "debian" ]]; then
|
||||
_ensure_user_in_group render
|
||||
_ensure_user_in_group video
|
||||
fi
|
||||
}
|
||||
|
||||
# Internal: add $USER to a group if it exists and they're not already in it.
|
||||
_ensure_user_in_group() {
|
||||
local g="$1"
|
||||
if ! getent group "$g" >/dev/null 2>&1; then
|
||||
info "Group '$g' does not exist on this system — skipping."
|
||||
return 0
|
||||
fi
|
||||
info "Adding '$USER' to 'input' group"
|
||||
as_root usermod -aG input "$USER"
|
||||
warn "You must log out and back in (or run 'newgrp input') for this to take effect."
|
||||
if id -nG "$USER" | tr ' ' '\n' | grep -qx "$g"; then
|
||||
ok "User '$USER' already in '$g' group"
|
||||
return 0
|
||||
fi
|
||||
info "Adding '$USER' to '$g' group"
|
||||
as_root usermod -aG "$g" "$USER"
|
||||
warn "Group '$g' change takes effect on next login (or 'newgrp $g'). For systemd-user"
|
||||
warn "services, you must fully log out and back in so the user manager restarts."
|
||||
}
|
||||
|
||||
ensure_uinput_udev_rule() {
|
||||
|
||||
@@ -4,23 +4,46 @@
|
||||
# Sway-headless capture path, also installs + enables sway-headless.service
|
||||
# and wires sunshine.service to depend on it.
|
||||
|
||||
# Resolves the actual unit file to operate on. Exports:
|
||||
# SUNSHINE_ENABLE_NAME - the name to pass to `systemctl --user enable`. May
|
||||
# be sunshine.service or app-dev.lizardbyte....service
|
||||
# depending on how the package shipped the unit.
|
||||
# SUNSHINE_SERVICE - the short name we use everywhere else (start, restart,
|
||||
# status, drop-ins). Always sunshine.service — systemd
|
||||
# resolves it via Alias= or our installed unit.
|
||||
ensure_sunshine_unit_present() {
|
||||
# Case 1: a sunshine.service unit already exists in any path systemd-user
|
||||
# scans. The LizardByte .deb on Ubuntu drops it at /lib/systemd/user/.
|
||||
# sunshine-bin on Arch drops it at /usr/lib/systemd/user/.
|
||||
SUNSHINE_SERVICE="sunshine.service"
|
||||
|
||||
# Clean up a broken symlink from older runs that pointed sunshine.service
|
||||
# at the FQDN unit. The upstream .deb already declares Alias=sunshine.service
|
||||
# in [Install], so the symlink we used to create conflicts with systemd's
|
||||
# enable path ("Refusing to operate on alias name").
|
||||
local user_unit="$HOME/.config/systemd/user/sunshine.service"
|
||||
if [[ -L "$user_unit" ]] && readlink "$user_unit" 2>/dev/null \
|
||||
| grep -q 'app-dev.lizardbyte.app.Sunshine.service$'; then
|
||||
info "Removing stale alias symlink at $user_unit (upstream unit declares Alias=)"
|
||||
rm -f "$user_unit"
|
||||
fi
|
||||
|
||||
# Case 1: a real sunshine.service unit ships in a system path (sunshine-bin
|
||||
# on Arch drops it at /usr/lib/systemd/user/).
|
||||
for p in \
|
||||
/lib/systemd/user/sunshine.service \
|
||||
/usr/lib/systemd/user/sunshine.service \
|
||||
/etc/systemd/user/sunshine.service \
|
||||
"$HOME/.config/systemd/user/sunshine.service" \
|
||||
"$HOME/.local/share/systemd/user/sunshine.service"
|
||||
do
|
||||
[[ -e "$p" ]] && return 0
|
||||
if [[ -f "$p" && ! -L "$p" ]]; then
|
||||
SUNSHINE_ENABLE_NAME="sunshine.service"
|
||||
export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Case 2: the AUR source 'sunshine' package ships the unit under a
|
||||
# Flatpak-style reverse-DNS name. Symlink it as sunshine.service so the rest
|
||||
# of our tooling can keep using the short name.
|
||||
# Case 2: the LizardByte .deb (Ubuntu) and the AUR source package ship the
|
||||
# unit under a reverse-DNS FQDN with Alias=sunshine.service in [Install].
|
||||
# Do NOT symlink — `systemctl --user enable` on the FQDN name creates the
|
||||
# alias symlink itself in ~/.config/systemd/user/.
|
||||
local fqdn_unit=""
|
||||
for p in \
|
||||
/usr/lib/systemd/user/app-dev.lizardbyte.app.Sunshine.service \
|
||||
@@ -30,10 +53,9 @@ ensure_sunshine_unit_present() {
|
||||
[[ -f "$p" ]] && { fqdn_unit="$p"; break; }
|
||||
done
|
||||
if [[ -n "$fqdn_unit" ]]; then
|
||||
info "Found packaged unit at $fqdn_unit"
|
||||
info "Aliasing it as sunshine.service in $HOME/.config/systemd/user/"
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
ln -sf "$fqdn_unit" "$HOME/.config/systemd/user/sunshine.service"
|
||||
info "Found packaged unit at $fqdn_unit (enables via alias)"
|
||||
SUNSHINE_ENABLE_NAME="app-dev.lizardbyte.app.Sunshine.service"
|
||||
export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -47,6 +69,8 @@ ensure_sunshine_unit_present() {
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
install -m 0644 "$fallback" "$HOME/.config/systemd/user/sunshine.service"
|
||||
ok "Installed $HOME/.config/systemd/user/sunshine.service"
|
||||
SUNSHINE_ENABLE_NAME="sunshine.service"
|
||||
export SUNSHINE_ENABLE_NAME SUNSHINE_SERVICE
|
||||
}
|
||||
|
||||
# Install and enable the headless sway compositor unit + config (Debian/Ubuntu
|
||||
@@ -114,8 +138,8 @@ enable_sunshine_service() {
|
||||
install_headless_prestart_dropin
|
||||
fi
|
||||
|
||||
if ! systemctl --user list-unit-files sunshine.service >/dev/null 2>&1; then
|
||||
err "sunshine.service still not found after fallback. Inspect: find /usr /lib ~/.config -name sunshine.service"
|
||||
if ! systemctl --user list-unit-files "$SUNSHINE_ENABLE_NAME" >/dev/null 2>&1; then
|
||||
err "$SUNSHINE_ENABLE_NAME not found after fallback. Inspect: find /usr /lib ~/.config -name '*[Ss]unshine*'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -126,8 +150,8 @@ enable_sunshine_service() {
|
||||
ok "User lingering already enabled"
|
||||
fi
|
||||
|
||||
info "Enabling sunshine.service (user)"
|
||||
systemctl --user enable sunshine.service >/dev/null
|
||||
info "Enabling ${SUNSHINE_ENABLE_NAME} (user)"
|
||||
systemctl --user enable "$SUNSHINE_ENABLE_NAME" >/dev/null
|
||||
|
||||
# Clear any prior start-limit state from a failed run so this attempt isn't
|
||||
# immediately rejected with "Start request repeated too quickly."
|
||||
|
||||
325
status.sh
Executable file
325
status.sh
Executable file
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bash
|
||||
# status.sh — runtime health check for an omarchy-moonlight Sunshine host.
|
||||
#
|
||||
# Walks the live system (service, display backend, ports, encoder, input,
|
||||
# pairing) and prints PASS / WARN / FAIL for each, then a verdict: either
|
||||
# "good to go" or a concrete TODO list of what needs work.
|
||||
#
|
||||
# Runtime-focused on purpose — complements `install.sh --doctor` (which checks
|
||||
# install-time correctness). Safe to run repeatedly; reads only, changes nothing.
|
||||
#
|
||||
# Usage:
|
||||
# ./status.sh # check the invoking user's Sunshine
|
||||
# sudo ./status.sh # root: auto-detects the Sunshine user
|
||||
# SUNSHINE_USER=alice ./status.sh
|
||||
#
|
||||
# Exit code: 0 if no FAILs, 1 if any FAIL.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ---- presentation -----------------------------------------------------------
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
|
||||
else
|
||||
R=''; G=''; Y=''; B=''; D=''; N=''
|
||||
fi
|
||||
oks=0; warns=0; fails=0
|
||||
declare -a TODO=()
|
||||
pass(){ printf " ${G}✓${N} %s\n" "$1"; oks=$((oks+1)); }
|
||||
warn(){ printf " ${Y}!${N} %s\n" "$1"; warns=$((warns+1)); [[ -n ${2:-} ]] && TODO+=("${Y}warn${N} $2"); }
|
||||
fail(){ printf " ${R}✗${N} %s\n" "$1"; fails=$((fails+1)); [[ -n ${2:-} ]] && TODO+=("${R}FAIL${N} $2"); }
|
||||
note(){ printf " ${D}·${N} %s\n" "$1"; }
|
||||
section(){ printf "\n${B}▸ %s${N}\n" "$1"; }
|
||||
|
||||
# ---- resolve the Sunshine user / runtime context ----------------------------
|
||||
if [[ -n ${SUNSHINE_USER:-} ]]; then
|
||||
SUSER=$SUNSHINE_USER
|
||||
elif [[ $EUID -ne 0 ]]; then
|
||||
SUSER=$(id -un)
|
||||
else
|
||||
SUSER=$(ls -1 /var/lib/systemd/linger/ 2>/dev/null | head -1)
|
||||
if [[ -z $SUSER ]]; then
|
||||
for d in /home/*; do
|
||||
[[ -e "$d/.config/sunshine/sunshine.conf" ]] && { SUSER=$(basename "$d"); break; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
if [[ -z ${SUSER:-} ]] || ! id "$SUSER" >/dev/null 2>&1; then
|
||||
echo "Could not determine the Sunshine user. Set SUNSHINE_USER=<name> and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
UID_N=$(id -u "$SUSER")
|
||||
RUNTIME="/run/user/$UID_N"
|
||||
HOME_DIR=$(getent passwd "$SUSER" | cut -d: -f6)
|
||||
CONF_DIR="$HOME_DIR/.config/sunshine"
|
||||
CONF="$CONF_DIR/sunshine.conf"
|
||||
LOG="$CONF_DIR/sunshine.log"
|
||||
SYSD_USER="$HOME_DIR/.config/systemd/user"
|
||||
|
||||
# systemctl --user, transparently as the Sunshine user when run as root
|
||||
uctl(){
|
||||
if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then
|
||||
sudo -u "$SUSER" XDG_RUNTIME_DIR="$RUNTIME" systemctl --user "$@"
|
||||
else
|
||||
XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME}" systemctl --user "$@"
|
||||
fi
|
||||
}
|
||||
# read a bare value from sunshine.conf (last wins; '=' separated; trimmed)
|
||||
conf_val(){ grep -E "^[[:space:]]*$1[[:space:]]*=" "$CONF" 2>/dev/null | tail -1 | cut -d= -f2- | xargs; }
|
||||
|
||||
printf "${B}omarchy-moonlight status${N} — user=${SUSER} host=$(hostname)\n"
|
||||
|
||||
# ---- 1. binary --------------------------------------------------------------
|
||||
section "Sunshine binary"
|
||||
if command -v sunshine >/dev/null 2>&1; then
|
||||
ver=$(sunshine --version 2>/dev/null | grep -i 'version' | head -1 | sed 's/.*version:/version/I' | xargs)
|
||||
pass "sunshine present ($(command -v sunshine))${ver:+ — $ver}"
|
||||
else
|
||||
fail "sunshine binary not found" "Install Sunshine (./install.sh, or apt/yay per distro)."
|
||||
fi
|
||||
if command -v flatpak >/dev/null 2>&1 && flatpak list 2>/dev/null | grep -qi 'lizardbyte.app.Sunshine' \
|
||||
&& command -v sunshine >/dev/null 2>&1; then
|
||||
warn "both a native sunshine AND the Sunshine flatpak are installed (redundant, confusing app-id)" \
|
||||
"Remove whichever you don't use: 'flatpak uninstall dev.lizardbyte.app.Sunshine'."
|
||||
fi
|
||||
|
||||
# ---- 2. service unit + state ------------------------------------------------
|
||||
section "Service"
|
||||
UNIT=""
|
||||
for u in sunshine.service app-dev.lizardbyte.app.Sunshine.service; do
|
||||
if uctl cat "$u" >/dev/null 2>&1; then UNIT=$u; break; fi
|
||||
done
|
||||
if [[ -z $UNIT ]]; then
|
||||
fail "no sunshine user unit found (sunshine.service / app-dev.lizardbyte.app.Sunshine.service)" \
|
||||
"Install/enable a unit — see lib/service.sh or files/sunshine.service."
|
||||
else
|
||||
# Resolve the canonical unit name. 'sunshine.service' is often an alias of
|
||||
# app-dev.lizardbyte.app.Sunshine.service; the .wants/ symlinks use whichever
|
||||
# name is canonical, so check both.
|
||||
CANON=$(uctl show "$UNIT" -p Id --value 2>/dev/null); [[ -z $CANON ]] && CANON=$UNIT
|
||||
if [[ $CANON != "$UNIT" ]]; then note "unit: $UNIT → $CANON"; else note "unit: $UNIT"; fi
|
||||
if uctl is-active --quiet "$UNIT"; then
|
||||
pass "service is active (running)"
|
||||
else
|
||||
state=$(uctl is-active "$UNIT" 2>/dev/null)
|
||||
fail "service is $state, not running" \
|
||||
"Start it: systemctl --user start $UNIT ; inspect: journalctl --user -u $UNIT -n 50"
|
||||
fi
|
||||
|
||||
# boot wiring — the classic headless trap (TROUBLESHOOTING §12)
|
||||
enabled=$(uctl is-enabled "$UNIT" 2>/dev/null)
|
||||
gs=$(uctl is-active graphical-session.target 2>/dev/null)
|
||||
want_default=no; want_graphical=no
|
||||
for nm in "$UNIT" "$CANON"; do
|
||||
[[ -e "$SYSD_USER/default.target.wants/$nm" ]] && want_default=yes
|
||||
[[ -e "$SYSD_USER/graphical-session.target.wants/$nm" ]] && want_graphical=yes
|
||||
# system-level packaged wants count too
|
||||
[[ -e "/usr/lib/systemd/user/default.target.wants/$nm" ]] && want_default=yes
|
||||
done
|
||||
|
||||
if [[ $enabled != enabled && $enabled != alias && $enabled != static ]]; then
|
||||
fail "service not enabled (enabled=$enabled) — won't start on boot" \
|
||||
"systemctl --user enable $UNIT"
|
||||
elif [[ $want_default == yes ]]; then
|
||||
pass "wired into default.target — auto-starts on a headless/lingering host"
|
||||
elif [[ $want_graphical == yes && $gs == active ]]; then
|
||||
pass "wired into graphical-session.target (active) — desktop session keeps it up"
|
||||
elif [[ $want_graphical == yes && $gs != active ]]; then
|
||||
fail "only wired into graphical-session.target, which is INACTIVE on this headless host — service won't auto-start on boot" \
|
||||
"Add a drop-in with [Install] WantedBy=default.target, then 'systemctl --user reenable $UNIT'. See TROUBLESHOOTING.md §12."
|
||||
else
|
||||
warn "enabled but no target.wants symlink found — boot behavior unclear" \
|
||||
"Verify: ls $SYSD_USER/*.target.wants/ | grep -i sunshine"
|
||||
fi
|
||||
|
||||
# lingering
|
||||
linger=$(loginctl show-user "$SUSER" -p Linger --value 2>/dev/null)
|
||||
if [[ $linger == yes ]]; then
|
||||
pass "user lingering enabled (survives logout)"
|
||||
else
|
||||
warn "user lingering is off — user services stop at logout / won't run before login" \
|
||||
"sudo loginctl enable-linger $SUSER"
|
||||
fi
|
||||
|
||||
# misplaced drop-in keys (Requires/After in [Service]) — systemd warns about these
|
||||
if uctl status "$UNIT" 2>&1 | grep -q 'Unknown key name'; then
|
||||
warn "a drop-in has keys in the wrong section (systemd is ignoring them)" \
|
||||
"Run 'systemctl --user status $UNIT' — move Requires=/After= into [Unit]. See TROUBLESHOOTING.md §12."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 3. config + capture backend --------------------------------------------
|
||||
section "Config & capture backend"
|
||||
if [[ -s $CONF ]]; then
|
||||
pass "sunshine.conf present and non-empty"
|
||||
elif [[ -f $CONF ]]; then
|
||||
fail "sunshine.conf exists but is EMPTY — Sunshine will run with defaults (no encoder/capture tuning)" \
|
||||
"Regenerate it (./install.sh) or restore your hand-edited config."
|
||||
else
|
||||
fail "sunshine.conf missing ($CONF)" "Run ./install.sh to generate it."
|
||||
fi
|
||||
CAP=$(conf_val capture); ENC=$(conf_val encoder); OUT=$(conf_val output_name)
|
||||
note "capture=${CAP:-<unset>} encoder=${ENC:-<auto>} output_name=${OUT:-<unset>}"
|
||||
|
||||
# ---- 4. display backend (depends on capture) --------------------------------
|
||||
section "Display backend"
|
||||
case "${CAP:-}" in
|
||||
x11)
|
||||
if uctl is-active --quiet xorg-headless.service 2>/dev/null; then
|
||||
pass "xorg-headless.service active"
|
||||
elif pgrep -af 'Xorg.*:0' >/dev/null 2>&1; then
|
||||
warn "an Xorg :0 is running but not via xorg-headless.service" \
|
||||
"Fine if intentional; otherwise enable xorg-headless.service so it starts on boot."
|
||||
else
|
||||
fail "capture=x11 but no Xorg :0 / xorg-headless.service running — nothing to capture" \
|
||||
"Start the headless X server (systemctl --user start xorg-headless.service). See TROUBLESHOOTING.md §13."
|
||||
fi
|
||||
# is DISPLAY :0 actually answering?
|
||||
if command -v xset >/dev/null 2>&1; then
|
||||
if (if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then sudo -u "$SUSER" DISPLAY=:0 xset -q; else DISPLAY=:0 xset -q; fi) >/dev/null 2>&1; then
|
||||
pass "X display :0 reachable"
|
||||
else
|
||||
fail "DISPLAY=:0 not reachable (X server not answering)" "Check xorg-headless.service logs."
|
||||
fi
|
||||
fi
|
||||
# wlr env leaking into an x11 unit (stale sway drop-in — TROUBLESHOOTING §13)
|
||||
if [[ -n $UNIT ]]; then
|
||||
env_dump=$(uctl show "$UNIT" -p Environment 2>/dev/null)
|
||||
if grep -qiE 'WAYLAND_DISPLAY|XDG_SESSION_TYPE=wayland' <<<"$env_dump"; then
|
||||
warn "Wayland env is leaking into the x11 unit (likely a stale sway-headless.conf drop-in)" \
|
||||
"Remove the leftover wlr drop-in; confirm with 'systemctl --user show $UNIT -p Environment'. See TROUBLESHOOTING.md §13."
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
wlr)
|
||||
if pgrep -x Hyprland >/dev/null 2>&1 || pgrep -x sway >/dev/null 2>&1 \
|
||||
|| uctl is-active --quiet sway-headless.service 2>/dev/null; then
|
||||
pass "a wlroots compositor (Hyprland/sway) is running"
|
||||
else
|
||||
fail "capture=wlr but no Hyprland/sway compositor running — encoder probe will fail" \
|
||||
"Start the compositor (or sway-headless.service on a server). See TROUBLESHOOTING.md §4."
|
||||
fi
|
||||
if command -v hyprctl >/dev/null 2>&1; then
|
||||
mons=$( (if [[ $EUID -eq 0 && "$SUSER" != "$(id -un)" ]]; then sudo -u "$SUSER" XDG_RUNTIME_DIR="$RUNTIME" hyprctl monitors all -j; else hyprctl monitors all -j; fi) 2>/dev/null)
|
||||
if grep -q 'HEADLESS' <<<"$mons"; then
|
||||
pass "a HEADLESS output exists"
|
||||
else
|
||||
warn "no HEADLESS output present yet (created per-connection by the prep-cmd hook)" \
|
||||
"Normal between streams; sunshine-prestart.sh creates one before the encoder probe."
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
kms)
|
||||
if command -v ls >/dev/null && ls /sys/class/drm/card*/card*-*/status >/dev/null 2>&1 \
|
||||
&& grep -ql '^connected' /sys/class/drm/card*/card*-*/status 2>/dev/null; then
|
||||
pass "a connected DRM output is present (KMS capture has something to grab)"
|
||||
else
|
||||
warn "capture=kms but no connected display detected — needs a real monitor or dummy plug" \
|
||||
"Attach a display/dummy plug, or switch to a headless backend (--headless)."
|
||||
fi
|
||||
;;
|
||||
"")
|
||||
warn "no capture method set in sunshine.conf — Sunshine will auto-detect" \
|
||||
"Pin one explicitly (capture=x11|wlr|kms) for predictable headless behavior."
|
||||
;;
|
||||
*)
|
||||
note "capture=$CAP (unrecognized by this checker — skipping backend-specific checks)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---- 5. encoder -------------------------------------------------------------
|
||||
section "Encoder"
|
||||
case "${ENC:-}" in
|
||||
*nvenc*|"")
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
if nvidia-smi -L >/dev/null 2>&1; then pass "NVIDIA GPU reachable ($(nvidia-smi -L | head -1 | sed 's/(UUID.*//'))"
|
||||
else fail "nvidia-smi present but no GPU responding" "Check the NVIDIA driver / 'nvidia-smi'."; fi
|
||||
elif [[ "${ENC:-}" == *nvenc* ]]; then
|
||||
warn "encoder=nvenc but nvidia-smi not found" "Install nvidia-utils, or switch encoder to vaapi/software."
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [[ -f $LOG ]]; then
|
||||
recent=$(tail -n 4000 "$LOG" 2>/dev/null)
|
||||
if grep -q 'Unable to find display or encoder' <<<"$recent"; then
|
||||
fail "log shows 'Unable to find display or encoder during startup'" \
|
||||
"Display backend wasn't ready at probe time — see Display backend section above & TROUBLESHOOTING.md §4."
|
||||
elif grep -qE 'Found (H.264|HEVC|AV1) encoder' <<<"$recent"; then
|
||||
enc_found=$(grep -oE 'Found (H.264|HEVC|AV1) encoder: [a-z0-9_]+' <<<"$recent" | tail -3 | sed 's/Found //' | paste -sd', ')
|
||||
pass "encoders detected in recent log: ${enc_found:-yes}"
|
||||
else
|
||||
note "no recent encoder-probe lines in log (service may not have probed since last start)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 6. network: ports + web UI ---------------------------------------------
|
||||
section "Network"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
for p in 47984 47989 47990; do
|
||||
if ss -tln 2>/dev/null | grep -q ":$p "; then pass "TCP $p listening"
|
||||
else fail "TCP $p NOT listening" "Service likely down or failed to bind — check the Service section."; fi
|
||||
done
|
||||
else
|
||||
note "ss not available — skipping port checks"
|
||||
fi
|
||||
code=$(curl -sk -o /dev/null -m 5 -w '%{http_code}' https://localhost:47990 2>/dev/null)
|
||||
case "$code" in
|
||||
401|200) pass "web UI responding on :47990 (HTTP $code)";;
|
||||
000|"") fail "web UI not responding on :47990" "Service down, or not bound. Check Service section.";;
|
||||
*) warn "web UI returned HTTP $code on :47990" "Unexpected — inspect manually.";;
|
||||
esac
|
||||
|
||||
# ---- 7. input injection (/dev/uinput) ---------------------------------------
|
||||
section "Input (/dev/uinput)"
|
||||
if [[ -e /dev/uinput ]]; then
|
||||
if id -nG "$SUSER" 2>/dev/null | grep -qw input; then
|
||||
pass "$SUSER is in the 'input' group"
|
||||
else
|
||||
fail "$SUSER is NOT in the 'input' group — keyboard/mouse injection will fail" \
|
||||
"sudo usermod -aG input $SUSER (then re-login)"
|
||||
fi
|
||||
perms=$(stat -c '%U:%G %a' /dev/uinput 2>/dev/null)
|
||||
mode=$(stat -c '%a' /dev/uinput 2>/dev/null)
|
||||
grp_digit=${mode: -2:1} # group permission digit; write bit set in 2,3,6,7
|
||||
if [[ $grp_digit =~ [2367] ]]; then
|
||||
pass "/dev/uinput group-writable ($perms)"
|
||||
else
|
||||
warn "/dev/uinput not group-writable ($perms) — udev rule may not have applied" \
|
||||
"Ensure 60-sunshine.rules is installed and 'udevadm control --reload && udevadm trigger'."
|
||||
fi
|
||||
else
|
||||
fail "/dev/uinput does not exist — no virtual input devices" "Load the uinput module: 'sudo modprobe uinput'."
|
||||
fi
|
||||
|
||||
# ---- 8. certs + pairing -----------------------------------------------------
|
||||
section "Certificates & pairing"
|
||||
if [[ -f "$CONF_DIR/credentials/cacert.pem" ]]; then
|
||||
pass "host cert present (credentials/cacert.pem)"
|
||||
else
|
||||
warn "no host cert in credentials/ — clients see Sunshine's self-signed default" \
|
||||
"Run the cert step (./install.sh with op signed in) if you use the shared CA."
|
||||
fi
|
||||
STATE="$CONF_DIR/sunshine_state.json"
|
||||
if [[ -f $STATE ]]; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
n=$(jq -r '[.. | objects | select(has("uniqueid") or has("uuid")) | (.name // empty)] | length' "$STATE" 2>/dev/null)
|
||||
fi
|
||||
[[ -z ${n:-} || $n == 0 ]] && n=$(grep -oc '"uniqueid"' "$STATE" 2>/dev/null)
|
||||
if [[ -n ${n:-} && $n -gt 0 ]]; then note "$n paired client(s) on record"
|
||||
else note "no paired clients yet (pair from Moonlight, then enter the PIN)"; fi
|
||||
fi
|
||||
|
||||
# ---- verdict ----------------------------------------------------------------
|
||||
printf "\n${B}── verdict ──${N}\n"
|
||||
printf " ${G}%d passed${N} ${Y}%d warnings${N} ${R}%d failures${N}\n" "$oks" "$warns" "$fails"
|
||||
if (( fails == 0 && warns == 0 )); then
|
||||
printf "\n ${G}${B}g2g${N} — everything checks out. Stream away.\n"
|
||||
elif (( fails == 0 )); then
|
||||
printf "\n ${G}${B}good to go${N} (with %d non-blocking warning(s)):\n\n" "$warns"
|
||||
for t in "${TODO[@]}"; do printf " • %s\n" "$t"; done
|
||||
else
|
||||
printf "\n ${R}${B}NOT ready${N} — fix these:\n\n"
|
||||
for t in "${TODO[@]}"; do printf " • %s\n" "$t"; done
|
||||
fi
|
||||
echo
|
||||
exit $(( fails > 0 ? 1 : 0 ))
|
||||
Reference in New Issue
Block a user