Complete settings snapshot restoration

This commit is contained in:
Gabriel Brown
2026-08-18 02:22:23 -04:00
parent cae72b5179
commit 172b099a04
4 changed files with 585 additions and 63 deletions
@@ -1,87 +1,388 @@
#!/usr/bin/env bash
# Snapshots of the Panama settings store.
# Versioned snapshots of Panama's durable settings stores.
#
# The whole desktop configuration is one JSON file, which makes a backup a copy
# and a restore an overwrite. That is worth exposing: the settings app now
# changes real things -- compositor geometry, idle timeouts, the dock -- and
# being able to get back to a known-good state without hunting through git is
# the difference between experimenting freely and being cautious.
#
# panama-settings-backup save snapshot the current settings
# panama-settings-backup list JSON list of snapshots, newest first
# panama-settings-backup restore <name> replace settings with a snapshot
#
# Snapshots are validated as JSON on the way in and on the way out, so a
# truncated file can never be restored over a working configuration.
#
# Names carry milliseconds. At one-second resolution a save followed promptly by
# a restore produced the same filename twice, and the restore's own safety
# snapshot overwrote the very file it was about to read.
# New snapshots are a small envelope which records both data and absence for
# the schema store and Home favourites. Settings-only snapshots written by an
# older Panama remain restorable; because they carry no Home metadata, they
# deliberately leave current Home state alone.
set -euo pipefail
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
config_root="${XDG_CONFIG_HOME:-$HOME/.config}"
state_root="${XDG_STATE_HOME:-$HOME/.local/state}"
settings="$config_root/panama/settings.json"
home="$state_root/panama/panama-home.json"
backup_dir="$state_root/panama/backups"
keep=15
home_json_filter='type == "object"
and (.initialized | type == "boolean")
and (.favorites | type == "array")
and all(.favorites[];
type == "object"
and (.id | type == "string" and test("^light\\.[a-z0-9_]+$"))
and (.alias | type == "string"))
and ((.initialized == true) or (.favorites | length == 0))
and ((.favorites | map(.id) | unique | length) == (.favorites | length))'
fail() {
printf '%s\n' "$1" >&2
exit 1
}
store_present() {
[[ -e "$1" || -L "$1" ]]
}
store_valid() {
local path="$1"
[[ -f "$path" && ! -L "$path" && -r "$path" ]] \
&& jq -e 'type == "object"' "$path" >/dev/null 2>&1
}
home_store_valid() {
local path="$1"
store_valid "$path" && jq -e "$home_json_filter" "$path" >/dev/null 2>&1
}
validate_store() {
local path="$1"
local label="$2"
store_present "$path" || return 1
[[ ! -L "$path" ]] || fail "$label is a symbolic link and cannot be backed up safely."
[[ -f "$path" && -r "$path" ]] || fail "$label is not a readable file."
jq -e 'type == "object"' "$path" >/dev/null 2>&1 \
|| fail "$label is not valid settings JSON."
}
validate_home_store() {
validate_store "$1" "$2"
jq -e "$home_json_filter" "$1" >/dev/null 2>&1 \
|| fail "$2 does not contain valid Home favourites."
}
# The live HomePreferences store still lives in Quickshell's private state
# directory. SettingsBackup passes its public values as one argv element so the
# canonical Panama state file is current before save. No value is evaluated or
# interpolated into a command.
write_live_home() {
local json="$1"
local directory
local temp
jq -e "$home_json_filter" <<<"$json" >/dev/null 2>&1 \
|| fail "The live Home state is not valid."
[[ ! -L "$home" ]] \
|| fail "The current Home state file is a symbolic link and cannot be replaced safely."
directory="$(dirname "$home")"
mkdir -p "$directory"
temp="$(mktemp "$directory/.home-save.XXXXXX")"
chmod 600 "$temp"
jq '.' <<<"$json" >"$temp"
mv -- "$temp" "$home"
}
next_snapshot_path() {
local stamp
local candidate
while true; do
stamp="$(date +%Y%m%d-%H%M%S%3N)"
candidate="$backup_dir/settings-$stamp.json"
if [[ ! -e "$candidate" && ! -L "$candidate" ]]; then
snapshot_stamp="$stamp"
snapshot_path="$candidate"
return
fi
sleep 0.002
done
}
prune_snapshots() {
local -a entries=()
local index
local path
mapfile -d '' entries < <(
find "$backup_dir" -maxdepth 1 -type f \
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
-printf '%T@ %p\0' | sort -zrn
)
for ((index = keep; index < ${#entries[@]}; index++)); do
path="${entries[$index]#* }"
rm -f -- "$path"
done
}
# Writes the current stores after callers have decided whether invalid or
# absent state should be fatal. The destination is created in the backup
# directory and renamed into place, so list/restore never observe half a JSON
# document.
write_snapshot() {
local desktop_present=false
local home_present=false
local temp
store_present "$settings" && desktop_present=true
store_present "$home" && home_present=true
[[ "$desktop_present" == true || "$home_present" == true ]] \
|| return 1
mkdir -p "$backup_dir"
next_snapshot_path
temp="$(mktemp "$backup_dir/.settings-snapshot.XXXXXX")"
chmod 600 "$temp"
if [[ "$desktop_present" == true && "$home_present" == true ]]; then
jq -n --slurpfile desktop "$settings" --slurpfile home "$home" '{
version: 2,
desktop: { present: true, data: $desktop[0] },
home: { present: true, data: $home[0] }
}' >"$temp"
elif [[ "$desktop_present" == true ]]; then
jq -n --slurpfile desktop "$settings" '{
version: 2,
desktop: { present: true, data: $desktop[0] },
home: { present: false }
}' >"$temp"
else
jq -n --slurpfile home "$home" '{
version: 2,
desktop: { present: false },
home: { present: true, data: $home[0] }
}' >"$temp"
fi
mv -- "$temp" "$snapshot_path"
prune_snapshots
}
snapshot_source() {
local name="$1"
local candidate="$backup_dir/$name"
local canonical_dir
local canonical_file
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] \
|| fail "Not a snapshot name."
[[ -f "$candidate" && ! -L "$candidate" && -r "$candidate" ]] \
|| fail "That snapshot is missing."
canonical_dir="$(realpath -e -- "$backup_dir")"
canonical_file="$(realpath -e -- "$candidate")"
[[ "$canonical_file" == "$canonical_dir/"* ]] \
|| fail "That snapshot is outside the backup directory."
printf '%s\n' "$candidate"
}
validate_snapshot() {
local source_file="$1"
jq -e 'type == "object"' "$source_file" >/dev/null 2>&1 \
|| fail "That snapshot is not valid JSON."
if jq -e 'has("version")' "$source_file" >/dev/null 2>&1; then
jq -e '
.version == 2
and (.desktop | type == "object")
and (.desktop.present | type == "boolean")
and ((.desktop.present == false) or (.desktop.data | type == "object"))
and (.home | type == "object")
and (.home.present | type == "boolean")
and ((.home.present == false) or (.home.data | type == "object"))
' "$source_file" >/dev/null 2>&1 \
|| fail "That snapshot uses an unsupported format."
if jq -e '.home.present' "$source_file" >/dev/null 2>&1; then
jq -e ".home.data | $home_json_filter" "$source_file" >/dev/null 2>&1 \
|| fail "That snapshot contains invalid Home favourites."
fi
snapshot_format="versioned"
else
snapshot_format="legacy"
fi
}
stage_json() {
local source_file="$1"
local filter="$2"
local directory="$3"
local template="$4"
local staged
mkdir -p "$directory"
staged="$(mktemp "$directory/$template.XXXXXX")"
chmod 600 "$staged"
jq -e "$filter" "$source_file" >"$staged"
printf '%s\n' "$staged"
}
backup_current_file() {
local target="$1"
local directory="$2"
local template="$3"
if ! store_present "$target"; then
printf '\n'
return
fi
[[ -f "$target" && ! -L "$target" ]] \
|| fail "A settings target is not a regular file."
local rollback
rollback="$(mktemp "$directory/$template.XXXXXX")"
chmod 600 "$rollback"
cp -- "$target" "$rollback"
printf '%s\n' "$rollback"
}
restore_target() {
local target="$1"
local present="$2"
local staged="$3"
if [[ "$present" == true ]]; then
mv -- "$staged" "$target"
else
rm -f -- "$target"
fi
}
case "${1:-list}" in
save)
[[ -r "$settings" ]] || fail "No settings file to back up."
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
mkdir -p "$backup_dir"
stamp="$(date +%Y%m%d-%H%M%S%3N)"
cp "$settings" "$backup_dir/settings-$stamp.json"
# Keep the most recent few. A snapshot per change would otherwise grow
# without bound in a directory nobody ever looks at.
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
rm -f "$old"
done
printf '{"saved":"settings-%s.json"}\n' "$stamp"
desktop_present=false
home_present=false
if store_present "$settings"; then
validate_store "$settings" "The current settings file"
desktop_present=true
fi
if [[ $# -ge 2 ]]; then
write_live_home "$2"
fi
if store_present "$home"; then
validate_home_store "$home" "The current Home state file"
home_present=true
fi
[[ "$desktop_present" == true || "$home_present" == true ]] \
|| fail "No Panama settings exist to back up."
write_snapshot
printf '{"saved":"settings-%s.json"}\n' "$snapshot_stamp"
;;
list)
mkdir -p "$backup_dir"
first=true
printf '['
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
name="$(basename "$file")"
# settings-20260818-004512.json -> 2026-08-18 00:45
raw="${name#settings-}"; raw="${raw%.json}"
while IFS= read -r -d '' entry; do
file="${entry#* }"
name="$(basename -- "$file")"
raw="${name#settings-}"
raw="${raw%.json}"
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
if jq -e '.version == 2' "$file" >/dev/null 2>&1; then
keys="$(jq -r 'if .desktop.present then (.desktop.data | keys | length) else 0 end' "$file" 2>/dev/null || printf 0)"
else
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
fi
[[ "$first" == true ]] || printf ','
first=false
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
done
done < <(
find "$backup_dir" -maxdepth 1 -type f \
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
-printf '%T@ %p\0' | sort -zrn
)
printf ']\n'
;;
restore)
name="${2:-}"
[[ -n "$name" ]] || fail "Which snapshot?"
# Only a bare filename from the backup directory, so a caller cannot
# walk out of it with a path.
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name."
source_file="$backup_dir/$name"
[[ -r "$source_file" ]] || fail "That snapshot is missing."
jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON."
source_file="$(snapshot_source "$name")"
validate_snapshot "$source_file"
# Snapshot what is being replaced, so restore is itself undoable.
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
mkdir -p "$backup_dir"
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
desktop_present=true
home_action="preserve"
if [[ "$snapshot_format" == "versioned" ]]; then
desktop_present="$(jq -r '.desktop.present' "$source_file")"
home_action="$(jq -r 'if .home.present then "present" else "absent" end' "$source_file")"
if [[ "$desktop_present" == true ]]; then
desktop_stage="$(stage_json "$source_file" '.desktop.data' "$(dirname "$settings")" '.settings-restore')"
else
mkdir -p "$(dirname "$settings")"
desktop_stage=""
fi
if [[ "$home_action" == "present" ]]; then
home_stage="$(stage_json "$source_file" '.home.data' "$(dirname "$home")" '.home-restore')"
else
mkdir -p "$(dirname "$home")"
home_stage=""
fi
else
desktop_stage="$(stage_json "$source_file" '.' "$(dirname "$settings")" '.settings-restore')"
mkdir -p "$(dirname "$home")"
home_stage=""
fi
mkdir -p "$(dirname "$settings")"
cp "$source_file" "$settings.tmp"
mv "$settings.tmp" "$settings"
printf '{"restored":"%s"}\n' "$name"
# Preserve the replaced state as an undo snapshot only when every
# existing store is valid. A corrupt store must not prevent recovery,
# but it is not useful as a future restore point either.
if { ! store_present "$settings" || store_valid "$settings"; } \
&& { ! store_present "$home" || home_store_valid "$home"; }; then
write_snapshot >/dev/null 2>&1 || true
fi
settings_dir="$(dirname "$settings")"
home_dir="$(dirname "$home")"
settings_rollback="$(backup_current_file "$settings" "$settings_dir" '.settings-rollback')"
home_rollback="$(backup_current_file "$home" "$home_dir" '.home-rollback')"
rollback_needed=true
rollback() {
if [[ "$settings_rollback" != "" ]]; then
mv -f -- "$settings_rollback" "$settings"
else
rm -f -- "$settings"
fi
if [[ "$home_action" != "preserve" ]]; then
if [[ "$home_rollback" != "" ]]; then
mv -f -- "$home_rollback" "$home"
else
rm -f -- "$home"
fi
fi
}
cleanup_restore() {
local status=$?
if [[ "$rollback_needed" == true ]]; then
rollback || true
fi
[[ "${desktop_stage:-}" == "" ]] || rm -f -- "$desktop_stage"
[[ "${home_stage:-}" == "" ]] || rm -f -- "$home_stage"
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
exit "$status"
}
trap cleanup_restore EXIT
restore_target "$settings" "$desktop_present" "$desktop_stage"
if [[ "$home_action" != "preserve" ]]; then
restore_target "$home" "$([[ "$home_action" == "present" ]] && printf true || printf false)" "$home_stage"
fi
rollback_needed=false
trap - EXIT
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
if [[ "$home_action" == "present" ]]; then
jq -cn --arg restored "$name" --slurpfile home "$home" \
'{restored: $restored, home: {present: true, data: $home[0]}}'
elif [[ "$home_action" == "absent" ]]; then
jq -cn --arg restored "$name" \
'{restored: $restored, home: {present: false}}'
else
jq -cn --arg restored "$name" \
'{restored: $restored, home: {preserve: true}}'
fi
;;
*)