diff --git a/bin/panama-hw b/bin/panama-hw
index 6a6e77a..5a1d2a9 100755
--- a/bin/panama-hw
+++ b/bin/panama-hw
@@ -38,28 +38,37 @@ is_laptop() {
[[ "$PORTABLE_CHASSIS" == *" $chassis "* ]]
}
-# The first battery, or nothing. Named rather than assumed to be BAT0: the
-# second battery in a ThinkPad is BAT1, and a machine with only BAT1 exists.
+# The first SYSTEM battery, or nothing. Named rather than assumed to be BAT0:
+# the second battery in a ThinkPad is BAT1, and a machine with only BAT1
+# exists. The scope check is what keeps a desktop a desktop: a wireless mouse
+# or a game controller publishes type=Battery with scope=Device, and counting
+# one turned a tower into a "laptop" whose battery readout was the mouse's.
battery_path() {
- local supply type
+ local supply type scope
for supply in "$SYS"/class/power_supply/*; do
[[ -r "$supply/type" ]] || continue
type="$(cat "$supply/type" 2>/dev/null)"
- if [[ "$type" == "Battery" ]]; then
- printf '%s\n' "$supply"
- return 0
- fi
+ [[ "$type" == "Battery" ]] || continue
+ scope="$(cat "$supply/scope" 2>/dev/null || echo System)"
+ [[ "$scope" == "Device" ]] && continue
+ printf '%s\n' "$supply"
+ return 0
done
return 1
}
has_battery() { battery_path >/dev/null; }
-# On wall power. A machine with no mains supply at all is a desktop, and a
-# desktop is always on wall power -- answering "no" there would make every
-# battery-aware timing apply to a machine that cannot run out of power.
+# On wall power. A machine with no mains supply at all and no system battery
+# is a desktop, and a desktop is always on wall power -- answering "no" there
+# would make every battery-aware timing apply to a machine that cannot run
+# out of power. But "no Mains" alone is not "desktop": hardware charged only
+# over USB-PD exposes type=USB supplies and no Mains at all, and reading that
+# as permanently-on-AC meant its battery timings never engaged while it ran
+# down. When no Mains exists but a system battery does, the battery's own
+# status is the answer: Discharging means battery, everything else means fed.
on_ac() {
- local supply type online found=1
+ local supply type online found=1 battery status
for supply in "$SYS"/class/power_supply/*; do
[[ -r "$supply/type" ]] || continue
type="$(cat "$supply/type" 2>/dev/null)"
@@ -70,16 +79,28 @@ on_ac() {
done
# Mains exists and none of it is online: genuinely on battery.
(( found == 0 )) && return 1
+ if battery="$(battery_path)"; then
+ status="$(cat "$battery/status" 2>/dev/null || echo Unknown)"
+ [[ "$status" == "Discharging" ]] && return 1
+ fi
return 0
}
+# ACPI first, logind second. Some platforms expose the lid only as an evdev
+# switch with no /proc/acpi/button entry; logind watches the switch either
+# way, so its LidClosed property is the fallback that keeps clamshell
+# detection honest there. No logind (a container, a test tree) means the
+# fallback quietly answers open, which is the safe direction.
lid_closed() {
local state
for state in "$ACPI"/button/lid/*/state; do
[[ -r "$state" ]] || continue
grep -qi closed "$state" && return 0
+ return 1
done
- return 1
+ [[ -d "$ACPI/button/lid" ]] && return 1
+ busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
+ org.freedesktop.login1.Manager LidClosed 2>/dev/null | grep -q 'b true'
}
# A connected output that is not the built-in panel. eDP, LVDS and DSI are the
diff --git a/config/bash/.bashrc b/config/bash/.bashrc
index 8a602fc..80079c6 100644
--- a/config/bash/.bashrc
+++ b/config/bash/.bashrc
@@ -18,6 +18,19 @@ if [ -d "$PANAMA_BASH" ]; then
[ -f "$f" ] && . "$f"
done
unset f
+
+ # Personal environment -- API keys, tokens -- lives OUTSIDE the checkout,
+ # where agents, backup tools and `panama update` walk, and is kept
+ # owner-only every time it is read: a secrets file that drifts to 644 is
+ # quietly re-tightened rather than trusted. (config/bash/env, its old home
+ # inside the repo, is still sourced by the glob above if a machine has not
+ # been migrated yet.)
+ PANAMA_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/panama/env"
+ if [ -f "$PANAMA_ENV" ]; then
+ [ "$(stat -c %a "$PANAMA_ENV" 2>/dev/null)" = "600" ] || chmod 600 "$PANAMA_ENV"
+ . "$PANAMA_ENV"
+ fi
+ unset PANAMA_ENV
else
if ! [[ "$PATH" =~ "$HOME/.local/bin:$HOME/bin:" ]]; then
PATH="$HOME/.local/bin:$HOME/bin:$PATH"
diff --git a/config/bash/aliases b/config/bash/aliases
index 501ea81..2f10d2c 100644
--- a/config/bash/aliases
+++ b/config/bash/aliases
@@ -6,7 +6,16 @@ alias :wq="exit"
alias sourcerc="source ~/.bashrc"
alias c="clear"
alias shutdown="systemctl poweroff"
-alias update-grub="sudo grub-mkconfig -o /etc/grub2-efi.cfg"
+# The config target differs by firmware: EFI machines regenerate the EFI
+# config, BIOS machines /boot/grub2/grub.cfg -- writing the EFI path on a BIOS
+# machine updates a file nothing boots from.
+update-grub() {
+ if [ -d /sys/firmware/efi ]; then
+ sudo grub2-mkconfig -o /etc/grub2-efi.cfg
+ else
+ sudo grub2-mkconfig -o /boot/grub2/grub.cfg
+ fi
+}
alias nvidia-smi-docker='sudo docker run --rm --gpus all --privileged nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi'
alias ncconnect='sudo docker exec -u www-data -it nextcloud-aio-nextcloud bash'
alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mode()end, 100)"'
diff --git a/config/bash/shell b/config/bash/shell
index f4d3af8..9911705 100644
--- a/config/bash/shell
+++ b/config/bash/shell
@@ -28,14 +28,22 @@ export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
# Auto-switch Node version when entering a directory with .nvmrc
_nvm_auto_use() {
- if [[ -f .nvmrc ]]; then
+ # Guarded on nvm actually being loaded: without this, a machine where the
+ # nvm profile script is absent printed "command not found" on every single
+ # prompt in any directory carrying a .nvmrc.
+ if [[ -f .nvmrc ]] && type -t nvm >/dev/null 2>&1; then
nvm use --silent
fi
}
export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_nvm_auto_use"
-# Auto-start or attach tmux for SSH interactive shells
-if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* ]]; then
+# Auto-start or attach tmux for SSH interactive shells. A deliberate Panama
+# behavior (tmux is in initial-packages, and a dropped SSH session keeping its
+# work is the point), but guarded: it must not replace the shell of someone
+# whose machine lacks tmux, and PANAMA_SSH_TMUX=off turns it off for people
+# who want a plain shell -- set it in ~/.config/panama/env.
+if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* \
+ && "${PANAMA_SSH_TMUX:-on}" != "off" ]] && command -v tmux >/dev/null 2>&1; then
exec tmux new-session -A -s main
fi
diff --git a/config/copy/etc/dnf/dnf.conf b/config/copy/etc/dnf/dnf.conf
deleted file mode 100644
index 364bd29..0000000
--- a/config/copy/etc/dnf/dnf.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-# see `man dnf.conf` for defaults and possible options
-
-[main]
-fastestmirror=True
-max_parallel_downloads=10
-defaultyes=True
-keepcache=True
-deltarpm=True
diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index d218777..336cc36 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -1395,14 +1395,18 @@ Singleton {
label: "Recording folder",
detail: "Where screen recordings are saved. Relative to your home folder unless it starts with /"
},
+ // "auto" stands in for the render node until record time:
+ // /dev/dri/renderD128 was baked into every option once, which is one
+ // machine's enumeration and frequently the wrong node on hybrid
+ // graphics. Capture.qml resolves it when recording starts.
{
- key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
+ key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d auto",
group: "capture",
label: "Recording encoder",
detail: "Hardware encoding keeps recording off the processor while gaming",
options: [
- { value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
- { value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
+ { value: "-c h264_vaapi -d auto", label: "VAAPI H.264" },
+ { value: "-c hevc_vaapi -d auto", label: "VAAPI HEVC" },
{ value: "-c libx264", label: "CPU x264" }
]
},
diff --git a/config/dot/quickshell/scripts/panama-battery b/config/dot/quickshell/scripts/panama-battery
index 4dd6dbc..9b95c8c 100755
--- a/config/dot/quickshell/scripts/panama-battery
+++ b/config/dot/quickshell/scripts/panama-battery
@@ -31,6 +31,22 @@ battery_dir() {
"$HW" battery-path 2>/dev/null
}
+# Every system battery, for the machines that have two. The primary pack
+# answers `paths` and drives the watch files; these answer the questions
+# where ignoring the second pack gives a wrong answer -- the total charge,
+# and which packs a threshold write must reach.
+all_battery_dirs() {
+ local supply type scope
+ for supply in "$SYS"/class/power_supply/*; do
+ [[ -r "$supply/type" ]] || continue
+ type="$(cat "$supply/type" 2>/dev/null)"
+ [[ "$type" == "Battery" ]] || continue
+ scope="$(cat "$supply/scope" 2>/dev/null || echo System)"
+ [[ "$scope" == "Device" ]] && continue
+ printf '%s\n' "$supply"
+ done
+}
+
# The mains supply, if the machine has one. A desktop has none, and that is
# not an error: panama-hw's `ac` predicate treats "no mains at all" as being on
# wall power, and the shell falls back to the same assumption.
@@ -78,6 +94,21 @@ cmd_status() {
capacity="$(read_int "$battery/capacity")" || capacity=""
[[ -r "$battery/status" ]] && state="$(cat "$battery/status" 2>/dev/null)"
threshold="$(read_int "$battery/charge_control_end_threshold")" || threshold=0
+
+ # With two packs, one pack's percentage is not the machine's: sum the
+ # stored and full energy across every system battery and answer with
+ # the real total. Single-battery machines never reach this.
+ local dirs=() dir now full total_now=0 total_full=0
+ mapfile -t dirs < <(all_battery_dirs)
+ if (( ${#dirs[@]} > 1 )); then
+ for dir in "${dirs[@]}"; do
+ now="$(read_int "$dir/energy_now" || read_int "$dir/charge_now")" || continue
+ full="$(read_int "$dir/energy_full" || read_int "$dir/charge_full")" || continue
+ total_now=$(( total_now + now ))
+ total_full=$(( total_full + full ))
+ done
+ (( total_full > 0 )) && capacity=$(( (total_now * 100 + total_full / 2) / total_full ))
+ fi
fi
if [[ -n "$mains" ]]; then
online="$(read_int "$mains/online")" || online=0
@@ -95,19 +126,25 @@ cmd_set_threshold() {
[[ "$value" =~ ^[0-9]+$ ]] || { echo 'set-threshold needs a percentage' >&2; return 2; }
(( value >= 50 && value <= 100 )) || { echo 'threshold must be between 50 and 100' >&2; return 2; }
- battery="$(battery_dir)" || { echo 'no battery on this machine' >&2; return 1; }
- file="$battery/charge_control_end_threshold"
- [[ -e "$file" ]] || { echo 'this machine cannot set a charge threshold' >&2; return 1; }
+ # Every pack that has the attribute, not just the first: capping one
+ # battery of a two-battery machine leaves the other charging to full,
+ # which is the opposite of what the person asked for.
+ local files=() dir
+ while IFS= read -r dir; do
+ [[ -e "$dir/charge_control_end_threshold" ]] && files+=("$dir/charge_control_end_threshold")
+ done < <(all_battery_dirs)
+ (( ${#files[@]} > 0 )) || { echo 'this machine cannot set a charge threshold' >&2; return 1; }
# tee rather than a redirect: the redirect is performed by the calling
- # shell, which is not the one holding root.
+ # shell, which is not the one holding root. One authorization writes every
+ # pack.
"$PANAMA_PATH/bin/panama-sudo" \
--reason "Capping battery charging at ${value}% to reduce wear" \
- -- sh -c "printf '%s\n' '$value' | tee '$file' >/dev/null" || return 1
+ -- sh -c "printf '%s\n' '$value' | tee ${files[*]} >/dev/null" || return 1
# Read it back rather than reporting success from the write's exit code:
# some firmware silently clamps or ignores the value.
- read_int "$file"
+ read_int "${files[0]}"
}
case "${1:-status}" in
diff --git a/config/dot/quickshell/scripts/panama-fingerprint b/config/dot/quickshell/scripts/panama-fingerprint
index 760c045..89ebac6 100755
--- a/config/dot/quickshell/scripts/panama-fingerprint
+++ b/config/dot/quickshell/scripts/panama-fingerprint
@@ -39,7 +39,10 @@ cmd_status() {
# so this also copes with the daemon not running yet) and names the
# enrolled fingers in one call.
local listing
- if ! listing="$(timeout 10 fprintd-list "$USER" 2>&1)"; then
+ # LC_ALL=C: the "no devices" match below reads fprintd's message, and a
+ # translated daemon would turn every readerless non-English machine into
+ # a permanent error card.
+ if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then
# "No devices available" is the normal no-reader machine; anything
# else is a real problem worth surfacing.
if grep -qi 'no devices' <<<"$listing"; then
diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle
index fd8177d..8aac71d 100755
--- a/config/dot/quickshell/scripts/panama-idle
+++ b/config/dot/quickshell/scripts/panama-idle
@@ -155,7 +155,7 @@ install_dropin() {
# Panama Settings rather than by editing a file in the Panama repository.
[Service]
ExecStart=
-ExecStart=/usr/bin/hypridle -c $generated
+ExecStart=$(command -v hypridle || echo /usr/bin/hypridle) -c $generated
EOF
systemctl --user daemon-reload
}
diff --git a/config/dot/quickshell/scripts/panama-snapshots b/config/dot/quickshell/scripts/panama-snapshots
index fe19c6c..92652b9 100755
--- a/config/dot/quickshell/scripts/panama-snapshots
+++ b/config/dot/quickshell/scripts/panama-snapshots
@@ -25,6 +25,8 @@ hit.
from __future__ import annotations
+import csv
+import io
import json
import os
import re
@@ -70,52 +72,47 @@ def require_number(value: str) -> int:
return number
-def config_names() -> list[str]:
- result = run(["snapper", "list-configs"])
+# Everything snapper answers is read through --machine-readable csv and a
+# DictReader. The first version split snapper's box-drawing table by column
+# INDEX, which is the presentation layer: it is localized, and its columns
+# have moved between snapper versions -- a German desktop parsed to zero
+# snapshots while `snapper list` showed twelve. Named columns survive both.
+def snapper_csv(args: list[str], timeout: int = 20) -> list[dict]:
+ result = run(["snapper", "--machine-readable", "csv", *args], timeout=timeout)
if result.returncode != 0:
return []
- names = []
- for line in result.stdout.splitlines()[2:]:
- parts = [part.strip() for part in line.split("│")]
- if len(parts) >= 2 and CONFIG_NAME.fullmatch(parts[0]):
- names.append(parts[0])
- return names
+ return list(csv.DictReader(io.StringIO(result.stdout)))
+
+
+def config_names() -> list[str]:
+ return [row["config"] for row in snapper_csv(["list-configs"])
+ if CONFIG_NAME.fullmatch(row.get("config", ""))]
def config_settings(name: str) -> dict:
- result = run(["snapper", "-c", name, "get-config"])
- if result.returncode != 0:
- return {}
- values = {}
- for line in result.stdout.splitlines()[2:]:
- parts = [part.strip() for part in line.split("│")]
- if len(parts) >= 2:
- values[parts[0]] = parts[1]
- return values
+ return {row["key"]: row.get("value", "")
+ for row in snapper_csv(["-c", name, "get-config"]) if row.get("key")}
def snapshots_for(name: str) -> list[dict]:
- result = run(["snapper", "-c", name, "list"], timeout=45)
- if result.returncode != 0:
- return []
entries = []
- for line in result.stdout.splitlines()[2:]:
- parts = [part.strip() for part in line.split("│")]
- if len(parts) < 7 or not parts[0].isdigit():
+ for row in snapper_csv(["-c", name, "list"], timeout=45):
+ if not str(row.get("number", "")).isdigit():
continue
- number = int(parts[0])
+ number = int(row["number"])
if number == 0:
continue
+ cleanup = row.get("cleanup", "") or ""
entries.append({
"number": number,
- "kind": parts[1],
- "date": parts[3],
- "user": parts[4],
- "cleanup": parts[5],
- "description": parts[6],
+ "kind": row.get("type", ""),
+ "date": row.get("date", ""),
+ "user": row.get("user", ""),
+ "cleanup": cleanup,
+ "description": row.get("description", ""),
# A snapshot with no cleanup algorithm is not on the timeline's
# list to remove, which is what "kept" means to someone reading it.
- "kept": parts[5] == "",
+ "kept": cleanup == "",
})
entries.sort(key=lambda entry: entry["number"], reverse=True)
return entries
@@ -193,6 +190,10 @@ def snapshot() -> dict:
"unprotected": unprotected,
"timelineRunning": timeline_running(),
"space": free_space(),
+ # An ext4 machine and an unconfigured btrfs machine used to look
+ # identical: an empty page. "supported" is whether snapshots are even
+ # possible here, so absence can say which kind of absence it is.
+ "supported": bool(btrfs_subvolumes()),
"error": "",
}
diff --git a/config/dot/quickshell/services/Capture.qml b/config/dot/quickshell/services/Capture.qml
index 22ba190..92aa89e 100644
--- a/config/dot/quickshell/services/Capture.qml
+++ b/config/dot/quickshell/services/Capture.qml
@@ -194,6 +194,20 @@ Singleton {
Quickshell.execDetached(args);
}
+ // The render node "auto" in recorderArgs resolves to. Enumerated once at
+ // startup, which is as often as it changes; empty until the scan lands,
+ // and the map below falls back to the conventional first node then.
+ property string renderNode: ""
+
+ Process {
+ id: renderNodeScan
+ command: ["sh", "-c", "ls /dev/dri/renderD* 2>/dev/null | head -1"]
+ running: true
+ stdout: StdioCollector {
+ onStreamFinished: root.renderNode = this.text.trim()
+ }
+ }
+
function recordRegion(geom: string): void {
if (recProc.running)
return;
@@ -205,7 +219,13 @@ Singleton {
// mkdir + exec so the PID we later SIGINT is wf-recorder itself and not
// the shell wrapping it.
let cmd = ["sh", "-c", 'mkdir -p "$1" && shift && exec "$@"', "qs-capture", root.recDir, "wf-recorder", "-y"];
- cmd = cmd.concat(Settings.recorderArgs.split(" ").filter(a => a !== ""));
+ // "auto" becomes a real render node here rather than in the schema:
+ // the stored option must not carry one machine's device path to the
+ // next machine. First node wins; a hybrid machine that needs the
+ // other one can store explicit args.
+ const args = Settings.recorderArgs.split(" ").filter(a => a !== "")
+ .map(a => a === "auto" ? (root.renderNode || "/dev/dri/renderD128") : a);
+ cmd = cmd.concat(args);
if (geom !== "")
cmd.push("-g", geom);
else if (root._outputName !== "")
diff --git a/docs/settings.md b/docs/settings.md
index b5ddc3c..f149caa 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -47,7 +47,7 @@ Found on **Screen Intelligence**.
|---|---|---|
| **Screenshot folder**
`screenshotDir` | Pictures/Screenshots | Where screenshots are saved. Relative to your home folder unless it starts with / |
| **Recording folder**
`recordingDir` | Videos/Screencasts | Where screen recordings are saved. Relative to your home folder unless it starts with / |
-| **Recording encoder**
`recorderArgs` | -c h264_vaapi -d /dev/dri/renderD128 | Hardware encoding keeps recording off the processor while gaming Choices: VAAPI H.264, VAAPI HEVC, CPU x264. |
+| **Recording encoder**
`recorderArgs` | -c h264_vaapi -d auto | Hardware encoding keeps recording off the processor while gaming Choices: VAAPI H.264, VAAPI HEVC, CPU x264. |
## clock
diff --git a/install b/install
index 78a064a..d83ee64 100755
--- a/install
+++ b/install
@@ -14,14 +14,23 @@ source "$PANAMA_PATH/bin/ascii"
# installed, and nothing asks again afterwards. That is the whole bargain: the
# rest of the run takes twenty minutes and needs nobody watching it.
#
-# gum is bootstrapped first because the interview is built on it and it cannot
-# install itself -- it is declared in initial-packages, which install-packages
-# installs, which runs after this. One small dnf call buys a real interface for
-# the only part of the install a person actually interacts with.
-if ! command -v gum >/dev/null 2>&1; then
- echo "Installing gum, which the setup questions are built on"
- sudo dnf install -y gum >/dev/null || {
- echo "Could not install gum, so the setup questions cannot be asked." >&2
+# The interview's tools are bootstrapped first because it cannot install them
+# itself -- they are declared in the package lists, which install-packages
+# installs, which runs after this. gum is the interface; pciutils, mokutil and
+# fwupd are the interview's eyes.
+# It probes for an NVIDIA card, Secure Boot state and updatable firmware
+# BEFORE install-packages runs, and a missing probe tool degrades the answer
+# silently to "no" -- which for Secure Boot once meant installing a driver
+# that could never load. Workstation ships all four; a minimal base does not.
+bootstrap=()
+command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
+command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
+command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil)
+command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd)
+if (( ${#bootstrap[@]} > 0 )); then
+ echo "Installing what the setup questions are built on: ${bootstrap[*]}"
+ sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
+ echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
exit 1
}
fi
diff --git a/migrations/1787500624.sh b/migrations/1787500624.sh
new file mode 100755
index 0000000..d86293a
--- /dev/null
+++ b/migrations/1787500624.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+# Remove defaultyes=True from /etc/dnf/dnf.conf on machines Panama configured.
+#
+# change-settings used to copy a whole dnf.conf over the machine's own, and
+# that file set defaultyes=True -- so every `dnf remove`, for every user,
+# treated a bare Enter as confirmation. The installer no longer ships the
+# file; this repairs the machines that already received it. Only the one
+# behavior key is touched: the performance keys it carried are harmless and
+# now applied additively by change-settings.
+
+set -euo pipefail
+
+conf=/etc/dnf/dnf.conf
+
+[[ -r "$conf" ]] || exit 0
+grep -q '^defaultyes=True$' "$conf" || exit 0
+
+PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
+sudo_cmd=(sudo)
+if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then
+ sudo_cmd=(
+ "$PANAMA_PATH/bin/panama-sudo" --reason
+ "Removing defaultyes=True from dnf.conf, which made every dnf remove treat Enter as yes"
+ --
+ )
+fi
+
+"${sudo_cmd[@]}" sed -i '/^defaultyes=True$/d' "$conf"
+echo "Removed defaultyes=True from $conf; dnf prompts default to No again."
diff --git a/migrations/1787500660.sh b/migrations/1787500660.sh
new file mode 100755
index 0000000..1ace76a
--- /dev/null
+++ b/migrations/1787500660.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+# Move the personal environment file out of the checkout.
+#
+# config/bash/env holds API keys and tokens, and it lived inside the working
+# tree that agents, backup tools and `panama update`'s diff walk routinely --
+# world-readable by default, one careless `git add -f` away from a remote.
+# Its home is now ~/.config/panama/env, owner-only, which .bashrc sources
+# with a permission check. The repo path keeps working unmigrated (the glob
+# in .bashrc still sources it), so this move can safely run at any login.
+
+set -euo pipefail
+
+PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
+old="$PANAMA_PATH/config/bash/env"
+new="${XDG_CONFIG_HOME:-$HOME/.config}/panama/env"
+
+[[ -f "$old" ]] || exit 0
+
+if [[ -f "$new" ]]; then
+ # Both exist: the person already started one at the new home. Refusing to
+ # merge secrets automatically is the safe answer; say what is where.
+ chmod 600 "$old" "$new"
+ echo "Both $old and $new exist; not merging them automatically."
+ echo "Move what you still need into $new and delete the old file."
+ exit 0
+fi
+
+mkdir -p "$(dirname "$new")"
+mv "$old" "$new"
+chmod 600 "$new"
+echo "Moved the personal environment to $new (owner-only)."
diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages
index 59611b8..a98f72b 100644
--- a/setup/packages/desktop-packages
+++ b/setup/packages/desktop-packages
@@ -45,7 +45,6 @@ gstreamer1-plugins-good-extras
gstreamer1-plugins-good-gtk
gstreamer1-plugins-good-qt
gstreamer1-plugins-good-qt6
-hipblas
jetbrainsmono-nerd-fonts
kernel-devel
lame
@@ -79,7 +78,6 @@ python3-dnf-plugin-versionlock
# The launcher's Copy Password command; talks to the same Bitwarden account
# the desktop app signs into. See panama-pick.
rbw
-rocm-opencl
# The Snapshots page is snapper end to end; see scripts/panama-snapshots.
snapper
sushi
diff --git a/setup/packages/development-packages b/setup/packages/development-packages
index 68b1688..f82f544 100644
--- a/setup/packages/development-packages
+++ b/setup/packages/development-packages
@@ -18,6 +18,10 @@ maven
# would win every switch. install-packages does the rest.
nvm
pipx
+# bin/get-port and bin/cp-files are interactive python tools; these are the
+# libraries they import, and without them both crash on their first line.
+python3-questionary
+python3-pyperclip
php
php-fpm
# Rootless containers, and the backend for the Containers settings page.
diff --git a/setup/packages/extras/gpu-compute b/setup/packages/extras/gpu-compute
new file mode 100644
index 0000000..81000cc
--- /dev/null
+++ b/setup/packages/extras/gpu-compute
@@ -0,0 +1,5 @@
+# AMD GPU compute -- hundreds of megabytes that only an AMD card can use.
+# These were in desktop-packages once, installed unconditionally on Intel and
+# NVIDIA machines that could never load them.
+hipblas | AMD hipBLAS (ROCm BLAS for GPU compute)
+rocm-opencl | AMD ROCm OpenCL runtime
diff --git a/setup/scripts/change-settings b/setup/scripts/change-settings
index 529b88b..094ac71 100755
--- a/setup/scripts/change-settings
+++ b/setup/scripts/change-settings
@@ -15,7 +15,18 @@ exists() { command -v "$1" >/dev/null 2>&1; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
echo -e "\n--- Copying System & User files ---"
-log "Changing DNF Settings"
+
+# dnf options are applied as individual keys rather than shipped as a file.
+# config/copy once carried a whole /etc/dnf/dnf.conf, which clobbered proxies
+# and mirrors a machine already had -- and set defaultyes=True system-wide, so
+# every `dnf remove` for every user treated a bare Enter as yes. These two are
+# performance knobs with no behavior change; anything else stays the machine's
+# own business. (fastestmirror and deltarpm were dnf4-era keys dnf5 ignores,
+# so they simply stop being written.)
+log "Setting dnf performance options (max_parallel_downloads, keepcache)"
+sudo dnf config-manager setopt max_parallel_downloads=10 keepcache=True 2>/dev/null \
+ || log "Could not set dnf options; defaults apply"
+log "Copying system files (udev rules, systemd drop-ins)"
sudo cp -r "$PANAMA_PATH/config/copy/." "/"
# The GPU symlink rules land via the copy above. Apply them without a reboot so
diff --git a/setup/scripts/install-hardware b/setup/scripts/install-hardware
index 408cd6b..b3ee99c 100755
--- a/setup/scripts/install-hardware
+++ b/setup/scripts/install-hardware
@@ -40,6 +40,18 @@ fi
# ── NVIDIA ───────────────────────────────────────────────────────────────────
if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then
+ # The interview asks about MOK enrollment only when mokutil was present to
+ # see Secure Boot at all. Re-check here rather than trusting that the
+ # question was ever asked: installing akmod-nvidia and blacklisting
+ # nouveau under Secure Boot with no key to enroll produces a machine that
+ # reboots into an unloadable driver with its fallback disabled -- the one
+ # failure in this installer that costs a person their display.
+ if mokutil --sb-state 2>/dev/null | grep -qi 'secureboot enabled' \
+ && [[ -z "${PANAMA_MOK_HASH:-}" ]]; then
+ warn "Secure Boot is on and no MOK enrollment was prepared; refusing to install"
+ warn "the NVIDIA driver, which could not load. Re-run ./install and answer the"
+ warn "Secure Boot question, or disable Secure Boot first."
+ else
log "Installing the NVIDIA driver"
if sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda; then
# nouveau has to be out of the way before the kernel would otherwise
@@ -61,6 +73,7 @@ if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then
else
warn "The NVIDIA driver did not install; skipping its kernel arguments and services"
fi
+ fi
fi
# ── Secure Boot ──────────────────────────────────────────────────────────────
diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages
index c2f28a8..c280d3f 100755
--- a/setup/scripts/install-packages
+++ b/setup/scripts/install-packages
@@ -22,6 +22,20 @@ packages_in() {
sed 's/#.*//' "$1" | tr "\n" " "
}
+# Names a list asked for that still are not installed, so --skip-unavailable
+# above can never silently shrink a list: a skipped font is a warning somebody
+# reads, not an absence somebody debugs a month later.
+report_missing() {
+ local file="$1" name missing=()
+ for name in $(packages_in "$file"); do
+ # --whatprovides, because several list entries are capabilities a
+ # differently-named package satisfies: awk is gawk, wget is wget2-wget.
+ rpm -q --whatprovides "$name" >/dev/null 2>&1 || missing+=("$name")
+ done
+ (( ${#missing[@]} > 0 )) && log "WARNING: not available on this machine: ${missing[*]}"
+ return 0
+}
+
# Runs something whose failure must not cost you the desktop.
#
# `set -e` above is right for the packages Panama cannot work without and wrong
@@ -58,10 +72,13 @@ echo -e "\n--- Installing Repositories ---"
log "Installing RPM Fusion Free and Nonfree Repositories"
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
log "Enabling Fedora Cisco OpenH264 Repository"
-sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
+# soft: this repo does not exist on every spin, and its absence must not cost
+# the desktop -- the ordering rule at soft()'s definition applies to the
+# repository extras just as much as to the codec swaps below.
+soft "enabling the openh264 repository" 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
+soft "the core group update" sudo dnf update @core -y
+soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data
# 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
@@ -90,7 +107,12 @@ if [[ -f "$PACKAGES_FILE" ]]; then
log "Installing Initial Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$PACKAGES_FILE")"
- sudo dnf install -y $INITIAL_PACKAGES > /dev/null
+ # --skip-unavailable: dnf5 refuses a whole transaction over one missing
+ # name, so a single rotted entry in this list used to cost every package
+ # in it -- and the desktop below never installed. The skipped names are
+ # reported afterwards rather than silently dropped.
+ sudo dnf install -y --skip-unavailable $INITIAL_PACKAGES > /dev/null
+ report_missing "$PACKAGES_FILE"
log "Initial packages installed!"
else
log "Package list was not in specified path: $PACKAGES_FILE"
@@ -103,7 +125,8 @@ if [[ -f "$DESKTOP_FILE" ]]; then
log "Installing Desktop Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$DESKTOP_FILE")"
- sudo dnf install -y $DESKTOP_PACKAGES > /dev/null
+ sudo dnf install -y --skip-unavailable $DESKTOP_PACKAGES > /dev/null
+ report_missing "$DESKTOP_FILE"
log "Desktop packages installed!"
else
log "Package list was not in specified path: $DESKTOP_FILE"
@@ -263,8 +286,19 @@ if rpm -q claude-desktop-extra >/dev/null 2>&1; then
else
if [[ ! -f /etc/yum.repos.d/claude-desktop.repo ]]; then
log "Adding the Claude Desktop repository..."
- curl -fsSL https://patrickjaja.github.io/claude-desktop-extra/install-rpm.sh \
- | sudo bash > /dev/null 2>&1 || log "Could not add the Claude Desktop repository"
+ # Fetched to a file and then run, never piped into root: a pipe executes
+ # whatever the network answered with no chance to look, and this one is an
+ # unpinned script from a personal GitHub Pages site -- the least trusted
+ # thing this installer touches. The file is kept next to the run so what
+ # executed is still on disk to read afterwards.
+ claude_repo_script="$(mktemp -t claude-desktop-repo.XXXXXX.sh)"
+ if curl -fsSL https://patrickjaja.github.io/claude-desktop-extra/install-rpm.sh \
+ -o "$claude_repo_script"; then
+ sudo bash "$claude_repo_script" > /dev/null 2>&1 \
+ || log "Could not add the Claude Desktop repository (script kept at $claude_repo_script)"
+ else
+ log "Could not download the Claude Desktop repository script"
+ fi
fi
log "Installing Claude Desktop..."
sudo dnf install -y claude-desktop-extra > /dev/null \
@@ -283,7 +317,7 @@ else
# rate-limited -- would otherwise trip set -e and kill the stage before the
# empty-result fallback below could do its job.
rustdesk_url="$(curl -fsSL https://api.github.com/repos/rustdesk/rustdesk/releases/latest 2>/dev/null \
- | jq -r '.assets[].browser_download_url | select(test("x86_64\\.rpm$")) | select(test("suse") | not)' \
+ | jq -r --arg arch "$(uname -m)" '.assets[].browser_download_url | select(test($arch + "\\.rpm$")) | select(test("suse") | not)' \
| head -1 || true)"
if [[ -n "$rustdesk_url" ]]; then
log "Installing RustDesk from $rustdesk_url"
diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract
index bcb3afa..d2d1649 100755
--- a/tests/quickshell/declared-dependencies-contract
+++ b/tests/quickshell/declared-dependencies-contract
@@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
# authselect is on the list for the same reason: it manages Fedora's PAM and
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
# fingerprint aliases in config/bash can rely on it without declaring it.
-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|authselect|setsid|nohup)$'
+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|authselect|setsid|nohup|grub2-mkconfig)$'
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)$'