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 #!/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}"
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)" 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
;; ;;
*) *)
+123 -10
View File
@@ -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,6 +36,7 @@ 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 : [];
if (root.lastError === "Could not read the list of snapshots.")
root.lastError = ""; 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
+63 -2
View File
@@ -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 snapshots 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
View File
@@ -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'