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)$'