diff --git a/README.md b/README.md index 4c899e5..4b241d0 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -152 of them, under `tests/`. Run the lot, or a subset by pattern: +153 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/modules/bar/AgentUsageWidget.qml b/config/dot/quickshell/modules/bar/AgentUsageWidget.qml index 6d9ce0a..c41c8f4 100644 --- a/config/dot/quickshell/modules/bar/AgentUsageWidget.qml +++ b/config/dot/quickshell/modules/bar/AgentUsageWidget.qml @@ -8,9 +8,14 @@ // it is a coding-tool readout, not something a general-purpose desktop shows // without being asked. // -// Children go straight into Pill's own Row -- it adopts them through its -// default alias, so wrapping them in another Row and centring that is both -// redundant and a warning at load. +// The glyph and the number live in their own Row, the same shape VitalsField +// uses. Anchoring them straight into Pill's layout Row instead made each child +// centre against a Row whose height those same children determine, and they +// came out sitting off-centre against the rest of the bar. +// +// Clickable, because a readout you cannot ask anything of is furniture. Left +// click opens the settings that govern it; hovering says which window the +// number belongs to and when it resets. import QtQuick import qs.config @@ -21,28 +26,36 @@ Pill { id: root visible: Settings.showAgentUsage && AgentUsage.available - interactive: false + onActivated: ShellState.openSettings("appearance") onSecondaryActivated: ShellState.openSettings("appearance") - Text { - anchors.verticalCenter: parent.verticalCenter - text: "\u{F1719}" // md-robot-outline - color: { - if (AgentUsage.headline >= 90) return Theme.danger; - if (AgentUsage.headline >= 75) return Theme.warn; - return Theme.fgDim; - } - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeSmall - } + Row { + spacing: 4 - Text { - anchors.verticalCenter: parent.verticalCenter - text: AgentUsage.headline + "%" - color: Theme.fg - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - font.features: Theme.tabularFigures + Text { + anchors.verticalCenter: parent.verticalCenter + text: "\u{F1719}" // md-robot-outline + color: { + if (AgentUsage.headline >= 90) return Theme.danger; + if (AgentUsage.headline >= 75) return Theme.warn; + return Theme.fgDim; + } + font.family: Theme.fontMono + font.pixelSize: Theme.fontSize + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: AgentUsage.headline + "%" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.features: Theme.tabularFigures + // Fixed and right-aligned so the bar does not shuffle sideways when + // the number gains a digit, exactly as VitalsField does. + horizontalAlignment: Text.AlignRight + width: 30 + } } } diff --git a/config/dot/quickshell/scripts/panama-agent-usage b/config/dot/quickshell/scripts/panama-agent-usage index 5536dd4..a3e4127 100755 --- a/config/dot/quickshell/scripts/panama-agent-usage +++ b/config/dot/quickshell/scripts/panama-agent-usage @@ -95,7 +95,12 @@ fi # field is optional: an endpoint that stops reporting one should cost that # number, not the whole indicator. usage="$(jq -c ' - def pct: if type == "number" then (. * 100 | round) else null end; + # The endpoint reports utilisation as a percentage already -- 15 means 15%. + # This multiplied by 100 on the assumption it was a 0..1 fraction, which is + # how the bar came to read 1500%. Clamped as well as rounded, because a + # readout is a number you glance at and trust; one that can exceed 100 + # teaches you not to. + def pct: if type == "number" then ([[(. | round), 0] | max, 100] | min) else null end; { tier: (.rate_limit_tier // .rateLimitTier // null), subscription: (.subscription_type // .subscriptionType // null), diff --git a/migrations/1787672400.sh b/migrations/1787672400.sh new file mode 100755 index 0000000..c34e5ff --- /dev/null +++ b/migrations/1787672400.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# Remove the solopasha/hyprland COPR left over from an earlier setup. +# +# Panama installs Hyprland from lionheartp/Hyprland. A machine set up before +# that decision -- or half set up from sunhat-era notes -- can still carry +# solopasha's COPR repo file. It arrives disabled, so nothing is broken today, +# and that is exactly why it is worth removing now rather than later: the moment +# somebody enables it while debugging, two repositories provide hyprland, +# hyprland-uwsm and xdg-desktop-portal-hyprland, and dnf resolves the split by +# version rather than by intent. Half the desktop from one COPR and half from +# the other is a very bad afternoon. +# +# Only Panama's business if it is disabled. A machine that has deliberately +# enabled solopasha is a machine somebody made a choice about, and a migration +# is not the place to overrule it. + +set -euo pipefail + +repo_file="/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:solopasha:hyprland.repo" + +[[ -e "$repo_file" ]] || exit 0 + +# Enabled means somebody chose it. Say so and leave. +if grep -qE '^\s*enabled\s*=\s*1' "$repo_file"; then + echo "Leaving solopasha/hyprland alone: it is enabled, so it is in use deliberately." + echo "Panama installs Hyprland from lionheartp/Hyprland; having both enabled will" + echo "eventually mix the two. Disable it with: sudo dnf copr disable solopasha/hyprland" + exit 0 +fi + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" +sudo_cmd=(sudo) +[[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=( + "$PANAMA_PATH/bin/panama-sudo" --reason + "Removing the unused solopasha/hyprland repository, which conflicts with the one Panama installs from" + -- +) + +"${sudo_cmd[@]}" rm -f "$repo_file" +echo "Removed the disabled solopasha/hyprland COPR; Hyprland comes from lionheartp/Hyprland." diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index a3410c5..e820a7d 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -22,6 +22,23 @@ packages_in() { sed 's/#.*//' "$1" | tr "\n" " " } +# Runs something whose failure must not cost you the desktop. +# +# `set -e` above is right for the packages Panama cannot work without and wrong +# for everything else. A codec swap that finds nothing to swap, a group update +# renamed upstream, a third-party host that is down -- each of those used to end +# this stage wherever it happened to sit, and the desktop was installed near the +# bottom, so any one of them meant a machine with no Hyprland on it and a single +# line of dnf output to explain why. +# +# So the ordering rule for this file: anything that can fail for a reason +# outside this repository goes below the desktop, and goes through here. +soft() { + local what="$1"; shift + "$@" >/dev/null 2>&1 || { log "$what did not complete; continuing"; softly_failed+=("$what"); } +} +softly_failed=() + # --- Defined Paths --- # The default, not an assignment: ./install and link-dotfiles honor an exported # PANAMA_PATH, and clobbering it here made a clone anywhere else source the @@ -41,32 +58,27 @@ sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1 log "Installing RPM Fusion AppStream Metadata" sudo dnf update @core -y > /dev/null sudo dnf install -y rpmfusion-\*-appstream-data > /dev/null -log "Installing Terra Repository" -sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null +# Terra bootstraps itself: --repofrompath defines a throwaway repo just long +# enough to install terra-release, which then writes the real /etc/yum.repos.d +# entry. Doing that a second time is not harmless -- dnf5 refuses the whole +# transaction with 'Id is present more than once in the configuration', because +# the throwaway id collides with the one terra-release already installed. +# +# That is what killed a re-run on a machine Terra had already reached: this sits +# in the repository section, above everything, so `set -e` ended the stage +# before a single package was considered. An installer whose second run does +# less than its first is worse than one that never ran. +if rpm -q terra-release >/dev/null 2>&1; then + log "Terra repository already installed" +else + log "Installing Terra Repository" + sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null +fi echo -e "\n--- Installing relevant packages ---" log "Updating all packages. This may take a while" sudo dnf update -y --refresh > /dev/null -log "Updating core, multimedia, and sound-and-video groups" -# A trailing `&& sync` here previously meant a failing groupupdate was exempt -# from set -e (bash does not apply -e to the left side of a && list), so the -# failure went unreported. Sync unconditionally on its own line instead. -sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \ - --setop='install_weak_deps=False' \ - --exclude='PackageKit-gstreamer-plugin' \ - --allowerasing > /dev/null -sync -log "Swapping ffmpeg-free for ffmpeg" -sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing > /dev/null -log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld" -sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld > /dev/null -log "Upgrading Multimedia group with optional packages" -sudo dnf4 group upgrade -y --with-optional Multimedia > /dev/null -log "Installing GStreamer plugins (bad, good, base)" -sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \ - --exclude=gstreamer1-plugins-bad-free-devel > /dev/null - # --- Install all initial packages --- PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages" if [[ -f "$PACKAGES_FILE" ]]; then @@ -93,6 +105,68 @@ else log "Package list was not in specified path: $DESKTOP_FILE" fi +# --- Install the Hyprland desktop --- +# +# Directly after desktop-packages, which is what supplies the dnf plugin that +# `dnf copr` needs, and deliberately before anything optional. This is the one +# thing on the list that Panama is; a machine that gets only this far is a +# machine you can log into, and every step below it is a convenience. +# +# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. +HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" +if [[ -f "$HYPR_FILE" ]]; then + log "Enabling Hyprland COPR" + sudo dnf copr enable -y lionheartp/Hyprland > /dev/null + HYPR_PACKAGES=$(packages_in "$HYPR_FILE") + log "Installing Hyprland desktop packages" + echo -e "Includes the following packages:" + echo -e "$(<"$HYPR_FILE")" + sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null + log "Hyprland packages installed!" +else + log "Package list was not in specified path: $HYPR_FILE" +fi + +# Said out loud, because the failure this guards against was silent. The stage +# used to die somewhere above this point and report one red line among twenty +# minutes of scrollback, and the machine looked installed until you tried to log +# into it. +if rpm -q hyprland >/dev/null 2>&1; then + log "Hyprland $(rpm -q --queryformat '%{VERSION}' hyprland) is installed." +else + log "Hyprland is NOT installed. Nothing below this point will give you a desktop." + exit 1 +fi + +# --- Codecs and multimedia --------------------------------------------------- +# +# Below the desktop and every one of them non-fatal, because none is a +# dependency of it and each can fail for reasons that have nothing to do with +# this repository -- a swap whose source package this spin never shipped, a +# group renamed upstream between Fedora releases. +# +# A trailing `&& sync` on the group update previously meant a failure was exempt +# from set -e as well (bash does not apply -e to the left of a && list), so it +# went unreported rather than being deliberately tolerated. It is deliberate now. + +log "Updating core, multimedia, and sound-and-video groups" +soft "the multimedia group update" \ + sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \ + --setop='install_weak_deps=False' \ + --exclude='PackageKit-gstreamer-plugin' \ + --allowerasing +sync +log "Swapping ffmpeg-free for ffmpeg" +soft "the ffmpeg swap" sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing +log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld" +soft "the mesa driver swap" sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld +log "Upgrading Multimedia group with optional packages" +soft "the optional Multimedia upgrade" sudo dnf4 group upgrade -y --with-optional Multimedia +log "Installing GStreamer plugins (bad, good, base)" +soft "the GStreamer plugins" \ + sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \ + --exclude=gstreamer1-plugins-bad-free-devel + # --- Install Development Packages needed for Neovim --- DEV_FILE="$PANAMA_PATH/setup/packages/development-packages" if [[ -f "$DEV_FILE" ]]; then @@ -100,7 +174,7 @@ if [[ -f "$DEV_FILE" ]]; then log "Installing Development Packages. Mostly for Neovim." echo -e "Includes the following packages:" echo -e "$(<"$DEV_FILE")" - sudo dnf install -y $DEV_PACKAGES > /dev/null + soft "the development packages" sudo dnf install -y $DEV_PACKAGES log "Development packages installed!" else log "Package list was not in specified path: $DEV_FILE" @@ -136,22 +210,6 @@ else log "nvm is not installed, so Node was not set up" fi -# --- Install the Hyprland desktop --- -# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. -HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" -if [[ -f "$HYPR_FILE" ]]; then - log "Enabling Hyprland COPR" - sudo dnf copr enable -y lionheartp/Hyprland > /dev/null - HYPR_PACKAGES=$(packages_in "$HYPR_FILE") - log "Installing Hyprland desktop packages" - echo -e "Includes the following packages:" - echo -e "$(<"$HYPR_FILE")" - sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null - log "Hyprland packages installed!" -else - log "Package list was not in specified path: $HYPR_FILE" -fi - # --- Applications no repository packages ------------------------------------- # # Everything else Panama installs comes from dnf or Flathub. These four do not @@ -238,11 +296,14 @@ FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages" if [[ -f "$FLATPAK_FILE" ]]; then FLATPAK_PACKAGES=$(packages_in "$FLATPAK_FILE") log "Adding Flathub remote" - sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null + soft "adding the Flathub remote" \ + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo log "Installing Flatpak Packages" echo -e "Includes the following packages:" echo -e "$(<"$FLATPAK_FILE")" - sudo flatpak install -y flathub $FLATPAK_PACKAGES > /dev/null + # One ID renamed on Flathub must not cost the rest of the run; the desktop + # is already installed by this point and none of these is part of it. + soft "some Flatpak packages" sudo flatpak install -y flathub $FLATPAK_PACKAGES log "Flatpak packages installed!" else log "Package list was not in specified path: $FLATPAK_FILE" @@ -301,3 +362,16 @@ for extra in ${PANAMA_EXTRAS:-}; do log "No such extras category: $extra" fi done + +# --- What was stepped over --------------------------------------------------- +# +# Tolerating a failure is only better than aborting on it if somebody is told. +# This stage now survives a codec swap that finds nothing to swap, and the whole +# point of surviving it is that the desktop gets installed anyway -- but a +# machine missing its video codecs should say so once, here, rather than be +# discovered a week later by a video that will not play. +if (( ${#softly_failed[@]} > 0 )); then + log "Installed, but these were stepped over:" + printf ' - %s\n' "${softly_failed[@]}" + log "None of them stops the desktop. Re-run this stage to try them again." +fi diff --git a/tests/quickshell/agent-usage-contract b/tests/quickshell/agent-usage-contract index e680982..588053b 100755 --- a/tests/quickshell/agent-usage-contract +++ b/tests/quickshell/agent-usage-contract @@ -54,7 +54,7 @@ CREDS cat >"$stub/curl" <>"$work/curl-argv" -printf '%s\n' '{"five_hour":{"utilization":0.42,"resets_at":"2026-08-22T14:00:00Z"},"seven_day":{"utilization":0.71,"resets_at":"2026-08-27T00:00:00Z"},"rate_limit_tier":"default_claude_max_5x"}' +printf '%s\n' '{"five_hour":{"utilization":42,"resets_at":"2026-08-22T14:00:00Z"},"seven_day":{"utilization":71,"resets_at":"2026-08-27T00:00:00Z"},"rate_limit_tier":"default_claude_max_5x"}' STUB chmod +x "$stub/curl" @@ -108,10 +108,40 @@ uncommented "$collector" | grep -qE '>[[:space:]]*"?\$?\{?CREDENTIALS' \ # ── The reading it produced ───────────────────────────────────────────────── +# The endpoint already reports percentages. Treating them as 0..1 fractions and +# multiplying is how the bar came to read 1500%. [[ "$(jq -r '.usage.fiveHour.used' "$output")" == "42" ]] \ - || note 'the five-hour utilisation was not converted to a percentage' + || note "the five-hour reading is $(jq -r '.usage.fiveHour.used' "$output") for a reported 42%" [[ "$(jq -r '.usage.week.used' "$output")" == "71" ]] \ - || note 'the weekly utilisation was not converted to a percentage' + || note "the weekly reading is $(jq -r '.usage.week.used' "$output") for a reported 71%" + +# Nothing the widget shows may fall outside the range a percentage has, whatever +# the endpoint says. A readout that can print 1500% is one you learn to ignore. +cat >"$stub/curl" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' '{"five_hour":{"utilization":1500},"seven_day":{"utilization":-4}}' +STUB +chmod +x "$stub/curl" +run +[[ "$(jq -r '.usage.fiveHour.used' "$output")" == "100" ]] \ + || note 'an out-of-range reading was not clamped to 100' +[[ "$(jq -r '.usage.week.used' "$output")" == "0" ]] \ + || note 'a negative reading was not clamped to 0' + +cat >"$stub/curl" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' '{"five_hour":{"utilization":42,"resets_at":"2026-08-22T14:00:00Z"},"seven_day":{"utilization":71,"resets_at":"2026-08-27T00:00:00Z"},"rate_limit_tier":"default_claude_max_5x"}' +STUB +chmod +x "$stub/curl" + +# ── It answers a click ────────────────────────────────────────────────────── +# +# Pill's MouseArea is gated on `interactive`, so a widget that sets it false and +# connects onSecondaryActivated has a handler nothing can ever reach. +uncommented "$widget" | grep -q 'interactive: false' \ + && note 'the widget disables Pill''s mouse area, so its click handlers never fire' +uncommented "$widget" | grep -q 'onActivated' \ + || note 'left-clicking the widget does nothing' # An endpoint that answers with something else must degrade, not crash. cat >"$stub/curl" <<'STUB' diff --git a/tests/setup/desktop-first-contract b/tests/setup/desktop-first-contract new file mode 100755 index 0000000..a8b06d8 --- /dev/null +++ b/tests/setup/desktop-first-contract @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +# The desktop installs before anything that is allowed to fail. +# +# install-packages runs under `set -euo pipefail`, and the Hyprland block used +# to sit near the bottom of it, below a codec swap, two group updates and a +# GStreamer glob. Any one of those exiting non-zero ended the stage where it +# stood. On a machine whose spin never shipped ffmpeg-free, the swap failed, the +# stage died, and the install finished reporting one red line among twenty +# minutes of scrollback -- with no Hyprland on the disk at all. +# +# The rule that prevents it: anything that can fail for a reason outside this +# repository goes BELOW the desktop, and goes through `soft`. What is left above +# the desktop is only what the desktop needs. +# +# This pins the order, not the individual commands, because the next fragile +# thing somebody adds will not be a codec swap. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +installer="$repo_dir/setup/scripts/install-packages" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$installer" ]] || { printf 'desktop first contract: %s is not executable\n' "$installer" >&2; exit 1; } + +line_of() { grep -n "$1" "$installer" | head -1 | cut -d: -f1; } + +hyprland_at="$(line_of '^HYPR_FILE=')" +[[ -n "$hyprland_at" ]] || { printf 'desktop first contract: no Hyprland block found\n' >&2; exit 1; } + +# ── Nothing fragile above the desktop ─────────────────────────────────────── +# +# Named individually rather than by pattern: each is a command whose failure is +# survivable, and each one above the Hyprland block is a machine that boots to +# nothing. + +for fragile in 'dnf swap' 'groupupdate' 'group upgrade' 'gstreamer1-plugins' \ + 'flatpak install' 'nvm install' 'curl -fsSL'; do + while read -r at; do + (( at < hyprland_at )) \ + && note "'$fragile' runs at line $at, above the desktop at line $hyprland_at" + done < <(grep -n -- "$fragile" "$installer" | grep -v '^\s*#' | cut -d: -f1) +done + +# ── Everything fragile is actually tolerated ──────────────────────────────── +# +# Being below the desktop is only half of it. A bare `dnf swap` below the +# Hyprland block still kills every step after it -- the flatpaks, the extras +# somebody explicitly chose. + +# Comments dropped and backslash continuations joined, so a `soft` invocation +# wrapped across three lines reads as the one command it is. +uncommented() { grep -vE '^\s*#' "$installer" | sed -e :a -e '/\\$/N; s/\\\n\s*/ /; ta'; } + +while read -r command; do + uncommented | grep -q "soft .*$command" \ + || note "'$command' runs without soft, so its failure still ends the stage" +done <<'FRAGILE' +dnf swap -y 'ffmpeg-free' +dnf swap -y mesa-va-drivers +groupupdate +group upgrade +gstreamer1-plugins +flatpak install -y flathub $FLATPAK_PACKAGES +FRAGILE + +grep -q '^soft()' "$installer" \ + || note 'install-packages defines no soft helper, so nothing can be tolerated deliberately' + +# ── The desktop failing is still fatal, and still said out loud ───────────── +# +# The inverse mistake: making everything survivable turns a machine with no +# desktop into a run that reports success. + +python3 - "$installer" "$hyprland_at" <<'PY' || note 'a missing Hyprland does not stop the stage' +import sys +lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +start = int(sys.argv[2]) +after = "\n".join(lines[start:start + 40]) +if "rpm -q hyprland" not in after or "exit 1" not in after: + raise SystemExit(1) +PY + +uncommented | grep -q 'soft .*HYPR_PACKAGES' \ + && note 'the Hyprland install is tolerated, so a machine with no desktop reports success' + +# ── Soft failures are reported ────────────────────────────────────────────── + +grep -q 'softly_failed' "$installer" \ + || note 'nothing collects what was stepped over, so a tolerated failure is a silent one' + +python3 - "$installer" <<'PY' || note 'the list of stepped-over steps is never printed' +import sys +text = open(sys.argv[1], encoding="utf-8").read() +tail = text[text.rindex("softly_failed"):] +if "printf" not in tail and "log" not in tail: + raise SystemExit(1) +PY + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'desktop first contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'desktop first contract: PASS (desktop at line %s, everything fragile below it)\n' "$hyprland_at"