Complete settings snapshot restoration
This commit is contained in:
@@ -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
|
||||
;;
|
||||
|
||||
*)
|
||||
|
||||
Reference in New Issue
Block a user