Turning on a WireGuard profile whose server was unreachable used to cost the whole network stack, and the only way out was nmcli typed into a terminal. Quickshell's Networking module has no VPN surface, so this arrives as the one sanctioned nmcli exception: a helper that lists, raises and lowers profiles, a service that watches NetworkManager for changes made anywhere, a quick settings tile (left-click toggles the most recently used profile, right-click picks among them), and a bar glyph while a tunnel is up. The safety property is in the helper, where it cannot be skipped: activation waits a bounded 25 seconds, and a failure is rolled back down and reported instead of leaving a black-hole default route. The contract pins exactly that, against a stateful stub NetworkManager. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
224 lines
11 KiB
Bash
Executable File
224 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Every asset Panama's desktop names must be something Panama installs.
|
|
#
|
|
# This is the sibling of declared-dependencies-contract, and it exists because
|
|
# that contract has a structural blind spot. It checks commands that scripts
|
|
# invoke -- so it reports PASS on a machine where the shell's icon font, the
|
|
# pointer theme, the wallpaper and four GUI applications are all missing, because
|
|
# none of those are a command in a script.
|
|
#
|
|
# That was not hypothetical. At the time this was written:
|
|
#
|
|
# * change-settings set monospace-font-name to a Nerd Font that no package list
|
|
# installed, and Fonts.qml documents that a mono family without Nerd glyphs
|
|
# replaces every icon in the shell with tofu.
|
|
# * oreo_blue_cursors was named in six files and installed by none.
|
|
# * Wallpaper.qml declared a `shippedPath` that the repository did not ship,
|
|
# and panama-lock fell back to the same non-existent file.
|
|
# * gnome-control-center backed fifteen rows of Panama's own Settings and was
|
|
# declared nowhere, as were gnome-calendar, podman and pipewire-utils.
|
|
#
|
|
# Every one of those presents as a feature that quietly is not there, which is
|
|
# the same failure mode -- and the same argument -- as the dependency contract.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null \
|
|
| sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
|
|
[[ -n "$declared" ]] || { printf 'declared assets contract: no package lists found\n' >&2; exit 1; }
|
|
|
|
is_declared() { grep -qix -- "$1" <<<"$declared"; }
|
|
|
|
# ── 1. Fonts ─────────────────────────────────────────────────────────────────
|
|
#
|
|
# A family name is not a package name. Nerd Font packages follow one rule --
|
|
# "VictorMono Nerd Font" is victormono-nerd-fonts -- and everything else needs
|
|
# naming, so an unmapped family is reported as a real omission rather than
|
|
# silently skipped.
|
|
|
|
font_package() {
|
|
local family="$1"
|
|
# Style and width suffixes are not part of the family that ships.
|
|
family="${family% Mono}"
|
|
family="${family% Propo}"
|
|
family="$(sed -E 's/ (Light|Regular|Medium|SemiBold|Bold|Thin|Black|Italic)$//' <<<"$family")"
|
|
|
|
if [[ "$family" == *" Nerd Font" ]]; then
|
|
printf '%s-nerd-fonts' "$(tr '[:upper:]' '[:lower:]' <<<"${family% Nerd Font}" | tr -d ' ')"
|
|
return
|
|
fi
|
|
case "$family" in
|
|
"Adwaita Sans"|"Adwaita Mono") printf 'adwaita-sans-fonts' ;;
|
|
Cantarell) printf 'abattis-cantarell-fonts' ;;
|
|
*) printf '?%s' "$family" ;;
|
|
esac
|
|
}
|
|
|
|
# Sources are named individually rather than crawled, because a font family is a
|
|
# bare string: crawling for one finds every English phrase in the repository.
|
|
fonts="$(
|
|
grep -hoE "font-name '[^']+'" "$repo_dir"/setup/scripts/* 2>/dev/null \
|
|
| sed -E "s/.*'(.*)'/\1/"
|
|
grep -hoE '^gtk-font-name=.*' "$repo_dir"/config/dot/gtk-""*/settings.ini.template 2>/dev/null \
|
|
| sed 's/^gtk-font-name=//'
|
|
grep -hoE '^font-family = ".*"' "$repo_dir"/config/dot/ghostty/config 2>/dev/null \
|
|
| sed -E 's/^font-family = "(.*)"/\1/'
|
|
grep -hoE '^\s*font_family = .*' "$repo_dir"/config/dot/hypr/hyprlock.conf.template 2>/dev/null \
|
|
| sed -E 's/^\s*font_family = //'
|
|
grep -hoE 'key: "(interfaceFont|iconFont|monospaceFont|documentFont|applicationFont)", type: "string", def: "[^"]+"' \
|
|
"$repo_dir"/config/dot/quickshell/config/PreferenceSchema.qml 2>/dev/null \
|
|
| sed -E 's/.*def: "([^"]+)".*/\1/'
|
|
)"
|
|
|
|
while IFS= read -r family; do
|
|
[[ -n "$family" ]] || continue
|
|
# Trailing point size, as gsettings and GTK both write it.
|
|
family="$(sed -E 's/ [0-9]+$//' <<<"$family")"
|
|
package="$(font_package "$family")"
|
|
if [[ "$package" == \?* ]]; then
|
|
note "font family '${package#?}' is named in the desktop but maps to no known package"
|
|
elif ! is_declared "$package"; then
|
|
note "font '$family' needs package '$package', which no package list declares"
|
|
fi
|
|
done < <(sort -u <<<"$fonts")
|
|
|
|
# ── 2. Cursor themes ─────────────────────────────────────────────────────────
|
|
#
|
|
# Satisfied either by a package or by a theme vendored into the repository --
|
|
# oreo_blue_cursors is packaged by no Fedora repository, so it ships here.
|
|
|
|
cursors="$(
|
|
grep -hoE "cursor-theme '[^']+'" "$repo_dir"/setup/scripts/* 2>/dev/null \
|
|
| sed -E "s/.*'(.*)'/\1/"
|
|
grep -hoE '^gtk-cursor-theme-name=.*' "$repo_dir"/config/dot/gtk-""*/settings.ini.template 2>/dev/null \
|
|
| sed 's/^gtk-cursor-theme-name=//'
|
|
grep -hoE '^export XCURSOR_THEME=.*' "$repo_dir"/config/dot/uwsm/env 2>/dev/null \
|
|
| sed 's/^export XCURSOR_THEME=//'
|
|
grep -hoE 'key: "cursorTheme", type: "string", def: "[^"]+"' \
|
|
"$repo_dir"/config/dot/quickshell/config/PreferenceSchema.qml 2>/dev/null \
|
|
| sed -E 's/.*def: "([^"]+)".*/\1/'
|
|
)"
|
|
|
|
while IFS= read -r theme; do
|
|
[[ -n "$theme" ]] || continue
|
|
[[ -d "$repo_dir/config/local/share/icons/$theme/cursors" ]] && continue
|
|
is_declared "${theme%%_*}-cursor-theme" && continue
|
|
is_declared "$theme" && continue
|
|
note "cursor theme '$theme' is named in the desktop but is neither vendored nor packaged"
|
|
done < <(sort -u <<<"$cursors")
|
|
|
|
# ── 3. The shipped wallpaper ─────────────────────────────────────────────────
|
|
#
|
|
# Wallpaper.qml calls one path `shippedPath` and panama-lock falls back to it.
|
|
# A fallback that does not exist is not a fallback.
|
|
|
|
while IFS= read -r image; do
|
|
[[ -n "$image" ]] || continue
|
|
[[ -f "$repo_dir/config/wallpapers/$image" ]] \
|
|
|| note "wallpaper '$image' is referenced as shipped, but config/wallpapers/$image does not exist"
|
|
done < <(grep -rhoE 'Pictures/Wallpapers/[A-Za-z0-9._-]+\.(jpg|jpeg|png|webp)' \
|
|
"$repo_dir"/config/dot/quickshell/services "$repo_dir"/config/dot/quickshell/scripts \
|
|
"$repo_dir"/config/dot/hypr 2>/dev/null | sed 's|.*/||' | sort -u)
|
|
|
|
# ── 3b. Shipping is not installing ───────────────────────────────────────────
|
|
#
|
|
# A vendored asset that link-dotfiles never puts into place is exactly as absent
|
|
# as one that was never vendored -- and it would satisfy the two checks above,
|
|
# which only prove the repository contains the file. Both directories must
|
|
# actually be consumed by the installer.
|
|
|
|
for vendored in config/local/share/icons config/wallpapers; do
|
|
[[ -e "$repo_dir/$vendored" ]] || continue
|
|
grep -qF "$vendored" "$repo_dir/setup/scripts/link-dotfiles" 2>/dev/null \
|
|
|| note "$vendored is shipped but link-dotfiles never installs it, so a fresh machine still does without"
|
|
done
|
|
|
|
# ── 4. Commands the shell launches ───────────────────────────────────────────
|
|
#
|
|
# The dependency contract reads scripts. These are launched from QML, so it
|
|
# never sees them -- which is how the application backing fifteen Settings rows
|
|
# went undeclared.
|
|
|
|
qml_package() {
|
|
case "$1" in
|
|
hyprctl) printf 'hyprland' ;;
|
|
nmcli) printf 'NetworkManager' ;;
|
|
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
|
xdg-open) printf 'xdg-utils' ;;
|
|
pw-dump|pw-play) printf 'pipewire-utils' ;;
|
|
*) printf '%s' "$1" ;;
|
|
esac
|
|
}
|
|
|
|
# Provided by the base system or the shell itself; nothing installs these
|
|
# separately, and listing them would be noise.
|
|
QML_BASELINE='^(sh|bash|rm|test|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
|
|
|
|
while IFS= read -r command_name; do
|
|
[[ -n "$command_name" ]] || continue
|
|
[[ "$command_name" =~ $QML_BASELINE ]] && continue
|
|
package="$(qml_package "$command_name")"
|
|
is_declared "$package" \
|
|
|| note "the shell launches '$command_name' from QML, but '$package' is undeclared"
|
|
done < <(grep -rhoE '(command: |execDetached\(|command = )\["[a-z][a-z0-9._-]*"' \
|
|
"$repo_dir"/config/dot/quickshell --include='*.qml' 2>/dev/null \
|
|
| sed -E 's/.*\["([^"]*)"/\1/' | sort -u)
|
|
|
|
# ── 5. Directories nothing puts into service ─────────────────────────────────
|
|
#
|
|
# A config directory nobody links is a directory nobody has. This catches the
|
|
# case where a new dot/ directory is added and link-dotfiles' `dirs` array is not
|
|
# updated, which fails silently -- the config is simply never in place.
|
|
#
|
|
# Linking is not the only way to be used: ohmyposh is loaded straight out of the
|
|
# repository by config/bash/shell, which needs no symlink at all. So the shell
|
|
# configuration counts as a consumer too, and only a directory nothing at all
|
|
# refers to is reported.
|
|
|
|
consumers=("$repo_dir/setup/scripts/link-dotfiles" "$repo_dir"/config/bash/*)
|
|
for dot_dir in "$repo_dir"/config/dot/*/; do
|
|
name="$(basename "$dot_dir")"
|
|
grep -qE "(^|[^A-Za-z0-9_-])$name([^A-Za-z0-9_-]|$)" "${consumers[@]}" 2>/dev/null \
|
|
|| note "config/dot/$name is referenced by neither link-dotfiles nor the shell configuration, so nothing puts it into service"
|
|
done
|
|
|
|
# ── 6. Names in the array with nothing behind them ───────────────────────────
|
|
#
|
|
# The inverse of the check above, and the one that bites when a directory is
|
|
# removed rather than added: an entry left in `dirs` makes link-dotfiles point
|
|
# ~/.config/<name> at a path that does not exist, and a dangling symlink is
|
|
# worse than no symlink, because everything that looks there finds something.
|
|
#
|
|
# Missed the first time this contract was written. Deleting config/dot/forge in
|
|
# the GNOME excision was caught by nothing until the array entry was removed too,
|
|
# which was luck rather than a test.
|
|
|
|
mapfile -t linked_dirs < <(sed -n '/^dirs=(/,/)/p' "$repo_dir/setup/scripts/link-dotfiles" \
|
|
| grep -oE '"[^"]+"' | tr -d '"')
|
|
|
|
(( ${#linked_dirs[@]} > 0 )) \
|
|
|| note "link-dotfiles' dirs array could not be read, so nothing checked what it links"
|
|
|
|
for name in "${linked_dirs[@]}"; do
|
|
[[ -d "$repo_dir/config/dot/$name" ]] \
|
|
|| note "link-dotfiles links $name, but config/dot/$name does not exist -- that is a dangling symlink in ~/.config"
|
|
done
|
|
|
|
# ── Report ───────────────────────────────────────────────────────────────────
|
|
|
|
# One asset named by three different files is one finding, not three.
|
|
if (( ${#findings[@]} > 0 )); then
|
|
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
|
printf 'declared assets contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'declared assets contract: PASS\n'
|