#!/usr/bin/env bash # Versioned snapshots of Panama's durable settings stores. # # 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 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) 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 '[' 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}" 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 < <( 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?" source_file="$(snapshot_source "$name")" validate_snapshot "$source_file" 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 # 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 ;; *) fail "usage: panama-settings-backup [save|list|restore ]" ;; esac