Make settings restore crash-safe
This commit is contained in:
@@ -1,391 +1,625 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Versioned snapshots of Panama's durable settings stores.
|
||||
#
|
||||
# New snapshots are a small envelope which records both data and absence for
|
||||
# the schema store and Home favourites. Settings-only snapshots written by an
|
||||
# older Panama remain restorable; because they carry no Home metadata, they
|
||||
# deliberately leave current Home state alone.
|
||||
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
|
||||
|
||||
set -euo pipefail
|
||||
A restore is a two-file transaction. Its fixed journal and artifacts live at
|
||||
`$XDG_STATE_HOME/panama/transactions/settings-restore`; they contain no
|
||||
caller-provided paths. The journal is fsynced before either destination changes
|
||||
and is removed only after both replacements are durable. Every invocation
|
||||
recovers an incomplete transaction before doing any other work.
|
||||
"""
|
||||
|
||||
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))'
|
||||
from __future__ import annotations
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import fcntl
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, NoReturn
|
||||
|
||||
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 = Path(os.environ.get("HOME", str(Path.home())))
|
||||
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
|
||||
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
|
||||
SETTINGS = CONFIG_ROOT / "panama/settings.json"
|
||||
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
|
||||
BACKUP_DIR = STATE_ROOT / "panama/backups"
|
||||
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
|
||||
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
|
||||
JOURNAL = TRANSACTION_DIR / "journal.json"
|
||||
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
|
||||
KEEP = 15
|
||||
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
|
||||
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
|
||||
|
||||
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."
|
||||
}
|
||||
class BackupError(RuntimeError):
|
||||
pass
|
||||
|
||||
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
|
||||
def fail(message: str) -> NoReturn:
|
||||
raise BackupError(message)
|
||||
|
||||
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
|
||||
}
|
||||
def fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
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
|
||||
|
||||
def ensure_directory(path: Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
fail(f"{path} is not a safe directory.")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def process_lock() -> Iterator[None]:
|
||||
ensure_directory(TRANSACTION_PARENT)
|
||||
if LOCK_FILE.is_symlink():
|
||||
fail("The settings transaction lock is a symbolic link.")
|
||||
descriptor = os.open(
|
||||
LOCK_FILE,
|
||||
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
for ((index = keep; index < ${#entries[@]}; index++)); do
|
||||
path="${entries[$index]#* }"
|
||||
rm -f -- "$path"
|
||||
done
|
||||
}
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
# 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
|
||||
def is_present(path: Path) -> bool:
|
||||
return path.exists() or path.is_symlink()
|
||||
|
||||
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
|
||||
def require_regular(path: Path, label: str) -> None:
|
||||
if path.is_symlink():
|
||||
fail(f"{label} is a symbolic link and cannot be used safely.")
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except FileNotFoundError:
|
||||
fail(f"{label} is missing.")
|
||||
if not stat.S_ISREG(mode):
|
||||
fail(f"{label} is not a regular file.")
|
||||
|
||||
mv -- "$temp" "$snapshot_path"
|
||||
prune_snapshots
|
||||
}
|
||||
|
||||
snapshot_source() {
|
||||
local name="$1"
|
||||
local candidate="$backup_dir/$name"
|
||||
local canonical_dir
|
||||
local canonical_file
|
||||
def read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
require_regular(path, label)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise BackupError(f"{label} is not valid JSON.") from error
|
||||
if not isinstance(value, dict):
|
||||
fail(f"{label} is not a JSON object.")
|
||||
return value
|
||||
|
||||
[[ "$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."
|
||||
def valid_home(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
initialized = value.get("initialized")
|
||||
favorites = value.get("favorites")
|
||||
if not isinstance(initialized, bool) or not isinstance(favorites, list):
|
||||
return False
|
||||
if not initialized and favorites:
|
||||
return False
|
||||
seen: set[str] = set()
|
||||
for favorite in favorites:
|
||||
if not isinstance(favorite, dict):
|
||||
return False
|
||||
entity_id = favorite.get("id")
|
||||
alias = favorite.get("alias")
|
||||
if (
|
||||
not isinstance(entity_id, str)
|
||||
or ENTITY_RE.fullmatch(entity_id) is None
|
||||
or not isinstance(alias, str)
|
||||
or entity_id in seen
|
||||
):
|
||||
return False
|
||||
seen.add(entity_id)
|
||||
return True
|
||||
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
validate_snapshot() {
|
||||
local source_file="$1"
|
||||
def validate_home(value: Any, label: str) -> dict[str, Any]:
|
||||
if not valid_home(value):
|
||||
fail(f"{label} does not contain valid Home favourites.")
|
||||
return value
|
||||
|
||||
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
|
||||
}
|
||||
def json_bytes(value: Any) -> bytes:
|
||||
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
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"
|
||||
}
|
||||
def atomic_write_bytes(path: Path, content: bytes) -> None:
|
||||
ensure_directory(path.parent)
|
||||
if path.is_symlink():
|
||||
fail(f"{path} is a symbolic link and cannot be replaced safely.")
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
fsync_directory(path.parent)
|
||||
finally:
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
|
||||
backup_current_file() {
|
||||
local target="$1"
|
||||
local directory="$2"
|
||||
local template="$3"
|
||||
|
||||
if ! store_present "$target"; then
|
||||
printf '\n'
|
||||
def atomic_write_json(path: Path, value: Any) -> None:
|
||||
atomic_write_bytes(path, json_bytes(value))
|
||||
|
||||
|
||||
def durable_remove(path: Path) -> None:
|
||||
if path.exists() or path.is_symlink():
|
||||
path.unlink()
|
||||
fsync_directory(path.parent)
|
||||
|
||||
|
||||
def transaction_path(name: str) -> Path:
|
||||
if name not in {
|
||||
"journal.json",
|
||||
"desktop.old",
|
||||
"desktop.new",
|
||||
"home.old",
|
||||
"home.new",
|
||||
}:
|
||||
fail("The restore transaction contains an unknown artifact name.")
|
||||
path = TRANSACTION_DIR / name
|
||||
resolved_parent = path.parent.resolve(strict=False)
|
||||
if resolved_parent != TRANSACTION_DIR.resolve(strict=False):
|
||||
fail("The restore transaction escaped its contained state directory.")
|
||||
return path
|
||||
|
||||
|
||||
def clean_transaction_artifacts() -> None:
|
||||
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
|
||||
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"
|
||||
}
|
||||
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
|
||||
fail("The restore transaction path is not a safe directory.")
|
||||
for child in list(TRANSACTION_DIR.iterdir()):
|
||||
if child.name not in {
|
||||
"journal.json",
|
||||
"desktop.old",
|
||||
"desktop.new",
|
||||
"home.old",
|
||||
"home.new",
|
||||
} and not child.name.startswith(".journal.json."):
|
||||
fail("The restore transaction directory contains an unknown artifact.")
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
fail("The restore transaction contains an unexpected directory.")
|
||||
child.unlink()
|
||||
fsync_directory(TRANSACTION_DIR)
|
||||
TRANSACTION_DIR.rmdir()
|
||||
fsync_directory(TRANSACTION_PARENT)
|
||||
|
||||
restore_target() {
|
||||
local target="$1"
|
||||
local present="$2"
|
||||
local staged="$3"
|
||||
|
||||
if [[ "$present" == true ]]; then
|
||||
mv -- "$staged" "$target"
|
||||
else
|
||||
rm -f -- "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
save)
|
||||
desktop_present=false
|
||||
home_present=false
|
||||
if store_present "$settings"; then
|
||||
validate_store "$settings" "The current settings file"
|
||||
desktop_present=true
|
||||
fi
|
||||
if [[ $# -ge 2 ]]; then
|
||||
write_live_home "$2"
|
||||
fi
|
||||
if store_present "$home"; then
|
||||
validate_home_store "$home" "The current Home state file"
|
||||
home_present=true
|
||||
fi
|
||||
[[ "$desktop_present" == true || "$home_present" == true ]] \
|
||||
|| fail "No Panama settings exist to back up."
|
||||
|
||||
write_snapshot
|
||||
printf '{"saved":"settings-%s.json"}\n' "$snapshot_stamp"
|
||||
;;
|
||||
|
||||
list)
|
||||
mkdir -p "$backup_dir"
|
||||
first=true
|
||||
printf '['
|
||||
while IFS= read -r -d '' entry; do
|
||||
file="${entry#* }"
|
||||
name="$(basename -- "$file")"
|
||||
raw="${name#settings-}"
|
||||
raw="${raw%.json}"
|
||||
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
|
||||
if jq -e '.version == 2' "$file" >/dev/null 2>&1; then
|
||||
keys="$(jq -r 'if .desktop.present then (.desktop.data | keys | length) else 0 end' "$file" 2>/dev/null || printf 0)"
|
||||
else
|
||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
||||
fi
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
||||
done < <(
|
||||
find "$backup_dir" -maxdepth 1 -type f \
|
||||
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
|
||||
-printf '%T@ %p\0' | sort -zrn
|
||||
def clean_stale_atomic_files() -> None:
|
||||
locations = (
|
||||
(SETTINGS.parent, (".settings.json.",)),
|
||||
(HOME_STATE.parent, (".panama-home.json.",)),
|
||||
(BACKUP_DIR, (".settings-",)),
|
||||
)
|
||||
printf ']\n'
|
||||
;;
|
||||
for directory, prefixes in locations:
|
||||
if not directory.exists():
|
||||
continue
|
||||
if directory.is_symlink() or not directory.is_dir():
|
||||
fail(f"{directory} is not a safe directory.")
|
||||
changed = False
|
||||
for child in directory.iterdir():
|
||||
if not any(child.name.startswith(prefix) for prefix in prefixes):
|
||||
continue
|
||||
# Only Panama's hidden atomic-write names are eligible. A matching
|
||||
# directory is unexpected and is never recursively removed.
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
fail("A stale settings temporary path is an unexpected directory.")
|
||||
child.unlink()
|
||||
changed = True
|
||||
if changed:
|
||||
fsync_directory(directory)
|
||||
|
||||
restore)
|
||||
name="${2:-}"
|
||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
||||
source_file="$(snapshot_source "$name")"
|
||||
validate_snapshot "$source_file"
|
||||
|
||||
desktop_present=true
|
||||
home_action="preserve"
|
||||
if [[ "$snapshot_format" == "versioned" ]]; then
|
||||
desktop_present="$(jq -r '.desktop.present' "$source_file")"
|
||||
home_action="$(jq -r 'if .home.present then "present" else "absent" end' "$source_file")"
|
||||
if [[ "$desktop_present" == true ]]; then
|
||||
desktop_stage="$(stage_json "$source_file" '.desktop.data' "$(dirname "$settings")" '.settings-restore')"
|
||||
else
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
desktop_stage=""
|
||||
fi
|
||||
if [[ "$home_action" == "present" ]]; then
|
||||
home_stage="$(stage_json "$source_file" '.home.data' "$(dirname "$home")" '.home-restore')"
|
||||
else
|
||||
mkdir -p "$(dirname "$home")"
|
||||
home_stage=""
|
||||
fi
|
||||
else
|
||||
desktop_stage="$(stage_json "$source_file" '.' "$(dirname "$settings")" '.settings-restore')"
|
||||
mkdir -p "$(dirname "$home")"
|
||||
home_stage=""
|
||||
fi
|
||||
def validate_journal_side(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict):
|
||||
fail("The restore journal is malformed.")
|
||||
if set(value) != {"touch", "oldPresent", "newPresent"}:
|
||||
fail("The restore journal is malformed.")
|
||||
if not all(isinstance(value[key], bool) for key in value):
|
||||
fail("The restore journal is malformed.")
|
||||
return value
|
||||
|
||||
# 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
|
||||
def read_journal() -> dict[str, Any]:
|
||||
value = read_json(JOURNAL, "The restore journal")
|
||||
if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
|
||||
fail("The restore journal uses an unsupported format.")
|
||||
return {
|
||||
"version": 1,
|
||||
"desktop": validate_journal_side(value.get("desktop")),
|
||||
"home": validate_journal_side(value.get("home")),
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
def target_for(store: str) -> Path:
|
||||
if store == "desktop":
|
||||
return SETTINGS
|
||||
if store == "home":
|
||||
return HOME_STATE
|
||||
fail("The restore journal names an unknown store.")
|
||||
|
||||
|
||||
def apply_artifact(store: str, generation: str, present: bool) -> None:
|
||||
target = target_for(store)
|
||||
if present:
|
||||
artifact = transaction_path(f"{store}.{generation}")
|
||||
require_regular(artifact, "A restore transaction artifact")
|
||||
atomic_write_bytes(target, artifact.read_bytes())
|
||||
else:
|
||||
ensure_directory(target.parent)
|
||||
if target.is_symlink():
|
||||
fail(f"{target} is a symbolic link and cannot be replaced safely.")
|
||||
durable_remove(target)
|
||||
|
||||
|
||||
def recover_transaction() -> None:
|
||||
ensure_directory(TRANSACTION_PARENT)
|
||||
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
|
||||
return
|
||||
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
|
||||
fail("The restore transaction path is not a safe directory.")
|
||||
if not JOURNAL.exists() and not JOURNAL.is_symlink():
|
||||
clean_transaction_artifacts()
|
||||
return
|
||||
|
||||
journal = read_journal()
|
||||
for store in ("desktop", "home"):
|
||||
side = journal[store]
|
||||
if side["touch"]:
|
||||
apply_artifact(store, "old", side["oldPresent"])
|
||||
|
||||
# Journal absence is the durable commit marker for recovery too. If a
|
||||
# second power loss occurs above, the journal remains and recovery retries.
|
||||
durable_remove(JOURNAL)
|
||||
clean_transaction_artifacts()
|
||||
|
||||
|
||||
def is_v2_side(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and isinstance(value.get("present"), bool)
|
||||
and (not value["present"] or isinstance(value.get("data"), dict))
|
||||
)
|
||||
|
||||
|
||||
def is_v2_envelope(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and value.get("version") == 2
|
||||
and is_v2_side(value.get("desktop"))
|
||||
and is_v2_side(value.get("home"))
|
||||
)
|
||||
|
||||
|
||||
def validate_snapshot(value: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
if not is_v2_envelope(value):
|
||||
return "legacy", value
|
||||
if value["home"]["present"]:
|
||||
validate_home(value["home"]["data"], "That snapshot")
|
||||
return "versioned", value
|
||||
|
||||
|
||||
def current_store(path: Path, label: str, *, home_store: bool = False) -> tuple[bool, Any]:
|
||||
if not is_present(path):
|
||||
return False, None
|
||||
value = read_json(path, label)
|
||||
if home_store:
|
||||
validate_home(value, label)
|
||||
return True, value
|
||||
|
||||
|
||||
def next_snapshot_path() -> Path:
|
||||
ensure_directory(BACKUP_DIR)
|
||||
while True:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S%f")[:18]
|
||||
candidate = BACKUP_DIR / f"settings-{stamp}.json"
|
||||
if not is_present(candidate):
|
||||
return candidate
|
||||
time.sleep(0.002)
|
||||
|
||||
|
||||
def prune_snapshots() -> None:
|
||||
snapshots = sorted(
|
||||
(
|
||||
path
|
||||
for path in BACKUP_DIR.iterdir()
|
||||
if SNAPSHOT_RE.fullmatch(path.name)
|
||||
and path.is_file()
|
||||
and not path.is_symlink()
|
||||
),
|
||||
key=lambda path: path.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
for old in snapshots[KEEP:]:
|
||||
durable_remove(old)
|
||||
|
||||
|
||||
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
|
||||
try:
|
||||
desktop_present, desktop = current_store(
|
||||
SETTINGS, "The current settings file"
|
||||
)
|
||||
home_present, home = current_store(
|
||||
HOME_STATE, "The current Home state file", home_store=True
|
||||
)
|
||||
except BackupError:
|
||||
if validate:
|
||||
raise
|
||||
return None
|
||||
|
||||
if not desktop_present and not home_present:
|
||||
if require_any:
|
||||
fail("No Panama settings exist to back up.")
|
||||
return None
|
||||
|
||||
envelope: dict[str, Any] = {
|
||||
"version": 2,
|
||||
"desktop": {"present": desktop_present},
|
||||
"home": {"present": home_present},
|
||||
}
|
||||
trap cleanup_restore EXIT
|
||||
if desktop_present:
|
||||
envelope["desktop"]["data"] = desktop
|
||||
if home_present:
|
||||
envelope["home"]["data"] = home
|
||||
|
||||
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
|
||||
destination = next_snapshot_path()
|
||||
atomic_write_json(destination, envelope)
|
||||
prune_snapshots()
|
||||
return destination
|
||||
|
||||
rollback_needed=false
|
||||
trap - EXIT
|
||||
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
|
||||
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
|
||||
if [[ "$home_action" == "present" ]]; then
|
||||
jq -cn --arg restored "$name" --slurpfile home "$home" \
|
||||
'{restored: $restored, home: {present: true, data: $home[0]}}'
|
||||
elif [[ "$home_action" == "absent" ]]; then
|
||||
jq -cn --arg restored "$name" \
|
||||
'{restored: $restored, home: {present: false}}'
|
||||
else
|
||||
jq -cn --arg restored "$name" \
|
||||
'{restored: $restored, home: {preserve: true}}'
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "usage: panama-settings-backup [save|list|restore <name>]"
|
||||
;;
|
||||
esac
|
||||
def snapshot_source(name: str) -> Path:
|
||||
if SNAPSHOT_RE.fullmatch(name) is None:
|
||||
fail("Not a snapshot name.")
|
||||
ensure_directory(BACKUP_DIR)
|
||||
candidate = BACKUP_DIR / name
|
||||
require_regular(candidate, "That snapshot")
|
||||
if candidate.resolve(strict=True).parent != BACKUP_DIR.resolve(strict=True):
|
||||
fail("That snapshot is outside the backup directory.")
|
||||
return candidate
|
||||
|
||||
|
||||
def stage_artifact(name: str, content: bytes) -> None:
|
||||
path = transaction_path(name)
|
||||
if path.exists() or path.is_symlink():
|
||||
fail("A stale restore transaction artifact was not recovered.")
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
finally:
|
||||
# fdopen owns the descriptor after construction.
|
||||
pass
|
||||
fsync_directory(TRANSACTION_DIR)
|
||||
|
||||
|
||||
def capture_old(store: str, target: Path) -> bool:
|
||||
if not is_present(target):
|
||||
return False
|
||||
require_regular(target, f"The current {store} settings file")
|
||||
stage_artifact(f"{store}.old", target.read_bytes())
|
||||
return True
|
||||
|
||||
|
||||
def prepare_transaction(
|
||||
desktop_present: bool,
|
||||
desktop_data: dict[str, Any] | None,
|
||||
home_touch: bool,
|
||||
home_present: bool,
|
||||
home_data: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
# Cleanup is active before the first artifact is created. A pre-journal
|
||||
# error removes every staged/rollback file; a process death is recovered as
|
||||
# stale preparation by the next invocation.
|
||||
ensure_directory(TRANSACTION_PARENT)
|
||||
clean_transaction_artifacts()
|
||||
ensure_directory(TRANSACTION_DIR)
|
||||
fsync_directory(TRANSACTION_PARENT)
|
||||
try:
|
||||
desktop_old = capture_old("desktop", SETTINGS)
|
||||
if desktop_present:
|
||||
stage_artifact("desktop.new", json_bytes(desktop_data))
|
||||
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_FAIL") == "after-desktop-stage":
|
||||
fail("Injected failure after desktop staging.")
|
||||
|
||||
home_old = capture_old("home", HOME_STATE) if home_touch else False
|
||||
if home_touch and home_present:
|
||||
stage_artifact("home.new", json_bytes(home_data))
|
||||
|
||||
journal = {
|
||||
"version": 1,
|
||||
"desktop": {
|
||||
"touch": True,
|
||||
"oldPresent": desktop_old,
|
||||
"newPresent": desktop_present,
|
||||
},
|
||||
"home": {
|
||||
"touch": home_touch,
|
||||
"oldPresent": home_old,
|
||||
"newPresent": home_present,
|
||||
},
|
||||
}
|
||||
atomic_write_json(JOURNAL, journal)
|
||||
return journal
|
||||
except BaseException:
|
||||
# SIGKILL/os._exit bypass this block by design; the next invocation
|
||||
# cleans a pre-journal directory or recovers a journalled transaction.
|
||||
if not JOURNAL.exists() and not JOURNAL.is_symlink():
|
||||
clean_transaction_artifacts()
|
||||
raise
|
||||
|
||||
|
||||
def commit_restore(journal: dict[str, Any]) -> None:
|
||||
try:
|
||||
desktop = journal["desktop"]
|
||||
apply_artifact("desktop", "new", desktop["newPresent"])
|
||||
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_CRASH") == "after-desktop":
|
||||
os._exit(86)
|
||||
|
||||
home = journal["home"]
|
||||
if home["touch"]:
|
||||
apply_artifact("home", "new", home["newPresent"])
|
||||
|
||||
# Both targets and their parent directories are durable. Removing and
|
||||
# fsyncing the journal is the transaction's commit record.
|
||||
durable_remove(JOURNAL)
|
||||
clean_transaction_artifacts()
|
||||
except BaseException:
|
||||
# Ordinary failures roll back immediately. Process death leaves the
|
||||
# journal in place and takes this same path on the next invocation.
|
||||
recover_transaction()
|
||||
raise
|
||||
|
||||
|
||||
def write_live_home(text: str) -> None:
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except json.JSONDecodeError as error:
|
||||
raise BackupError("The live Home state is not valid JSON.") from error
|
||||
validate_home(value, "The live Home state")
|
||||
atomic_write_json(HOME_STATE, value)
|
||||
|
||||
|
||||
def command_save(arguments: list[str]) -> None:
|
||||
if arguments:
|
||||
write_live_home(arguments[0])
|
||||
destination = save_snapshot(require_any=True, validate=True)
|
||||
assert destination is not None
|
||||
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
|
||||
|
||||
|
||||
def snapshot_files() -> list[Path]:
|
||||
ensure_directory(BACKUP_DIR)
|
||||
return sorted(
|
||||
(
|
||||
path
|
||||
for path in BACKUP_DIR.iterdir()
|
||||
if SNAPSHOT_RE.fullmatch(path.name)
|
||||
and path.is_file()
|
||||
and not path.is_symlink()
|
||||
),
|
||||
key=lambda path: path.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def command_list() -> None:
|
||||
output: list[dict[str, Any]] = []
|
||||
for path in snapshot_files():
|
||||
try:
|
||||
value = read_json(path, "A snapshot")
|
||||
if is_v2_envelope(value):
|
||||
desktop = value["desktop"]
|
||||
keys = len(desktop["data"]) if desktop["present"] else 0
|
||||
else:
|
||||
keys = len(value)
|
||||
except BackupError:
|
||||
keys = 0
|
||||
raw = path.name.removeprefix("settings-").removesuffix(".json")
|
||||
pretty = (
|
||||
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
|
||||
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
|
||||
)
|
||||
output.append({"name": path.name, "when": pretty, "keys": keys})
|
||||
print(json.dumps(output, separators=(",", ":")))
|
||||
|
||||
|
||||
def command_restore(arguments: list[str]) -> None:
|
||||
if not arguments:
|
||||
fail("Which snapshot?")
|
||||
name = arguments[0]
|
||||
source = snapshot_source(name)
|
||||
snapshot = read_json(source, "That snapshot")
|
||||
snapshot_format, value = validate_snapshot(snapshot)
|
||||
|
||||
if snapshot_format == "versioned":
|
||||
desktop_present = value["desktop"]["present"]
|
||||
desktop_data = value["desktop"].get("data")
|
||||
home_touch = True
|
||||
home_present = value["home"]["present"]
|
||||
home_data = value["home"].get("data")
|
||||
else:
|
||||
desktop_present = True
|
||||
desktop_data = value
|
||||
home_touch = False
|
||||
home_present = False
|
||||
home_data = None
|
||||
|
||||
# Restoring remains undoable, but a corrupt current file must not prevent a
|
||||
# known-good snapshot from recovering the desktop.
|
||||
save_snapshot(require_any=False, validate=False)
|
||||
journal = prepare_transaction(
|
||||
desktop_present,
|
||||
desktop_data,
|
||||
home_touch,
|
||||
home_present,
|
||||
home_data,
|
||||
)
|
||||
commit_restore(journal)
|
||||
|
||||
if not home_touch:
|
||||
home_result: dict[str, Any] = {"preserve": True}
|
||||
elif home_present:
|
||||
home_result = {"present": True, "data": home_data}
|
||||
else:
|
||||
home_result = {"present": False}
|
||||
print(
|
||||
json.dumps(
|
||||
{"restored": name, "home": home_result},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with process_lock():
|
||||
clean_stale_atomic_files()
|
||||
recover_transaction()
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||
arguments = sys.argv[2:]
|
||||
if command == "save":
|
||||
command_save(arguments)
|
||||
elif command == "list":
|
||||
command_list()
|
||||
elif command == "restore":
|
||||
command_restore(arguments)
|
||||
else:
|
||||
fail("usage: panama-settings-backup [save|list|restore <name>]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except BackupError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
except OSError as error:
|
||||
print("The settings backup could not access its state files.", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
|
||||
@@ -25,6 +25,29 @@ Singleton {
|
||||
property string lastError: ""
|
||||
property string lastAction: ""
|
||||
|
||||
// Narrow service boundaries keep restore sequencing explicit and make it
|
||||
// possible to verify the real handler in an isolated shell without ever
|
||||
// calling the daily-driver compositor or wallpaper services.
|
||||
property var readHomeState: function() {
|
||||
return {
|
||||
initialized: HomePreferences.initialized,
|
||||
favorites: HomePreferences.favorites
|
||||
};
|
||||
}
|
||||
property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
|
||||
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
|
||||
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
|
||||
property var reloadDesktop: function() { DesktopPreferences.reload(); }
|
||||
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||
property var systemBusy: function() { return SystemSettings.busy; }
|
||||
property var currentWallpaper: function() {
|
||||
return String(DesktopPreferences.get("wallpaperPath") ?? "");
|
||||
}
|
||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||
property var reloadShell: function() { Quickshell.reload(false); }
|
||||
|
||||
readonly property bool busy: listQuery.running || actionRun.running
|
||||
|| applyRestoredState.running || settleReload.running
|
||||
|
||||
@@ -62,12 +85,10 @@ Singleton {
|
||||
}
|
||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||
if (actionRun.restoring) {
|
||||
const homeReloaded = root.reloadHomeState(actionRun.outputText);
|
||||
const homeReloaded = root.handleRestoreOutput(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();
|
||||
@@ -82,9 +103,9 @@ Singleton {
|
||||
// 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") ?? ""));
|
||||
root.applyCompositor();
|
||||
root.reloadKeybinds();
|
||||
root.applyWallpaper(root.currentWallpaper());
|
||||
|
||||
settleReload.attempts = 0;
|
||||
settleReload.restart();
|
||||
@@ -101,9 +122,9 @@ Singleton {
|
||||
// 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) {
|
||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||
stop();
|
||||
Quickshell.reload(false);
|
||||
root.reloadShell();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,19 +144,28 @@ Singleton {
|
||||
}
|
||||
|
||||
function serialiseHomeState(): string {
|
||||
const current = root.readHomeState();
|
||||
const favorites = [];
|
||||
for (const favorite of HomePreferences.favorites ?? []) {
|
||||
for (const favorite of current.favorites ?? []) {
|
||||
favorites.push({
|
||||
id: String(favorite.id ?? ""),
|
||||
alias: String(favorite.alias ?? "")
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
initialized: HomePreferences.initialized,
|
||||
initialized: current.initialized === true,
|
||||
favorites: favorites
|
||||
});
|
||||
}
|
||||
|
||||
function handleRestoreOutput(text: string): bool {
|
||||
if (!root.reloadHomeState(text))
|
||||
return false;
|
||||
root.reloadDesktop();
|
||||
applyRestoredState.restart();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -170,12 +200,12 @@ Singleton {
|
||||
if (!data.initialized && ids.length > 0)
|
||||
return false;
|
||||
|
||||
HomePreferences.resetHomeDefaults();
|
||||
root.resetHome();
|
||||
if (!data.initialized)
|
||||
return true;
|
||||
HomePreferences.initialize(ids);
|
||||
root.initializeHome(ids);
|
||||
for (let index = 0; index < ids.length; index++)
|
||||
HomePreferences.setAlias(ids[index], aliases[index]);
|
||||
root.aliasHome(ids[index], aliases[index]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -183,7 +213,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function resetHomeState(): bool {
|
||||
HomePreferences.resetHomeDefaults();
|
||||
root.resetHome();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Isolated behavioral harness for SettingsBackup's live restore handoff.
|
||||
// Every external consumer is replaced before restore output is exercised, so
|
||||
// this file never writes the real compositor, wallpaper, keymap, or shell.
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property var calls: []
|
||||
property bool homeInitialized: false
|
||||
property var homeFavorites: []
|
||||
|
||||
function record(name: string): void {
|
||||
const next = root.calls.slice();
|
||||
next.push(name);
|
||||
root.calls = next;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
SettingsBackup.readHomeState = function() {
|
||||
return {
|
||||
initialized: root.homeInitialized,
|
||||
favorites: root.homeFavorites
|
||||
};
|
||||
};
|
||||
SettingsBackup.resetHome = function() {
|
||||
root.record("home.reset");
|
||||
root.homeInitialized = false;
|
||||
root.homeFavorites = [];
|
||||
};
|
||||
SettingsBackup.initializeHome = function(ids) {
|
||||
root.record("home.initialize:" + ids.join(","));
|
||||
root.homeInitialized = true;
|
||||
root.homeFavorites = ids.map(id => ({ id: id, alias: "" }));
|
||||
};
|
||||
SettingsBackup.aliasHome = function(id, alias) {
|
||||
root.record("home.alias:" + id + "=" + alias);
|
||||
root.homeFavorites = root.homeFavorites.map(favorite =>
|
||||
favorite.id === id ? { id: id, alias: alias } : favorite);
|
||||
};
|
||||
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
|
||||
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
||||
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||
SettingsBackup.keybindsReloading = function() { return false; };
|
||||
SettingsBackup.systemBusy = function() { return false; };
|
||||
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
|
||||
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
|
||||
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "settings-backup-behavior"
|
||||
|
||||
function reset(): void {
|
||||
root.calls = [];
|
||||
root.homeInitialized = false;
|
||||
root.homeFavorites = [];
|
||||
}
|
||||
|
||||
function apply(output: string): bool {
|
||||
return SettingsBackup.handleRestoreOutput(output);
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
calls: root.calls,
|
||||
initialized: root.homeInitialized,
|
||||
favorites: root.homeFavorites
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,24 @@ trap cleanup EXIT
|
||||
settings="$work/config/panama/settings.json"
|
||||
home="$work/state/panama/panama-home.json"
|
||||
backups="$work/state/panama/backups"
|
||||
transaction_dir="$work/state/panama/transactions/settings-restore"
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
|
||||
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
|
||||
run_with() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" env "$@"; }
|
||||
|
||||
assert_transaction_clean() {
|
||||
if [[ -d "$transaction_dir" ]] && find "$transaction_dir" -mindepth 1 -print -quit | rg -q .; then
|
||||
fail 'restore left staged, rollback, or journal files behind'
|
||||
fi
|
||||
if find "$work" -type f \( \
|
||||
-name '.settings-restore.*' -o -name '.home-restore.*' \
|
||||
-o -name '*rollback*' -o -name '.journal.json.*' \
|
||||
-o -name '.settings.json.*' -o -name '.panama-home.json.*' \
|
||||
\) -print -quit | rg -q .; then
|
||||
fail 'restore left a temporary target or journal file behind'
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Nothing to back up ───────────────────────────────────────────────────────
|
||||
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
|
||||
@@ -62,6 +77,22 @@ absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snap
|
||||
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'
|
||||
|
||||
# Desktop absence is symmetric: a Home-only snapshot removes a desktop file
|
||||
# created later and restores the Home store.
|
||||
rm -f "$settings"
|
||||
printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home"
|
||||
run save >/dev/null || fail 'save failed when desktop settings were absent'
|
||||
desktop_absent_name="$(run list | jq -r '.[0].name')"
|
||||
printf '{"gapsOut":47}' >"$settings"
|
||||
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||||
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
|
||||
[[ ! -e "$settings" ]] || fail 'restore did not preserve the snapshot’s absent desktop state'
|
||||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \
|
||||
|| fail 'Home-only snapshot did not restore Home state'
|
||||
assert_transaction_clean
|
||||
|
||||
printf '{"gapsOut":17}' >"$settings"
|
||||
|
||||
# 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.
|
||||
@@ -72,6 +103,43 @@ 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'
|
||||
|
||||
# `version` is a valid unknown desktop preference. It is only an envelope when
|
||||
# the complete v2 shape is present.
|
||||
legacy_version="settings-20000101-010203005.json"
|
||||
printf '{"version":77,"gapsOut":19}' >"$backups/$legacy_version"
|
||||
run restore "$legacy_version" >/dev/null || fail 'a legacy snapshot with an unknown version key was rejected'
|
||||
[[ "$(jq -r '.version' "$settings")" == "77" ]] || fail 'legacy version key was not restored as desktop data'
|
||||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy version key changed Home state'
|
||||
|
||||
# ── A durable journal recovers a process/power-loss split ────────────────────
|
||||
printf '{"gapsOut":28,"windowRounding":12}' >"$settings"
|
||||
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Snapshot"}]}' >"$home"
|
||||
run save >/dev/null || fail 'could not create crash-recovery snapshot'
|
||||
crash_name="$(run list | jq -r '.[0].name')"
|
||||
|
||||
printf '{"gapsOut":91,"windowRounding":3}' >"$settings"
|
||||
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Before crash"}]}' >"$home"
|
||||
run_with PANAMA_SETTINGS_BACKUP_TEST_CRASH=after-desktop "$helper" restore "$crash_name" >/dev/null 2>&1 \
|
||||
&& fail 'crash injection completed restore instead of terminating after the first replacement'
|
||||
[[ "$(jq -r '.gapsOut' "$settings")" == "28" ]] || fail 'crash did not occur after desktop replacement'
|
||||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'crash unexpectedly replaced Home state'
|
||||
[[ -f "$transaction_dir/journal.json" ]] || fail 'crash left no durable recovery journal'
|
||||
|
||||
# Every entry point must recover before doing its own work. `list` is the least
|
||||
# invasive proof and must put both stores back to the pre-restore generation.
|
||||
run list >/dev/null || fail 'next invocation could not recover the interrupted restore'
|
||||
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'recovery did not roll desktop settings back'
|
||||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'recovery did not keep Home state in the same generation'
|
||||
assert_transaction_clean
|
||||
|
||||
# Cleanup is installed before staging. A deterministic pre-journal failure
|
||||
# must leave both destinations untouched and no hidden artifacts behind.
|
||||
run_with PANAMA_SETTINGS_BACKUP_TEST_FAIL=after-desktop-stage "$helper" restore "$crash_name" >/dev/null 2>&1 \
|
||||
&& fail 'staging failure injection unexpectedly restored the snapshot'
|
||||
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'staging failure changed desktop settings'
|
||||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'staging failure changed Home state'
|
||||
assert_transaction_clean
|
||||
|
||||
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
||||
count="$(run list | jq 'length')"
|
||||
[[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)"
|
||||
@@ -81,7 +149,7 @@ 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")" == "17" ]] || fail 'a refused restore still damaged the settings file'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
|
||||
|
||||
invalid_home="settings-19990101-000000001.json"
|
||||
jq -n '{
|
||||
@@ -96,7 +164,7 @@ jq -n '{
|
||||
}}
|
||||
}' >"$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'
|
||||
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || 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'
|
||||
|
||||
@@ -1,47 +1,132 @@
|
||||
#!/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.
|
||||
# Behavioral coverage for the QML handoff after the helper commits a restore.
|
||||
# The harness has a unique shell identity, isolated XDG roots, and fake external
|
||||
# consumers. It records the real SettingsBackup call order without touching the
|
||||
# daily-driver shell, compositor, keymap, or wallpaper.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml"
|
||||
work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)"
|
||||
|
||||
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'
|
||||
qs_test() {
|
||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
# 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" \
|
||||
cleanup() {
|
||||
qs_test kill >/dev/null 2>&1 || true
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# The production command boundary must remain argv-only.
|
||||
rg -Fq 'actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);' "$service" \
|
||||
|| fail 'save does not pass live Home state as one argument'
|
||||
rg -Fq '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
|
||||
|
||||
# The harness replaces these seams, while these mappings prove the production
|
||||
# defaults still delegate to Panama's existing public service APIs.
|
||||
for mapping in \
|
||||
'HomePreferences.resetHomeDefaults();' \
|
||||
'HomePreferences.initialize(ids);' \
|
||||
'HomePreferences.setAlias(id, alias);' \
|
||||
'DesktopPreferences.reload();' \
|
||||
'SystemSettings.applyPersistedDisplayPolicy();' \
|
||||
'Keybinds.applyReload();' \
|
||||
'Wallpaper.set(path);' \
|
||||
'Quickshell.reload(false);'; do
|
||||
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
|
||||
done
|
||||
|
||||
qs_test --daemonize >"$work/quickshell.log" 2>&1
|
||||
ready=false
|
||||
for _ in $(seq 1 60); do
|
||||
if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ "$ready" != true ]]; then
|
||||
sed -n '1,200p' "$work/quickshell.log" >&2
|
||||
fail 'isolated SettingsBackup harness did not start'
|
||||
fi
|
||||
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}'
|
||||
[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \
|
||||
|| fail 'valid restore output was rejected'
|
||||
|
||||
status=""
|
||||
for _ in $(seq 1 50); do
|
||||
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.calls == [
|
||||
"home.reset",
|
||||
"home.initialize:light.desk,light.office",
|
||||
"home.alias:light.desk=Desk",
|
||||
"home.alias:light.office=Office",
|
||||
"desktop.reload",
|
||||
"system.apply",
|
||||
"keybinds.reload",
|
||||
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
||||
"shell.reload"
|
||||
]
|
||||
and .initialized == true
|
||||
and .favorites == [
|
||||
{"id":"light.desk","alias":"Desk"},
|
||||
{"id":"light.office","alias":"Office"}
|
||||
]
|
||||
' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status"
|
||||
|
||||
# Invalid output is rejected before Home state or external consumers change.
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}'
|
||||
[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \
|
||||
|| fail 'duplicate Home state was accepted'
|
||||
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \
|
||||
|| fail 'invalid restore output caused partial live mutations'
|
||||
|
||||
# An absent Home generation uses the same ordered external handoff but leaves
|
||||
# the live Home service reset rather than manufacturing an initialized store.
|
||||
qs_test ipc call settings-backup-behavior reset >/dev/null
|
||||
absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}'
|
||||
[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \
|
||||
|| fail 'absent Home restore output was rejected'
|
||||
for _ in $(seq 1 50); do
|
||||
status="$(qs_test ipc call settings-backup-behavior status)"
|
||||
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '
|
||||
.calls == [
|
||||
"home.reset",
|
||||
"desktop.reload",
|
||||
"system.apply",
|
||||
"keybinds.reload",
|
||||
"wallpaper.set:/tmp/restored-wallpaper.jpg",
|
||||
"shell.reload"
|
||||
]
|
||||
and .initialized == false
|
||||
and .favorites == []
|
||||
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'settings backup live contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user