Make ./install something you could hand a stranger
The audit's third tier: everything between this installer and a fresh machine it has never met. The one path that could cost a person their display: the interview probes Secure Boot with mokutil, which install-packages had not installed yet, so on a minimal base the MOK question silently never fired -- and install-hardware still installed akmod-nvidia and blacklisted nouveau, arming a reboot into an unloadable driver with its fallback disabled. The probe tools (pciutils, mokutil, fwupd) now bootstrap beside gum, and install-hardware re-checks Secure Boot for itself and refuses the driver rather than the display. Secrets leave the checkout: the personal environment moves to ~/.config/panama/env at mode 600 by migration, and .bashrc sources it with a permission check that quietly re-tightens drift. change-settings no longer overwrites /etc/dnf/dnf.conf -- two performance keys are set additively, the defaultyes=True that made every `dnf remove` treat Enter as yes is gone, and a migration strips it from machines that already received it. Package installation survives the world changing: the initial and desktop lists run with --skip-unavailable and a report_missing pass that names what was skipped (resolved through --whatprovides, so capability names like awk do not cry wolf); the openh264, appstream and core-group extras go through soft; RustDesk resolves its RPM for the machine's own architecture; and the Claude Desktop repository script is fetched to a kept file and run, never piped from the network into root. The hardware predicates stop guessing: a wireless mouse's scope=Device battery no longer turns a tower into a laptop, USB-PD-only machines read their power state from the battery's own status instead of being permanently "on AC", the lid falls back to logind's LidClosed where ACPI is silent, and charge limits reach every pack of a two-battery machine in one authorization -- with the reported percentage summed across packs. And the parsers stop assuming this machine: snapper is read through --machine-readable csv with named columns instead of a localized box-drawing table, and reports whether snapshots are even possible so ext4 and unconfigured-btrfs stop looking identical; fprintd is parsed under LC_ALL=C; the hypridle drop-in resolves the binary it points at; the recorder's render node became an "auto" token resolved at record time; update-grub writes the config its firmware actually boots; the nvm prompt hook and the SSH tmux takeover are guarded; hipblas and rocm-opencl move to an opt-in gpu-compute category; and the two interactive python tools' libraries are declared. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user