Make settings restore crash-safe

This commit is contained in:
Gabriel Brown
2026-08-18 02:22:23 -04:00
parent 172b099a04
commit 83391e0453
5 changed files with 880 additions and 387 deletions
@@ -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
}
def clean_stale_atomic_files() -> None:
locations = (
(SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)),
)
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)
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"
;;
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
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 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")),
}
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"
)
printf ']\n'
;;
home_present, home = current_store(
HOME_STATE, "The current Home state file", home_store=True
)
except BackupError:
if validate:
raise
return None
restore)
name="${2:-}"
[[ -n "$name" ]] || fail "Which snapshot?"
source_file="$(snapshot_source "$name")"
validate_snapshot "$source_file"
if not desktop_present and not home_present:
if require_any:
fail("No Panama settings exist to back up.")
return None
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
envelope: dict[str, Any] = {
"version": 2,
"desktop": {"present": desktop_present},
"home": {"present": home_present},
}
if desktop_present:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
# 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
destination = next_snapshot_path()
atomic_write_json(destination, envelope)
prune_snapshots()
return destination
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 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
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
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)
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
;;
home = journal["home"]
if home["touch"]:
apply_artifact("home", "new", home["newPresent"])
*)
fail "usage: panama-settings-backup [save|list|restore <name>]"
;;
esac
# 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
});
}
}
}