Complete settings snapshot restoration
This commit is contained in:
@@ -1,87 +1,388 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# New snapshots are a small envelope which records both data and absence for
|
||||||
# and a restore an overwrite. That is worth exposing: the settings app now
|
# the schema store and Home favourites. Settings-only snapshots written by an
|
||||||
# changes real things -- compositor geometry, idle timeouts, the dock -- and
|
# older Panama remain restorable; because they carry no Home metadata, they
|
||||||
# being able to get back to a known-good state without hunting through git is
|
# deliberately leave current Home state alone.
|
||||||
# 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.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
config_root="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||||
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
|
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
|
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() {
|
fail() {
|
||||||
printf '%s\n' "$1" >&2
|
printf '%s\n' "$1" >&2
|
||||||
exit 1
|
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
|
case "${1:-list}" in
|
||||||
save)
|
save)
|
||||||
[[ -r "$settings" ]] || fail "No settings file to back up."
|
desktop_present=false
|
||||||
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
|
home_present=false
|
||||||
mkdir -p "$backup_dir"
|
if store_present "$settings"; then
|
||||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
validate_store "$settings" "The current settings file"
|
||||||
cp "$settings" "$backup_dir/settings-$stamp.json"
|
desktop_present=true
|
||||||
# Keep the most recent few. A snapshot per change would otherwise grow
|
fi
|
||||||
# without bound in a directory nobody ever looks at.
|
if [[ $# -ge 2 ]]; then
|
||||||
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
|
write_live_home "$2"
|
||||||
rm -f "$old"
|
fi
|
||||||
done
|
if store_present "$home"; then
|
||||||
printf '{"saved":"settings-%s.json"}\n' "$stamp"
|
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)
|
list)
|
||||||
mkdir -p "$backup_dir"
|
mkdir -p "$backup_dir"
|
||||||
first=true
|
first=true
|
||||||
printf '['
|
printf '['
|
||||||
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
|
while IFS= read -r -d '' entry; do
|
||||||
name="$(basename "$file")"
|
file="${entry#* }"
|
||||||
# settings-20260818-004512.json -> 2026-08-18 00:45
|
name="$(basename -- "$file")"
|
||||||
raw="${name#settings-}"; raw="${raw%.json}"
|
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}"
|
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" == true ]] || printf ','
|
||||||
first=false
|
first=false
|
||||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
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'
|
printf ']\n'
|
||||||
;;
|
;;
|
||||||
|
|
||||||
restore)
|
restore)
|
||||||
name="${2:-}"
|
name="${2:-}"
|
||||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
[[ -n "$name" ]] || fail "Which snapshot?"
|
||||||
# Only a bare filename from the backup directory, so a caller cannot
|
source_file="$(snapshot_source "$name")"
|
||||||
# walk out of it with a path.
|
validate_snapshot "$source_file"
|
||||||
[[ "$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."
|
|
||||||
|
|
||||||
# Snapshot what is being replaced, so restore is itself undoable.
|
desktop_present=true
|
||||||
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
|
home_action="preserve"
|
||||||
mkdir -p "$backup_dir"
|
if [[ "$snapshot_format" == "versioned" ]]; then
|
||||||
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
|
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
|
fi
|
||||||
|
|
||||||
mkdir -p "$(dirname "$settings")"
|
# Preserve the replaced state as an undo snapshot only when every
|
||||||
cp "$source_file" "$settings.tmp"
|
# existing store is valid. A corrupt store must not prevent recovery,
|
||||||
mv "$settings.tmp" "$settings"
|
# but it is not useful as a future restore point either.
|
||||||
printf '{"restored":"%s"}\n' "$name"
|
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
|
||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// Snapshots of the settings store.
|
// Snapshots of Panama's durable settings stores.
|
||||||
//
|
//
|
||||||
// The whole desktop configuration is one JSON file, so a backup is a copy and a
|
// DesktopPreferences and HomePreferences use separate files. The helper owns
|
||||||
// restore is an overwrite. Worth exposing now that the settings app changes
|
// the transactional filesystem boundary; this service owns settling the live
|
||||||
// real things -- compositor geometry, idle timeouts, the dock -- because being
|
// desktop after those files have changed underneath it.
|
||||||
// able to return to a known-good state is what makes experimenting feel safe.
|
|
||||||
//
|
//
|
||||||
// Restoring rewrites the file underneath the running shell, so the store is
|
// HomePreferences intentionally keeps its FileView in Quickshell's private
|
||||||
// told to re-read afterwards rather than waiting for the next change.
|
// state directory while snapshots use Panama's canonical state directory. This
|
||||||
|
// service bridges them through HomePreferences' public mutation API, then soft
|
||||||
|
// reloads once external consumers have settled.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
@@ -25,6 +26,7 @@ Singleton {
|
|||||||
property string lastAction: ""
|
property string lastAction: ""
|
||||||
|
|
||||||
readonly property bool busy: listQuery.running || actionRun.running
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|
|| applyRestoredState.running || settleReload.running
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: listQuery
|
id: listQuery
|
||||||
@@ -34,7 +36,8 @@ Singleton {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(this.text);
|
const parsed = JSON.parse(this.text);
|
||||||
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
||||||
root.lastError = "";
|
if (root.lastError === "Could not read the list of snapshots.")
|
||||||
|
root.lastError = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
root.lastError = "Could not read the list of snapshots.";
|
root.lastError = "Could not read the list of snapshots.";
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,11 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: actionRun
|
id: actionRun
|
||||||
property bool restoring: false
|
property bool restoring: false
|
||||||
|
property string outputText: ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: actionRun.outputText = this.text
|
||||||
|
}
|
||||||
|
onStarted: actionRun.outputText = ""
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = actionRun.restoring
|
root.lastError = actionRun.restoring
|
||||||
@@ -52,14 +60,54 @@ Singleton {
|
|||||||
: "The settings could not be backed up.";
|
: "The settings could not be backed up.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
|
||||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
if (actionRun.restoring)
|
if (actionRun.restoring) {
|
||||||
|
const homeReloaded = root.reloadHomeState(actionRun.outputText);
|
||||||
|
root.lastError = homeReloaded
|
||||||
|
? ""
|
||||||
|
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||||
DesktopPreferences.reload();
|
DesktopPreferences.reload();
|
||||||
|
applyRestoredState.restart();
|
||||||
|
} else
|
||||||
|
root.lastError = "";
|
||||||
root.refresh();
|
root.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: applyRestoredState
|
||||||
|
interval: 80
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||||
|
// These services also own state outside QML and need an explicit
|
||||||
|
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||||
|
SystemSettings.applyPersistedDisplayPolicy();
|
||||||
|
Keybinds.applyReload();
|
||||||
|
Wallpaper.set(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
||||||
|
|
||||||
|
settleReload.attempts = 0;
|
||||||
|
settleReload.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleReload
|
||||||
|
property int attempts: 0
|
||||||
|
interval: 100
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
attempts++;
|
||||||
|
// Let the current instances finish their external writes before a
|
||||||
|
// soft reload replaces them. The cap keeps a failed external tool
|
||||||
|
// from leaving restored Home state stale indefinitely.
|
||||||
|
if ((!Keybinds.reloading && !SystemSettings.busy) || attempts >= 30) {
|
||||||
|
stop();
|
||||||
|
Quickshell.reload(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.refresh()
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
function refresh(): void {
|
function refresh(): void {
|
||||||
@@ -71,7 +119,72 @@ Singleton {
|
|||||||
if (actionRun.running)
|
if (actionRun.running)
|
||||||
return;
|
return;
|
||||||
actionRun.restoring = false;
|
actionRun.restoring = false;
|
||||||
actionRun.exec([root.helperPath, "save"]);
|
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseHomeState(): string {
|
||||||
|
const favorites = [];
|
||||||
|
for (const favorite of HomePreferences.favorites ?? []) {
|
||||||
|
favorites.push({
|
||||||
|
id: String(favorite.id ?? ""),
|
||||||
|
alias: String(favorite.alias ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
initialized: HomePreferences.initialized,
|
||||||
|
favorites: favorites
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore output carries the canonical Home state. Reconstructing through
|
||||||
|
// these methods keeps validation and persistence inside HomePreferences;
|
||||||
|
// this service never mutates its aliases or private FileView directly.
|
||||||
|
function reloadHomeState(text: string): bool {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
const restored = result?.home;
|
||||||
|
if (!restored || restored.preserve === true)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (restored.present !== true)
|
||||||
|
return restored.present === false
|
||||||
|
? root.resetHomeState()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const data = restored.data;
|
||||||
|
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
|
||||||
|
return false;
|
||||||
|
const ids = [];
|
||||||
|
const aliases = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const favorite of data.favorites) {
|
||||||
|
const id = favorite?.id;
|
||||||
|
const alias = favorite?.alias;
|
||||||
|
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|
||||||
|
|| typeof alias !== "string" || seen[id])
|
||||||
|
return false;
|
||||||
|
seen[id] = true;
|
||||||
|
ids.push(id);
|
||||||
|
aliases.push(alias);
|
||||||
|
}
|
||||||
|
if (!data.initialized && ids.length > 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
HomePreferences.resetHomeDefaults();
|
||||||
|
if (!data.initialized)
|
||||||
|
return true;
|
||||||
|
HomePreferences.initialize(ids);
|
||||||
|
for (let index = 0; index < ids.length; index++)
|
||||||
|
HomePreferences.setAlias(ids[index], aliases[index]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHomeState(): bool {
|
||||||
|
HomePreferences.resetHomeDefaults();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The name is matched against the snapshot list rather than trusted, so no
|
// The name is matched against the snapshot list rather than trusted, so no
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ cleanup() { rm -rf "$work"; }
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
settings="$work/config/panama/settings.json"
|
settings="$work/config/panama/settings.json"
|
||||||
|
home="$work/state/panama/panama-home.json"
|
||||||
backups="$work/state/panama/backups"
|
backups="$work/state/panama/backups"
|
||||||
mkdir -p "$(dirname "$settings")"
|
mkdir -p "$(dirname "$settings")"
|
||||||
|
|
||||||
@@ -33,15 +34,43 @@ run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported su
|
|||||||
|
|
||||||
# ── A snapshot round-trips ───────────────────────────────────────────────────
|
# ── A snapshot round-trips ───────────────────────────────────────────────────
|
||||||
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
|
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
|
||||||
|
mkdir -p "$(dirname "$home")"
|
||||||
|
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home"
|
||||||
run save >/dev/null || fail 'save failed on a valid settings file'
|
run save >/dev/null || fail 'save failed on a valid settings file'
|
||||||
name="$(run list | jq -r '.[0].name')"
|
name="$(run list | jq -r '.[0].name')"
|
||||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
||||||
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
|
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
|
||||||
|
|
||||||
printf '{"gapsOut":99}' >"$settings"
|
printf '{"gapsOut":99}' >"$settings"
|
||||||
run restore "$name" >/dev/null || fail 'restore failed'
|
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||||||
|
restore_result="$(run restore "$name")" || fail 'restore failed'
|
||||||
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
|
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
|
||||||
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
||||||
|
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favourites'
|
||||||
|
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Desk" ]] || fail 'restore lost a Home alias'
|
||||||
|
jq -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \
|
||||||
|
|| fail 'restore did not return Home state for the live service to reload'
|
||||||
|
|
||||||
|
# ── Absence is part of a snapshot ───────────────────────────────────────────
|
||||||
|
rm -f "$home"
|
||||||
|
printf '{"gapsOut":30}' >"$settings"
|
||||||
|
run save >/dev/null || fail 'save failed when Home state was absent'
|
||||||
|
absent_name="$(run list | jq -r '.[0].name')"
|
||||||
|
printf '{"initialized":true,"favorites":[{"id":"light.living_room","alias":"Living room"}]}' >"$home"
|
||||||
|
absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snapshot without Home state'
|
||||||
|
[[ ! -e "$home" ]] || fail 'restore did not preserve the snapshot’s absent Home state'
|
||||||
|
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \
|
||||||
|
|| fail 'restore did not return absent Home state for the live service to reload'
|
||||||
|
|
||||||
|
# A legacy settings-only snapshot predates presence metadata. Its safest
|
||||||
|
# interpretation is to restore desktop settings without deleting current Home
|
||||||
|
# state that the old format knew nothing about.
|
||||||
|
legacy="settings-20000101-010203004.json"
|
||||||
|
printf '{"gapsOut":17}' >"$backups/$legacy"
|
||||||
|
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Office"}]}' >"$home"
|
||||||
|
run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed'
|
||||||
|
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings'
|
||||||
|
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe'
|
||||||
|
|
||||||
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
||||||
count="$(run list | jq 'length')"
|
count="$(run list | jq 'length')"
|
||||||
@@ -52,12 +81,44 @@ bad="settings-19990101-000000000.json"
|
|||||||
mkdir -p "$backups"
|
mkdir -p "$backups"
|
||||||
printf '{ truncated' >"$backups/$bad"
|
printf '{ truncated' >"$backups/$bad"
|
||||||
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
||||||
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'a refused restore still damaged the settings file'
|
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'a refused restore still damaged the settings file'
|
||||||
|
|
||||||
|
invalid_home="settings-19990101-000000001.json"
|
||||||
|
jq -n '{
|
||||||
|
version: 2,
|
||||||
|
desktop: {present: true, data: {gapsOut: 88}},
|
||||||
|
home: {present: true, data: {
|
||||||
|
initialized: true,
|
||||||
|
favorites: [
|
||||||
|
{id: "light.desk", alias: "Desk"},
|
||||||
|
{id: "light.desk", alias: "Duplicate"}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
}' >"$backups/$invalid_home"
|
||||||
|
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored'
|
||||||
|
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
|
||||||
|
|
||||||
|
printf '{ truncated' >"$home"
|
||||||
|
run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up'
|
||||||
|
printf '{"initialized":true,"favorites":[]}' >"$home"
|
||||||
|
|
||||||
|
# ── The live service can sync its private Home state before save ─────────────
|
||||||
|
rm -f "$home"
|
||||||
|
printf '{"gapsOut":21}' >"$settings"
|
||||||
|
live_home='{"initialized":true,"favorites":[{"id":"light.studio","alias":"Studio"}]}'
|
||||||
|
run save "$live_home" >/dev/null || fail 'save rejected valid live Home state'
|
||||||
|
live_name="$(run list | jq -r '.[0].name')"
|
||||||
|
jq -e '.home.present == true and .home.data.favorites[0].alias == "Studio"' \
|
||||||
|
"$backups/$live_name" >/dev/null \
|
||||||
|
|| fail 'live Home state was not written to the canonical snapshot'
|
||||||
|
|
||||||
# ── A snapshot cannot name a path outside the backup directory ───────────────
|
# ── A snapshot cannot name a path outside the backup directory ───────────────
|
||||||
printf '{"pwned":true}' >"$work/outside.json"
|
printf '{"pwned":true}' >"$work/outside.json"
|
||||||
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
|
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
|
||||||
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
|
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
|
||||||
|
link_name="settings-20000101-000000001.json"
|
||||||
|
ln -s "$work/outside.json" "$backups/$link_name"
|
||||||
|
run restore "$link_name" >/dev/null 2>&1 && fail 'a snapshot symlink escaping the backup directory was accepted'
|
||||||
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
|
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
|
||||||
|
|
||||||
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# The helper proves the two stores round-trip in an isolated XDG tree. This
|
||||||
|
# source contract pins the live handoff without launching a second copy of the
|
||||||
|
# daily-driver shell or invoking Hyprland during tests.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'settings backup live contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
rg -q 'DesktopPreferences\.reload\(\)' "$service" \
|
||||||
|
|| fail 'restore does not reload DesktopPreferences'
|
||||||
|
rg -q 'HomePreferences\.resetHomeDefaults\(\)' "$service" \
|
||||||
|
|| fail 'restore does not clear current Home state before reloading it'
|
||||||
|
rg -q 'HomePreferences\.initialize\(' "$service" \
|
||||||
|
|| fail 'restore does not reload restored Home favourites through the public API'
|
||||||
|
rg -q 'HomePreferences\.setAlias\(' "$service" \
|
||||||
|
|| fail 'restore does not reload restored Home aliases through the public API'
|
||||||
|
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$service"; then
|
||||||
|
fail 'restore bypasses the durable HomePreferences API with direct alias mutation'
|
||||||
|
fi
|
||||||
|
rg -q 'SystemSettings\.applyPersistedDisplayPolicy\(\)' "$service" \
|
||||||
|
|| fail 'restore does not reapply compositor-backed preferences'
|
||||||
|
rg -q 'Keybinds\.applyReload\(\)' "$service" \
|
||||||
|
|| fail 'restore does not regenerate and reload rebound shortcuts'
|
||||||
|
rg -q 'Wallpaper\.set\(' "$service" \
|
||||||
|
|| fail 'restore does not reapply the restored wallpaper'
|
||||||
|
rg -q 'Quickshell\.reload\(false\)' "$service" \
|
||||||
|
|| fail 'restore does not soft-reload HomePreferences and reactive theme state'
|
||||||
|
rg -q 'actionRun\.exec\(\[root\.helperPath, "save", root\.serialiseHomeState\(\)\]\)' "$service" \
|
||||||
|
|| fail 'save does not hand the live HomePreferences state to the canonical backup store'
|
||||||
|
|
||||||
|
# User-controlled snapshot names must remain argv values. Restoring through a
|
||||||
|
# shell command would make validation in the helper the only line of defence.
|
||||||
|
rg -q 'actionRun\.exec\(\[root\.helperPath, "restore", name\]\)' "$service" \
|
||||||
|
|| fail 'restore is not executed through an argument array'
|
||||||
|
if rg -q 'bash.*-c|sh.*-c' "$service"; then
|
||||||
|
fail 'the restore service constructs a shell command'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'settings backup live contract: PASS\n'
|
||||||
Reference in New Issue
Block a user