From 172b099a04096d38a0e164857f6a8908f3aa2db1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 01:57:53 -0400 Subject: [PATCH] Complete settings snapshot restoration --- .../quickshell/scripts/panama-settings-backup | 401 +++++++++++++++--- .../quickshell/services/SettingsBackup.qml | 135 +++++- tests/quickshell/settings-backup-contract.sh | 65 ++- .../settings-backup-live-contract.sh | 47 ++ 4 files changed, 585 insertions(+), 63 deletions(-) create mode 100755 tests/quickshell/settings-backup-live-contract.sh diff --git a/config/dot/quickshell/scripts/panama-settings-backup b/config/dot/quickshell/scripts/panama-settings-backup index 1c0c280..ca6548b 100755 --- a/config/dot/quickshell/scripts/panama-settings-backup +++ b/config/dot/quickshell/scripts/panama-settings-backup @@ -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 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 ;; *) diff --git a/config/dot/quickshell/services/SettingsBackup.qml b/config/dot/quickshell/services/SettingsBackup.qml index d4e712b..a940eb4 100644 --- a/config/dot/quickshell/services/SettingsBackup.qml +++ b/config/dot/quickshell/services/SettingsBackup.qml @@ -1,14 +1,15 @@ 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 -// restore is an overwrite. Worth exposing now that the settings app changes -// real things -- compositor geometry, idle timeouts, the dock -- because being -// able to return to a known-good state is what makes experimenting feel safe. +// DesktopPreferences and HomePreferences use separate files. The helper owns +// the transactional filesystem boundary; this service owns settling the live +// desktop after those files have changed underneath it. // -// Restoring rewrites the file underneath the running shell, so the store is -// told to re-read afterwards rather than waiting for the next change. +// HomePreferences intentionally keeps its FileView in Quickshell's private +// 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.Io @@ -25,6 +26,7 @@ Singleton { property string lastAction: "" readonly property bool busy: listQuery.running || actionRun.running + || applyRestoredState.running || settleReload.running Process { id: listQuery @@ -34,7 +36,8 @@ Singleton { try { const parsed = JSON.parse(this.text); root.snapshots = Array.isArray(parsed) ? parsed : []; - root.lastError = ""; + if (root.lastError === "Could not read the list of snapshots.") + root.lastError = ""; } catch (error) { root.lastError = "Could not read the list of snapshots."; } @@ -45,6 +48,11 @@ Singleton { Process { id: actionRun property bool restoring: false + property string outputText: "" + stdout: StdioCollector { + onStreamFinished: actionRun.outputText = this.text + } + onStarted: actionRun.outputText = "" onExited: (exitCode, exitStatus) => { if (exitCode !== 0) { root.lastError = actionRun.restoring @@ -52,14 +60,54 @@ Singleton { : "The settings could not be backed up."; return; } - root.lastError = ""; 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(); + applyRestoredState.restart(); + } else + root.lastError = ""; 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() function refresh(): void { @@ -71,7 +119,72 @@ Singleton { if (actionRun.running) return; 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 diff --git a/tests/quickshell/settings-backup-contract.sh b/tests/quickshell/settings-backup-contract.sh index f728939..dc71dc6 100755 --- a/tests/quickshell/settings-backup-contract.sh +++ b/tests/quickshell/settings-backup-contract.sh @@ -22,6 +22,7 @@ cleanup() { rm -rf "$work"; } trap cleanup EXIT settings="$work/config/panama/settings.json" +home="$work/state/panama/panama-home.json" backups="$work/state/panama/backups" 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 ─────────────────────────────────────────────────── 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' name="$(run list | jq -r '.[0].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' 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 .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 ────────────────── count="$(run list | jq 'length')" @@ -52,12 +81,44 @@ bad="settings-19990101-000000000.json" mkdir -p "$backups" printf '{ truncated' >"$backups/$bad" 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 ─────────────── printf '{"pwned":true}' >"$work/outside.json" 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' +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' # ── A snapshot that is not listed is refused ───────────────────────────────── diff --git a/tests/quickshell/settings-backup-live-contract.sh b/tests/quickshell/settings-backup-live-contract.sh new file mode 100755 index 0000000..05cd04d --- /dev/null +++ b/tests/quickshell/settings-backup-live-contract.sh @@ -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'