Complete settings snapshot restoration
This commit is contained in:
@@ -1,87 +1,388 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Snapshots of the Panama settings store.
|
||||
# Versioned snapshots of Panama's durable settings stores.
|
||||
#
|
||||
# The whole desktop configuration is one JSON file, which makes a backup a copy
|
||||
# and a restore an overwrite. That is worth exposing: the settings app now
|
||||
# changes real things -- compositor geometry, idle timeouts, the dock -- and
|
||||
# being able to get back to a known-good state without hunting through git is
|
||||
# the difference between experimenting freely and being cautious.
|
||||
#
|
||||
# panama-settings-backup save snapshot the current settings
|
||||
# panama-settings-backup list JSON list of snapshots, newest first
|
||||
# panama-settings-backup restore <name> replace settings with a snapshot
|
||||
#
|
||||
# Snapshots are validated as JSON on the way in and on the way out, so a
|
||||
# truncated file can never be restored over a working configuration.
|
||||
#
|
||||
# Names carry milliseconds. At one-second resolution a save followed promptly by
|
||||
# a restore produced the same filename twice, and the restore's own safety
|
||||
# snapshot overwrote the very file it was about to read.
|
||||
# New snapshots are a small envelope which records both data and absence for
|
||||
# the schema store and Home favourites. Settings-only snapshots written by an
|
||||
# older Panama remain restorable; because they carry no Home metadata, they
|
||||
# deliberately leave current Home state alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
|
||||
config_root="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
state_root="${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
settings="$config_root/panama/settings.json"
|
||||
home="$state_root/panama/panama-home.json"
|
||||
backup_dir="$state_root/panama/backups"
|
||||
keep=15
|
||||
home_json_filter='type == "object"
|
||||
and (.initialized | type == "boolean")
|
||||
and (.favorites | type == "array")
|
||||
and all(.favorites[];
|
||||
type == "object"
|
||||
and (.id | type == "string" and test("^light\\.[a-z0-9_]+$"))
|
||||
and (.alias | type == "string"))
|
||||
and ((.initialized == true) or (.favorites | length == 0))
|
||||
and ((.favorites | map(.id) | unique | length) == (.favorites | length))'
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
store_present() {
|
||||
[[ -e "$1" || -L "$1" ]]
|
||||
}
|
||||
|
||||
store_valid() {
|
||||
local path="$1"
|
||||
[[ -f "$path" && ! -L "$path" && -r "$path" ]] \
|
||||
&& jq -e 'type == "object"' "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
home_store_valid() {
|
||||
local path="$1"
|
||||
store_valid "$path" && jq -e "$home_json_filter" "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
validate_store() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
store_present "$path" || return 1
|
||||
[[ ! -L "$path" ]] || fail "$label is a symbolic link and cannot be backed up safely."
|
||||
[[ -f "$path" && -r "$path" ]] || fail "$label is not a readable file."
|
||||
jq -e 'type == "object"' "$path" >/dev/null 2>&1 \
|
||||
|| fail "$label is not valid settings JSON."
|
||||
}
|
||||
|
||||
validate_home_store() {
|
||||
validate_store "$1" "$2"
|
||||
jq -e "$home_json_filter" "$1" >/dev/null 2>&1 \
|
||||
|| fail "$2 does not contain valid Home favourites."
|
||||
}
|
||||
|
||||
# The live HomePreferences store still lives in Quickshell's private state
|
||||
# directory. SettingsBackup passes its public values as one argv element so the
|
||||
# canonical Panama state file is current before save. No value is evaluated or
|
||||
# interpolated into a command.
|
||||
write_live_home() {
|
||||
local json="$1"
|
||||
local directory
|
||||
local temp
|
||||
|
||||
jq -e "$home_json_filter" <<<"$json" >/dev/null 2>&1 \
|
||||
|| fail "The live Home state is not valid."
|
||||
[[ ! -L "$home" ]] \
|
||||
|| fail "The current Home state file is a symbolic link and cannot be replaced safely."
|
||||
directory="$(dirname "$home")"
|
||||
mkdir -p "$directory"
|
||||
temp="$(mktemp "$directory/.home-save.XXXXXX")"
|
||||
chmod 600 "$temp"
|
||||
jq '.' <<<"$json" >"$temp"
|
||||
mv -- "$temp" "$home"
|
||||
}
|
||||
|
||||
next_snapshot_path() {
|
||||
local stamp
|
||||
local candidate
|
||||
while true; do
|
||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
||||
candidate="$backup_dir/settings-$stamp.json"
|
||||
if [[ ! -e "$candidate" && ! -L "$candidate" ]]; then
|
||||
snapshot_stamp="$stamp"
|
||||
snapshot_path="$candidate"
|
||||
return
|
||||
fi
|
||||
sleep 0.002
|
||||
done
|
||||
}
|
||||
|
||||
prune_snapshots() {
|
||||
local -a entries=()
|
||||
local index
|
||||
local path
|
||||
mapfile -d '' entries < <(
|
||||
find "$backup_dir" -maxdepth 1 -type f \
|
||||
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
|
||||
-printf '%T@ %p\0' | sort -zrn
|
||||
)
|
||||
for ((index = keep; index < ${#entries[@]}; index++)); do
|
||||
path="${entries[$index]#* }"
|
||||
rm -f -- "$path"
|
||||
done
|
||||
}
|
||||
|
||||
# Writes the current stores after callers have decided whether invalid or
|
||||
# absent state should be fatal. The destination is created in the backup
|
||||
# directory and renamed into place, so list/restore never observe half a JSON
|
||||
# document.
|
||||
write_snapshot() {
|
||||
local desktop_present=false
|
||||
local home_present=false
|
||||
local temp
|
||||
|
||||
store_present "$settings" && desktop_present=true
|
||||
store_present "$home" && home_present=true
|
||||
[[ "$desktop_present" == true || "$home_present" == true ]] \
|
||||
|| return 1
|
||||
|
||||
mkdir -p "$backup_dir"
|
||||
next_snapshot_path
|
||||
temp="$(mktemp "$backup_dir/.settings-snapshot.XXXXXX")"
|
||||
chmod 600 "$temp"
|
||||
|
||||
if [[ "$desktop_present" == true && "$home_present" == true ]]; then
|
||||
jq -n --slurpfile desktop "$settings" --slurpfile home "$home" '{
|
||||
version: 2,
|
||||
desktop: { present: true, data: $desktop[0] },
|
||||
home: { present: true, data: $home[0] }
|
||||
}' >"$temp"
|
||||
elif [[ "$desktop_present" == true ]]; then
|
||||
jq -n --slurpfile desktop "$settings" '{
|
||||
version: 2,
|
||||
desktop: { present: true, data: $desktop[0] },
|
||||
home: { present: false }
|
||||
}' >"$temp"
|
||||
else
|
||||
jq -n --slurpfile home "$home" '{
|
||||
version: 2,
|
||||
desktop: { present: false },
|
||||
home: { present: true, data: $home[0] }
|
||||
}' >"$temp"
|
||||
fi
|
||||
|
||||
mv -- "$temp" "$snapshot_path"
|
||||
prune_snapshots
|
||||
}
|
||||
|
||||
snapshot_source() {
|
||||
local name="$1"
|
||||
local candidate="$backup_dir/$name"
|
||||
local canonical_dir
|
||||
local canonical_file
|
||||
|
||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] \
|
||||
|| fail "Not a snapshot name."
|
||||
[[ -f "$candidate" && ! -L "$candidate" && -r "$candidate" ]] \
|
||||
|| fail "That snapshot is missing."
|
||||
|
||||
canonical_dir="$(realpath -e -- "$backup_dir")"
|
||||
canonical_file="$(realpath -e -- "$candidate")"
|
||||
[[ "$canonical_file" == "$canonical_dir/"* ]] \
|
||||
|| fail "That snapshot is outside the backup directory."
|
||||
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
validate_snapshot() {
|
||||
local source_file="$1"
|
||||
|
||||
jq -e 'type == "object"' "$source_file" >/dev/null 2>&1 \
|
||||
|| fail "That snapshot is not valid JSON."
|
||||
|
||||
if jq -e 'has("version")' "$source_file" >/dev/null 2>&1; then
|
||||
jq -e '
|
||||
.version == 2
|
||||
and (.desktop | type == "object")
|
||||
and (.desktop.present | type == "boolean")
|
||||
and ((.desktop.present == false) or (.desktop.data | type == "object"))
|
||||
and (.home | type == "object")
|
||||
and (.home.present | type == "boolean")
|
||||
and ((.home.present == false) or (.home.data | type == "object"))
|
||||
' "$source_file" >/dev/null 2>&1 \
|
||||
|| fail "That snapshot uses an unsupported format."
|
||||
if jq -e '.home.present' "$source_file" >/dev/null 2>&1; then
|
||||
jq -e ".home.data | $home_json_filter" "$source_file" >/dev/null 2>&1 \
|
||||
|| fail "That snapshot contains invalid Home favourites."
|
||||
fi
|
||||
snapshot_format="versioned"
|
||||
else
|
||||
snapshot_format="legacy"
|
||||
fi
|
||||
}
|
||||
|
||||
stage_json() {
|
||||
local source_file="$1"
|
||||
local filter="$2"
|
||||
local directory="$3"
|
||||
local template="$4"
|
||||
local staged
|
||||
|
||||
mkdir -p "$directory"
|
||||
staged="$(mktemp "$directory/$template.XXXXXX")"
|
||||
chmod 600 "$staged"
|
||||
jq -e "$filter" "$source_file" >"$staged"
|
||||
printf '%s\n' "$staged"
|
||||
}
|
||||
|
||||
backup_current_file() {
|
||||
local target="$1"
|
||||
local directory="$2"
|
||||
local template="$3"
|
||||
|
||||
if ! store_present "$target"; then
|
||||
printf '\n'
|
||||
return
|
||||
fi
|
||||
[[ -f "$target" && ! -L "$target" ]] \
|
||||
|| fail "A settings target is not a regular file."
|
||||
local rollback
|
||||
rollback="$(mktemp "$directory/$template.XXXXXX")"
|
||||
chmod 600 "$rollback"
|
||||
cp -- "$target" "$rollback"
|
||||
printf '%s\n' "$rollback"
|
||||
}
|
||||
|
||||
restore_target() {
|
||||
local target="$1"
|
||||
local present="$2"
|
||||
local staged="$3"
|
||||
|
||||
if [[ "$present" == true ]]; then
|
||||
mv -- "$staged" "$target"
|
||||
else
|
||||
rm -f -- "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
save)
|
||||
[[ -r "$settings" ]] || fail "No settings file to back up."
|
||||
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
|
||||
mkdir -p "$backup_dir"
|
||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
||||
cp "$settings" "$backup_dir/settings-$stamp.json"
|
||||
# Keep the most recent few. A snapshot per change would otherwise grow
|
||||
# without bound in a directory nobody ever looks at.
|
||||
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
|
||||
rm -f "$old"
|
||||
done
|
||||
printf '{"saved":"settings-%s.json"}\n' "$stamp"
|
||||
desktop_present=false
|
||||
home_present=false
|
||||
if store_present "$settings"; then
|
||||
validate_store "$settings" "The current settings file"
|
||||
desktop_present=true
|
||||
fi
|
||||
if [[ $# -ge 2 ]]; then
|
||||
write_live_home "$2"
|
||||
fi
|
||||
if store_present "$home"; then
|
||||
validate_home_store "$home" "The current Home state file"
|
||||
home_present=true
|
||||
fi
|
||||
[[ "$desktop_present" == true || "$home_present" == true ]] \
|
||||
|| fail "No Panama settings exist to back up."
|
||||
|
||||
write_snapshot
|
||||
printf '{"saved":"settings-%s.json"}\n' "$snapshot_stamp"
|
||||
;;
|
||||
|
||||
list)
|
||||
mkdir -p "$backup_dir"
|
||||
first=true
|
||||
printf '['
|
||||
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
|
||||
name="$(basename "$file")"
|
||||
# settings-20260818-004512.json -> 2026-08-18 00:45
|
||||
raw="${name#settings-}"; raw="${raw%.json}"
|
||||
while IFS= read -r -d '' entry; do
|
||||
file="${entry#* }"
|
||||
name="$(basename -- "$file")"
|
||||
raw="${name#settings-}"
|
||||
raw="${raw%.json}"
|
||||
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
|
||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
||||
if jq -e '.version == 2' "$file" >/dev/null 2>&1; then
|
||||
keys="$(jq -r 'if .desktop.present then (.desktop.data | keys | length) else 0 end' "$file" 2>/dev/null || printf 0)"
|
||||
else
|
||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
||||
fi
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
||||
done
|
||||
done < <(
|
||||
find "$backup_dir" -maxdepth 1 -type f \
|
||||
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
|
||||
-printf '%T@ %p\0' | sort -zrn
|
||||
)
|
||||
printf ']\n'
|
||||
;;
|
||||
|
||||
restore)
|
||||
name="${2:-}"
|
||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
||||
# Only a bare filename from the backup directory, so a caller cannot
|
||||
# walk out of it with a path.
|
||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name."
|
||||
source_file="$backup_dir/$name"
|
||||
[[ -r "$source_file" ]] || fail "That snapshot is missing."
|
||||
jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON."
|
||||
source_file="$(snapshot_source "$name")"
|
||||
validate_snapshot "$source_file"
|
||||
|
||||
# Snapshot what is being replaced, so restore is itself undoable.
|
||||
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
|
||||
mkdir -p "$backup_dir"
|
||||
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
|
||||
desktop_present=true
|
||||
home_action="preserve"
|
||||
if [[ "$snapshot_format" == "versioned" ]]; then
|
||||
desktop_present="$(jq -r '.desktop.present' "$source_file")"
|
||||
home_action="$(jq -r 'if .home.present then "present" else "absent" end' "$source_file")"
|
||||
if [[ "$desktop_present" == true ]]; then
|
||||
desktop_stage="$(stage_json "$source_file" '.desktop.data' "$(dirname "$settings")" '.settings-restore')"
|
||||
else
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
desktop_stage=""
|
||||
fi
|
||||
if [[ "$home_action" == "present" ]]; then
|
||||
home_stage="$(stage_json "$source_file" '.home.data' "$(dirname "$home")" '.home-restore')"
|
||||
else
|
||||
mkdir -p "$(dirname "$home")"
|
||||
home_stage=""
|
||||
fi
|
||||
else
|
||||
desktop_stage="$(stage_json "$source_file" '.' "$(dirname "$settings")" '.settings-restore')"
|
||||
mkdir -p "$(dirname "$home")"
|
||||
home_stage=""
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
cp "$source_file" "$settings.tmp"
|
||||
mv "$settings.tmp" "$settings"
|
||||
printf '{"restored":"%s"}\n' "$name"
|
||||
# Preserve the replaced state as an undo snapshot only when every
|
||||
# existing store is valid. A corrupt store must not prevent recovery,
|
||||
# but it is not useful as a future restore point either.
|
||||
if { ! store_present "$settings" || store_valid "$settings"; } \
|
||||
&& { ! store_present "$home" || home_store_valid "$home"; }; then
|
||||
write_snapshot >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
settings_dir="$(dirname "$settings")"
|
||||
home_dir="$(dirname "$home")"
|
||||
settings_rollback="$(backup_current_file "$settings" "$settings_dir" '.settings-rollback')"
|
||||
home_rollback="$(backup_current_file "$home" "$home_dir" '.home-rollback')"
|
||||
rollback_needed=true
|
||||
|
||||
rollback() {
|
||||
if [[ "$settings_rollback" != "" ]]; then
|
||||
mv -f -- "$settings_rollback" "$settings"
|
||||
else
|
||||
rm -f -- "$settings"
|
||||
fi
|
||||
if [[ "$home_action" != "preserve" ]]; then
|
||||
if [[ "$home_rollback" != "" ]]; then
|
||||
mv -f -- "$home_rollback" "$home"
|
||||
else
|
||||
rm -f -- "$home"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_restore() {
|
||||
local status=$?
|
||||
if [[ "$rollback_needed" == true ]]; then
|
||||
rollback || true
|
||||
fi
|
||||
[[ "${desktop_stage:-}" == "" ]] || rm -f -- "$desktop_stage"
|
||||
[[ "${home_stage:-}" == "" ]] || rm -f -- "$home_stage"
|
||||
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
|
||||
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_restore EXIT
|
||||
|
||||
restore_target "$settings" "$desktop_present" "$desktop_stage"
|
||||
if [[ "$home_action" != "preserve" ]]; then
|
||||
restore_target "$home" "$([[ "$home_action" == "present" ]] && printf true || printf false)" "$home_stage"
|
||||
fi
|
||||
|
||||
rollback_needed=false
|
||||
trap - EXIT
|
||||
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
|
||||
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
|
||||
if [[ "$home_action" == "present" ]]; then
|
||||
jq -cn --arg restored "$name" --slurpfile home "$home" \
|
||||
'{restored: $restored, home: {present: true, data: $home[0]}}'
|
||||
elif [[ "$home_action" == "absent" ]]; then
|
||||
jq -cn --arg restored "$name" \
|
||||
'{restored: $restored, home: {present: false}}'
|
||||
else
|
||||
jq -cn --arg restored "$name" \
|
||||
'{restored: $restored, home: {preserve: true}}'
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
+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