diff --git a/config/dot/quickshell/modules/settings/HealthPage.qml b/config/dot/quickshell/modules/settings/HealthPage.qml index cfbf323..92a03f1 100644 --- a/config/dot/quickshell/modules/settings/HealthPage.qml +++ b/config/dot/quickshell/modules/settings/HealthPage.qml @@ -8,7 +8,7 @@ SettingsPage { objectName: "system-health-page" title: "System Health" - lede: "Panama checks the parts of your desktop it owns and explains what needs attention." + lede: "Checks the parts of the desktop this app owns, and explains what needs attention." property var pendingConfirmation: null property string instructionTarget: "" @@ -32,7 +32,7 @@ SettingsPage { }, { group: "panama-tools", - title: "Panama tools", + title: "Desktop tools", subtitle: "Tracked links, launcher commands, apps, and inhibitors." } ] @@ -204,7 +204,7 @@ SettingsPage { width: parent.width text: root.pendingConfirmation ? `${root.pendingConfirmation.action.label}?` - : "Restart Panama?" + : "Restart the shell?" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize @@ -234,7 +234,7 @@ SettingsPage { SettingsButton { id: repairButton - text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart Panama" + text: root.pendingConfirmation ? root.pendingConfirmation.action.label : "Restart the shell" activeFocusOnTab: true border.width: activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) @@ -297,7 +297,7 @@ SettingsPage { SettingsCard { visible: root.instructionTarget === "ddc-permissions" title: "External monitor brightness" - subtitle: "Panama can see DDC/CI support, but this session cannot access the monitor bus." + subtitle: "The monitor reports DDC/CI support, but this session cannot reach the monitor bus." Item { width: parent.width @@ -401,7 +401,7 @@ SettingsPage { anchors.right: gnomeSettingsButton.left anchors.rightMargin: 18 anchors.verticalCenter: parent.verticalCenter - text: "Use GNOME Settings for the parts of the system Panama does not manage." + text: "Use GNOME Settings for the parts of the system this app does not manage." color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall diff --git a/config/dot/quickshell/modules/settings/HealthSummary.qml b/config/dot/quickshell/modules/settings/HealthSummary.qml index d2d332d..511c8ea 100644 --- a/config/dot/quickshell/modules/settings/HealthSummary.qml +++ b/config/dot/quickshell/modules/settings/HealthSummary.qml @@ -13,7 +13,7 @@ SettingsCard { if (Health.diagnosticUnavailable) return "Health check unavailable"; if (Health.checks.length === 0) - return "Checking Panama desktop"; + return "Checking the desktop"; if (Health.status === "error") return "Action required"; if (Health.status === "warning") @@ -22,9 +22,9 @@ SettingsCard { } readonly property string heroDetail: { if (Health.diagnosticUnavailable) - return Health.lastError || "Panama could not complete the latest health check."; + return Health.lastError || "The latest health check could not be completed."; if (Health.checks.length === 0) - return "Panama is checking the desktop services, tools, and integrations it owns."; + return "Checking the desktop services, tools, and integrations this app owns."; if (Health.status === "error") return root.observationCount === 1 ? "One part of the desktop needs action." @@ -33,7 +33,7 @@ SettingsCard { return root.observationCount === 1 ? "Your desktop is working. One feature needs a decision." : `Your desktop is working. ${root.observationCount} features need a decision.`; - return "Panama-owned desktop services and tools are working normally."; + return "Desktop services and tools are working normally."; } readonly property color statusColor: { if (Health.diagnosticUnavailable || Health.status === "error") diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index e8bd2c6..20fabdb 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -301,14 +301,14 @@ Rectangle { function footerText(): string { if (Health.checks.length === 0) - return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking Panama desktop"; + return Health.diagnosticUnavailable ? "Health check unavailable" : "Checking the desktop"; if (Health.status === "error") - return "Panama requires attention"; + return "Desktop needs attention"; if (Health.status === "warning") { const count = Health.summary.warnings + Health.summary.errors; return count + (count === 1 ? " health observation" : " health observations"); } - return "Panama desktop is healthy"; + return "Desktop is healthy"; } function footerColor(): color { diff --git a/install b/install index c0a09d5..2438c27 100755 --- a/install +++ b/install @@ -1,11 +1,24 @@ #!/usr/bin/env bash -source ~/.local/share/Panama/bin/ascii -# Set host name + +# Panama's installer. Safe to re-run: every stage is idempotent, and this is +# also the upgrade path. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" +source "$PANAMA_PATH/bin/ascii" + +# ── Hostname, which is optional ────────────────────────────────────────────── +# +# Declining this used to `exit`, which aborted the ENTIRE installation. The +# prompt defaults to N, so simply pressing Enter -- the obvious thing to do when +# you do not want to rename your machine -- installed nothing at all and said +# nothing about it. echo -e "Current hostname is: $(hostname)" -read -p "Do you want to change the hostname? [y/N]: " confirm_change +read -r -p "Do you want to change the hostname? [y/N]: " confirm_change if [[ "$confirm_change" =~ ^[Yy]$ ]]; then - read -p "Hostname: " HOST_NAME - read -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname + read -r -p "Hostname: " HOST_NAME + read -r -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then sudo hostnamectl set-hostname "$HOST_NAME" echo "Hostname set to: $(hostname)" @@ -13,18 +26,45 @@ if [[ "$confirm_change" =~ ^[Yy]$ ]]; then echo "Hostname not changed." fi else - echo "Not changing hostname." - exit + echo "Keeping the current hostname." fi -# Ensure computer doesn't go to sleep. -gsettings set org.gnome.desktop.screensaver lock-enabled false -gsettings set org.gnome.desktop.session idle-delay 0 +# ── Keep the machine awake for the duration ────────────────────────────────── +# Package installation takes long enough to hit an idle lock, and being locked +# out mid-transaction is unpleasant. Restored on every exit path, including +# failure and Ctrl-C, so an interrupted install does not leave the screen +# permanently awake. +restore_idle() { + gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true + gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true +} +trap restore_idle EXIT INT TERM -# Run each setup stage in its own process. This keeps strict-shell options and -# helper variables local to the script that owns them. -for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done +gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true +gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true -# Revert to normal idle settings -gsettings set org.gnome.desktop.screensaver lock-enabled true -gsettings set org.gnome.desktop.session idle-delay 300 +# ── Stages ─────────────────────────────────────────────────────────────────── +# Each runs in its own process so strict-shell options and helper variables stay +# local to the script that owns them. A failing stage is reported and the rest +# still run: a missing optional package should not stop the dotfiles being +# linked. The summary at the end is what decides whether the install worked, +# because a failure scrolled past twenty minutes ago is a failure nobody saw. +failed=() +for script in "$PANAMA_PATH"/setup/scripts/*; do + [[ -x "$script" ]] || continue + stage="$(basename "$script")" + printf '\n=== %s ===\n' "$stage" + if ! "$script"; then + failed+=("$stage") + printf '!!! %s failed\n' "$stage" >&2 + fi +done + +printf '\n' +if (( ${#failed[@]} == 0 )); then + echo "Panama installed. Log out and choose the Hyprland session to start it." +else + printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 + printf 'Re-running ./install is safe and will retry them.\n' >&2 + exit 1 +fi diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages index 550aa17..bd9f678 100644 --- a/setup/packages/hyprland-packages +++ b/setup/packages/hyprland-packages @@ -1,38 +1,45 @@ -hyprland -hyprland-uwsm -uwsm -quickshell -vicinae -hyprlock -hypridle -hyprpaper -hyprpicker -hyprsunset -hyprpolkitagent -hyprshutdown -hyprpwcenter -hyprsysteminfo -hyprland-guiutils -xdg-desktop-portal-hyprland -grim -slurp -grimblast -satty -wl-clipboard -wf-recorder -gpu-screen-recorder -brightnessctl -playerctl -pamixer -udiskie -wofi +NetworkManager adw-gtk3-theme adwaita-icon-theme adwaita-sans-fonts -qt6-qtwayland -nm-connection-editor +brightnessctl +ddcutil +gpu-screen-recorder +grim +grimblast +gtk-update-icon-cache +hypridle +hyprland +hyprland-guiutils +hyprland-uwsm +hyprlock +hyprpaper +hyprpicker +hyprpolkitagent +hyprpwcenter +hyprshutdown +hyprsunset +hyprsysteminfo kde-connect +libnotify +nm-connection-editor +orca +pamixer +playerctl +qrencode +qt6-qtwayland +quickshell +satty +slurp +system-config-printer tesseract tesseract-langpack-eng +udiskie +uwsm +vicinae +wf-recorder +wireplumber +wl-clipboard +wofi +xdg-desktop-portal-hyprland zbar -system-config-printer diff --git a/setup/packages/initial-packages b/setup/packages/initial-packages index 12f0d89..6a556e8 100644 --- a/setup/packages/initial-packages +++ b/setup/packages/initial-packages @@ -1,18 +1,27 @@ awk bat +btop cargo curl eza +fontconfig +fwupd fzf -git-all gh +git-all gum +jq +kitty ksshaskpass +libselinux-utils neovim openssl +pciutils +python3-dnf python3-neovim rustup +tmux unzip -wireguard-tools wget +wireguard-tools zoxide diff --git a/tests/quickshell/declared-dependencies-contract.sh b/tests/quickshell/declared-dependencies-contract.sh new file mode 100755 index 0000000..f73b746 --- /dev/null +++ b/tests/quickshell/declared-dependencies-contract.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +# Every external command Panama's own scripts invoke must be installed by +# Panama's own package lists. +# +# This exists because the lists had drifted badly. jq is used by thirty-one call +# sites across the helpers and the contracts; kitty has a full shipped config +# and a dock pin; tmux and btop have shipped themes that the colour scheme +# switches. None of the four were declared. So a fresh machine that followed +# this repository's own install instructions would not have them. +# +# The failure is quiet by design, which is what makes it worth a test: the +# helpers are written to report "not installed" rather than crash, so a missing +# dependency presents as a feature that silently is not there. +# +# Commands from coreutils and the shell itself are not checked -- nothing +# installs those separately, and listing them would be noise. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + printf 'declared dependencies contract: %s\n' "$1" >&2 + exit 1 +} + +# Shell syntax and builtins. These are not commands anyone installs, and the +# first version of this contract reported `then`, `esac` and `done` as missing +# packages, which buried the four real findings in a hundred lines of noise. +SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$' + +# Provided by any Fedora install: coreutils, util-linux, the shell, and the +# systemd/session tooling. Nothing here is a choice Panama makes. +BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof)$' + +SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' + +declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)" +[[ -n "$declared" ]] || fail 'no package lists found' + +# A package is not always named after its command. Only the genuine mismatches +# are mapped, so an unmapped command is a real omission rather than a lookup +# failure. +package_for() { + case "$1" in + zbarimg) printf 'zbar' ;; + fc-list|fc-match) printf 'fontconfig' ;; + lspci) printf 'pciutils' ;; + getenforce) printf 'libselinux-utils' ;; + nmcli) printf 'NetworkManager' ;; + wpctl) printf 'wireplumber' ;; + nvim) printf 'neovim' ;; + fwupdmgr) printf 'fwupd' ;; + dnf4) printf 'python3-dnf' ;; + notify-send) printf 'libnotify' ;; + wl-copy|wl-paste) printf 'wl-clipboard' ;; + rg) printf 'ripgrep' ;; + python3) printf 'python3' ;; + *) printf '%s' "$1" ;; + esac +} + +missing=() +checked=0 + +while read -r script; do + [[ -n "$script" ]] || continue + head -1 "$script" | grep -qE 'bash|/sh' || continue + + # Commands appearing at the start of a statement or after a pipe. Crude, but + # it is looking for undeclared dependencies, not building a call graph. + # + # No minimum length. An earlier version required three characters, which + # quietly excluded the most-used dependency in the repository -- jq, at + # thirty-one call sites -- along with rg, ss and ip. A dependency checker + # with a blind spot for short names is worse than none, because it reports + # PASS. + while read -r cmd; do + [[ -n "$cmd" ]] || continue + [[ "$cmd" =~ $SHELL_WORDS ]] && continue + [[ "$cmd" =~ $BASELINE ]] && continue + [[ "$cmd" =~ $SESSION ]] && continue + + pkg="$(package_for "$cmd")" + grep -qx "$pkg" <<<"$declared" && continue + + # Only report a command that actually exists on this machine. An + # invented name in a comment or a heredoc is a false positive; a real + # binary that nothing declares is the thing being looked for. + command -v "$cmd" >/dev/null 2>&1 || continue + + missing+=("$cmd (from $(basename "$script"), package: $pkg)") + done < <({ + # Statement-initial or after a pipe. + grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \ + | grep -oE '[a-z][a-z0-9_-]+$' + + # Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`, + # so it never appears statement-initial and was missed entirely. + grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' "$script" \ + | grep -oE '[a-z][a-z0-9_-]+$' + + # `command -v X` is how these helpers probe for a tool before using it, + # which makes it the clearest possible statement of a dependency. + grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \ + | grep -oE '[a-z][a-z0-9_-]+$' + } | sort -u) + + checked=$((checked + 1)) +done < <(find "$repo_dir/config/dot/quickshell/scripts" \ + "$repo_dir/config/local/share/vicinae/scripts" \ + "$repo_dir/setup/scripts" "$repo_dir/bin" \ + -type f 2>/dev/null) + +if (( ${#missing[@]} > 0 )); then + printf 'declared dependencies contract: commands used but never installed:\n' >&2 + printf ' %s\n' "${missing[@]}" | sort -u >&2 + fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine' +fi + +printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked" diff --git a/tests/quickshell/health-ui-contract.sh b/tests/quickshell/health-ui-contract.sh index e52671e..a39fe77 100755 --- a/tests/quickshell/health-ui-contract.sh +++ b/tests/quickshell/health-ui-contract.sh @@ -68,6 +68,24 @@ rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \ || fail 'Fedora ownership boundary lost the Colour profiles handoff' rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \ || fail 'Fedora ownership boundary lost the Digital wellbeing handoff' +# Exact authored handoffs are asserted above. Also prove every panel named by +# this boundary is accepted by SystemSettings, so a typo cannot ship a dead +# button even if its copy still looks correct. +rg -Fq 'title: "Fedora system settings"' "$settings_dir/HealthPage.qml" \ + || fail 'the Fedora ownership boundary card is gone' + +allowed="$(rg -o '"[a-z-]+"' "$repo_dir/config/dot/quickshell/services/SystemSettings.qml" \ + | sed -n '/"\(applications\|background\|bluetooth\|color\|display\|keyboard\|mouse\|multitasking\|network\|notifications\|online-accounts\|power\|printers\|privacy\|search\|sharing\|sound\|system\|universal-access\|wacom\|wellbeing\|wifi\|wwan\)"/p' \ + | tr -d '"' | sort -u)" + +while read -r panel; do + [[ -n "$panel" ]] || continue + grep -qx "$panel" <<<"$allowed" \ + || fail "the Fedora card opens \"$panel\", which openGnomePanel does not allow -- that button does nothing" +done < <(rg -o 'openGnomePanel\("([a-z-]+)"' -r '$1' "$settings_dir/HealthPage.qml" | sort -u) + +rg -q 'openGnomePanel\(' "$settings_dir/HealthPage.qml" \ + || fail 'the Fedora ownership boundary does not open GNOME Settings at all' rg -Fq 'Health.repair(check.id, false)' "$settings_dir/HealthPage.qml" \ || fail 'Settings repair does not stay inline/non-external' rg -Fq 'ShellState.openSettings(check.action.target)' "$settings_dir/HealthPage.qml" \ diff --git a/tests/quickshell/panama-command-install-contract.sh b/tests/quickshell/panama-command-install-contract.sh index 93d627c..928c2b6 100755 --- a/tests/quickshell/panama-command-install-contract.sh +++ b/tests/quickshell/panama-command-install-contract.sh @@ -25,8 +25,16 @@ trap cleanup EXIT if rg -q 'setup/scripts/link-vicinae-scripts' "$dotfile_installer"; then fail 'link-dotfiles also invokes the command installer' fi -rg -Fq 'do "$script"; done' "$top_level_installer" \ - || fail 'top-level installer sources setup scripts into one shared shell' +# Each setup stage must run in its OWN process, so strict-shell options and +# helper variables stay local to the script that owns them. What matters is +# that the stages are executed rather than sourced -- this previously matched +# the literal one-liner `do "$script"; done`, which failed the moment the loop +# gained error reporting and spanned more than one line, despite the property +# it cares about being unchanged. +rg -q '(^|[^a-z-])(\.|source)\s+[^;]*setup/scripts' "$top_level_installer" \ + && fail 'top-level installer sources setup scripts into one shared shell' +rg -q '"\$script"' "$top_level_installer" \ + || fail 'top-level installer does not execute the setup scripts' mkdir -p "$data_dir/scripts/panama" "$fake_bin" printf 'user-owned\n' >"$data_dir/scripts/panama/keep.sh" diff --git a/tests/quickshell/settings-hyprland-write-contract.sh b/tests/quickshell/settings-hyprland-write-contract.sh index f56e002..78c3d2f 100755 --- a/tests/quickshell/settings-hyprland-write-contract.sh +++ b/tests/quickshell/settings-hyprland-write-contract.sh @@ -137,10 +137,20 @@ last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastE # ── A rejected value must be refused, not silently accepted ────────────────── qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null -sleep 0.3 + +# The refusal is reported asynchronously, so wait for it rather than sleeping a +# fixed 0.3s and hoping. That sleep made this fail roughly one run in three, +# reporting "a rejected value did not surface an error" when the error simply +# had not arrived yet -- which reads as a missing guard rather than a slow one. +rejected="" +for _ in $(seq 1 60); do + rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" + [[ -n "$rejected" ]] && break + sleep 0.1 +done + [[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor' -[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \ - || fail 'a rejected VRR value did not surface an error' +[[ -n "$rejected" ]] || fail 'a rejected VRR value did not surface an error' # ── Every getoption answer shape must be handled, not just integers ────────── # The compositor reports each option in a different JSON field depending on its