Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83391e0453 | ||
|
|
172b099a04 |
@@ -1,90 +1,625 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
# Snapshots of the Panama settings store.
|
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
|
||||||
#
|
|
||||||
# The whole desktop configuration is one JSON file, which makes a backup a copy
|
|
||||||
# and a restore an overwrite. That is worth exposing: the settings app now
|
|
||||||
# changes real things -- compositor geometry, idle timeouts, the dock -- and
|
|
||||||
# being able to get back to a known-good state without hunting through git is
|
|
||||||
# the difference between experimenting freely and being cautious.
|
|
||||||
#
|
|
||||||
# panama-settings-backup save snapshot the current settings
|
|
||||||
# panama-settings-backup list JSON list of snapshots, newest first
|
|
||||||
# panama-settings-backup restore <name> replace settings with a snapshot
|
|
||||||
#
|
|
||||||
# Snapshots are validated as JSON on the way in and on the way out, so a
|
|
||||||
# truncated file can never be restored over a working configuration.
|
|
||||||
#
|
|
||||||
# Names carry milliseconds. At one-second resolution a save followed promptly by
|
|
||||||
# a restore produced the same filename twice, and the restore's own safety
|
|
||||||
# snapshot overwrote the very file it was about to read.
|
|
||||||
|
|
||||||
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.
|
||||||
|
"""
|
||||||
|
|
||||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
from __future__ import annotations
|
||||||
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
|
|
||||||
keep=15
|
|
||||||
|
|
||||||
fail() {
|
import json
|
||||||
printf '%s\n' "$1" >&2
|
import os
|
||||||
exit 1
|
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
|
||||||
|
|
||||||
case "${1:-list}" in
|
|
||||||
save)
|
|
||||||
[[ -r "$settings" ]] || fail "No settings file to back up."
|
|
||||||
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
|
|
||||||
mkdir -p "$backup_dir"
|
|
||||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
|
||||||
cp "$settings" "$backup_dir/settings-$stamp.json"
|
|
||||||
# Keep the most recent few. A snapshot per change would otherwise grow
|
|
||||||
# without bound in a directory nobody ever looks at.
|
|
||||||
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
|
|
||||||
rm -f "$old"
|
|
||||||
done
|
|
||||||
printf '{"saved":"settings-%s.json"}\n' "$stamp"
|
|
||||||
;;
|
|
||||||
|
|
||||||
list)
|
HOME = Path(os.environ.get("HOME", str(Path.home())))
|
||||||
mkdir -p "$backup_dir"
|
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
|
||||||
first=true
|
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
|
||||||
printf '['
|
SETTINGS = CONFIG_ROOT / "panama/settings.json"
|
||||||
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
|
HOME_STATE = STATE_ROOT / "panama/panama-home.json"
|
||||||
name="$(basename "$file")"
|
BACKUP_DIR = STATE_ROOT / "panama/backups"
|
||||||
# settings-20260818-004512.json -> 2026-08-18 00:45
|
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
|
||||||
raw="${name#settings-}"; raw="${raw%.json}"
|
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
|
||||||
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
|
JOURNAL = TRANSACTION_DIR / "journal.json"
|
||||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
|
||||||
[[ "$first" == true ]] || printf ','
|
KEEP = 15
|
||||||
first=false
|
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
|
||||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
|
||||||
done
|
|
||||||
printf ']\n'
|
|
||||||
;;
|
|
||||||
|
|
||||||
restore)
|
|
||||||
name="${2:-}"
|
|
||||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
|
||||||
# Only a bare filename from the backup directory, so a caller cannot
|
|
||||||
# walk out of it with a path.
|
|
||||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name."
|
|
||||||
source_file="$backup_dir/$name"
|
|
||||||
[[ -r "$source_file" ]] || fail "That snapshot is missing."
|
|
||||||
jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON."
|
|
||||||
|
|
||||||
# Snapshot what is being replaced, so restore is itself undoable.
|
class BackupError(RuntimeError):
|
||||||
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
|
pass
|
||||||
mkdir -p "$backup_dir"
|
|
||||||
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$settings")"
|
|
||||||
cp "$source_file" "$settings.tmp"
|
|
||||||
mv "$settings.tmp" "$settings"
|
|
||||||
printf '{"restored":"%s"}\n' "$name"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
def fail(message: str) -> NoReturn:
|
||||||
fail "usage: panama-settings-backup [save|list|restore <name>]"
|
raise BackupError(message)
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
def fsync_directory(path: Path) -> None:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def is_present(path: Path) -> bool:
|
||||||
|
return path.exists() or path.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def json_bytes(value: Any) -> bytes:
|
||||||
|
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
if desktop_present:
|
||||||
|
envelope["desktop"]["data"] = desktop
|
||||||
|
if home_present:
|
||||||
|
envelope["home"]["data"] = home
|
||||||
|
|
||||||
|
destination = next_snapshot_path()
|
||||||
|
atomic_write_json(destination, envelope)
|
||||||
|
prune_snapshots()
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// Snapshots of the settings store.
|
// Snapshots of Panama's durable settings stores.
|
||||||
//
|
//
|
||||||
// The whole desktop configuration is one JSON file, so a backup is a copy and a
|
// DesktopPreferences and HomePreferences use separate files. The helper owns
|
||||||
// restore is an overwrite. Worth exposing now that the settings app changes
|
// the transactional filesystem boundary; this service owns settling the live
|
||||||
// real things -- compositor geometry, idle timeouts, the dock -- because being
|
// desktop after those files have changed underneath it.
|
||||||
// able to return to a known-good state is what makes experimenting feel safe.
|
|
||||||
//
|
//
|
||||||
// Restoring rewrites the file underneath the running shell, so the store is
|
// HomePreferences intentionally keeps its FileView in Quickshell's private
|
||||||
// told to re-read afterwards rather than waiting for the next change.
|
// state directory while snapshots use Panama's canonical state directory. This
|
||||||
|
// service bridges them through HomePreferences' public mutation API, then soft
|
||||||
|
// reloads once external consumers have settled.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
@@ -24,7 +25,31 @@ Singleton {
|
|||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property string lastAction: ""
|
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
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|
|| applyRestoredState.running || settleReload.running
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: listQuery
|
id: listQuery
|
||||||
@@ -34,7 +59,8 @@ Singleton {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(this.text);
|
const parsed = JSON.parse(this.text);
|
||||||
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
||||||
root.lastError = "";
|
if (root.lastError === "Could not read the list of snapshots.")
|
||||||
|
root.lastError = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
root.lastError = "Could not read the list of snapshots.";
|
root.lastError = "Could not read the list of snapshots.";
|
||||||
}
|
}
|
||||||
@@ -45,6 +71,11 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: actionRun
|
id: actionRun
|
||||||
property bool restoring: false
|
property bool restoring: false
|
||||||
|
property string outputText: ""
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: actionRun.outputText = this.text
|
||||||
|
}
|
||||||
|
onStarted: actionRun.outputText = ""
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = actionRun.restoring
|
root.lastError = actionRun.restoring
|
||||||
@@ -52,14 +83,52 @@ Singleton {
|
|||||||
: "The settings could not be backed up.";
|
: "The settings could not be backed up.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
|
||||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
if (actionRun.restoring)
|
if (actionRun.restoring) {
|
||||||
DesktopPreferences.reload();
|
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
|
||||||
|
root.lastError = homeReloaded
|
||||||
|
? ""
|
||||||
|
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||||
|
} else
|
||||||
|
root.lastError = "";
|
||||||
root.refresh();
|
root.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: applyRestoredState
|
||||||
|
interval: 80
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||||
|
// These services also own state outside QML and need an explicit
|
||||||
|
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||||
|
root.applyCompositor();
|
||||||
|
root.reloadKeybinds();
|
||||||
|
root.applyWallpaper(root.currentWallpaper());
|
||||||
|
|
||||||
|
settleReload.attempts = 0;
|
||||||
|
settleReload.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleReload
|
||||||
|
property int attempts: 0
|
||||||
|
interval: 100
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
attempts++;
|
||||||
|
// Let the current instances finish their external writes before a
|
||||||
|
// soft reload replaces them. The cap keeps a failed external tool
|
||||||
|
// from leaving restored Home state stale indefinitely.
|
||||||
|
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||||
|
stop();
|
||||||
|
root.reloadShell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.refresh()
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
function refresh(): void {
|
function refresh(): void {
|
||||||
@@ -71,7 +140,81 @@ Singleton {
|
|||||||
if (actionRun.running)
|
if (actionRun.running)
|
||||||
return;
|
return;
|
||||||
actionRun.restoring = false;
|
actionRun.restoring = false;
|
||||||
actionRun.exec([root.helperPath, "save"]);
|
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseHomeState(): string {
|
||||||
|
const current = root.readHomeState();
|
||||||
|
const favorites = [];
|
||||||
|
for (const favorite of current.favorites ?? []) {
|
||||||
|
favorites.push({
|
||||||
|
id: String(favorite.id ?? ""),
|
||||||
|
alias: String(favorite.alias ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
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.
|
||||||
|
function reloadHomeState(text: string): bool {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
const restored = result?.home;
|
||||||
|
if (!restored || restored.preserve === true)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (restored.present !== true)
|
||||||
|
return restored.present === false
|
||||||
|
? root.resetHomeState()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const data = restored.data;
|
||||||
|
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
|
||||||
|
return false;
|
||||||
|
const ids = [];
|
||||||
|
const aliases = [];
|
||||||
|
const seen = {};
|
||||||
|
for (const favorite of data.favorites) {
|
||||||
|
const id = favorite?.id;
|
||||||
|
const alias = favorite?.alias;
|
||||||
|
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|
||||||
|
|| typeof alias !== "string" || seen[id])
|
||||||
|
return false;
|
||||||
|
seen[id] = true;
|
||||||
|
ids.push(id);
|
||||||
|
aliases.push(alias);
|
||||||
|
}
|
||||||
|
if (!data.initialized && ids.length > 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
root.resetHome();
|
||||||
|
if (!data.initialized)
|
||||||
|
return true;
|
||||||
|
root.initializeHome(ids);
|
||||||
|
for (let index = 0; index < ids.length; index++)
|
||||||
|
root.aliasHome(ids[index], aliases[index]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHomeState(): bool {
|
||||||
|
root.resetHome();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The name is matched against the snapshot list rather than trusted, so no
|
// The name is matched against the snapshot list rather than trusted, so no
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,26 @@ cleanup() { rm -rf "$work"; }
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
settings="$work/config/panama/settings.json"
|
settings="$work/config/panama/settings.json"
|
||||||
|
home="$work/state/panama/panama-home.json"
|
||||||
backups="$work/state/panama/backups"
|
backups="$work/state/panama/backups"
|
||||||
|
transaction_dir="$work/state/panama/transactions/settings-restore"
|
||||||
mkdir -p "$(dirname "$settings")"
|
mkdir -p "$(dirname "$settings")"
|
||||||
|
|
||||||
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
|
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 ───────────────────────────────────────────────────────
|
# ── Nothing to back up ───────────────────────────────────────────────────────
|
||||||
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
|
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
|
||||||
@@ -33,15 +49,96 @@ run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported su
|
|||||||
|
|
||||||
# ── A snapshot round-trips ───────────────────────────────────────────────────
|
# ── A snapshot round-trips ───────────────────────────────────────────────────
|
||||||
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
|
printf '{"gapsOut":24,"windowRounding":6}' >"$settings"
|
||||||
|
mkdir -p "$(dirname "$home")"
|
||||||
|
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home"
|
||||||
run save >/dev/null || fail 'save failed on a valid settings file'
|
run save >/dev/null || fail 'save failed on a valid settings file'
|
||||||
name="$(run list | jq -r '.[0].name')"
|
name="$(run list | jq -r '.[0].name')"
|
||||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
||||||
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
|
[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong'
|
||||||
|
|
||||||
printf '{"gapsOut":99}' >"$settings"
|
printf '{"gapsOut":99}' >"$settings"
|
||||||
run restore "$name" >/dev/null || fail 'restore failed'
|
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||||||
|
restore_result="$(run restore "$name")" || fail 'restore failed'
|
||||||
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
|
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
|
||||||
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
||||||
|
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favourites'
|
||||||
|
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Desk" ]] || fail 'restore lost a Home alias'
|
||||||
|
jq -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \
|
||||||
|
|| fail 'restore did not return Home state for the live service to reload'
|
||||||
|
|
||||||
|
# ── Absence is part of a snapshot ───────────────────────────────────────────
|
||||||
|
rm -f "$home"
|
||||||
|
printf '{"gapsOut":30}' >"$settings"
|
||||||
|
run save >/dev/null || fail 'save failed when Home state was absent'
|
||||||
|
absent_name="$(run list | jq -r '.[0].name')"
|
||||||
|
printf '{"initialized":true,"favorites":[{"id":"light.living_room","alias":"Living room"}]}' >"$home"
|
||||||
|
absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snapshot without Home state'
|
||||||
|
[[ ! -e "$home" ]] || fail 'restore did not preserve the snapshot’s absent Home state'
|
||||||
|
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \
|
||||||
|
|| fail 'restore did not return absent Home state for the live service to reload'
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
legacy="settings-20000101-010203004.json"
|
||||||
|
printf '{"gapsOut":17}' >"$backups/$legacy"
|
||||||
|
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Office"}]}' >"$home"
|
||||||
|
run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed'
|
||||||
|
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings'
|
||||||
|
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe'
|
||||||
|
|
||||||
|
# `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 ──────────────────
|
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
||||||
count="$(run list | jq 'length')"
|
count="$(run list | jq 'length')"
|
||||||
@@ -52,12 +149,44 @@ bad="settings-19990101-000000000.json"
|
|||||||
mkdir -p "$backups"
|
mkdir -p "$backups"
|
||||||
printf '{ truncated' >"$backups/$bad"
|
printf '{ truncated' >"$backups/$bad"
|
||||||
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
||||||
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'a refused restore still damaged the settings file'
|
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
|
||||||
|
|
||||||
|
invalid_home="settings-19990101-000000001.json"
|
||||||
|
jq -n '{
|
||||||
|
version: 2,
|
||||||
|
desktop: {present: true, data: {gapsOut: 88}},
|
||||||
|
home: {present: true, data: {
|
||||||
|
initialized: true,
|
||||||
|
favorites: [
|
||||||
|
{id: "light.desk", alias: "Desk"},
|
||||||
|
{id: "light.desk", alias: "Duplicate"}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
}' >"$backups/$invalid_home"
|
||||||
|
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored'
|
||||||
|
[[ "$(jq -r .gapsOut "$settings")" == "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'
|
||||||
|
printf '{"initialized":true,"favorites":[]}' >"$home"
|
||||||
|
|
||||||
|
# ── The live service can sync its private Home state before save ─────────────
|
||||||
|
rm -f "$home"
|
||||||
|
printf '{"gapsOut":21}' >"$settings"
|
||||||
|
live_home='{"initialized":true,"favorites":[{"id":"light.studio","alias":"Studio"}]}'
|
||||||
|
run save "$live_home" >/dev/null || fail 'save rejected valid live Home state'
|
||||||
|
live_name="$(run list | jq -r '.[0].name')"
|
||||||
|
jq -e '.home.present == true and .home.data.favorites[0].alias == "Studio"' \
|
||||||
|
"$backups/$live_name" >/dev/null \
|
||||||
|
|| fail 'live Home state was not written to the canonical snapshot'
|
||||||
|
|
||||||
# ── A snapshot cannot name a path outside the backup directory ───────────────
|
# ── A snapshot cannot name a path outside the backup directory ───────────────
|
||||||
printf '{"pwned":true}' >"$work/outside.json"
|
printf '{"pwned":true}' >"$work/outside.json"
|
||||||
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
|
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
|
||||||
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
|
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
|
||||||
|
link_name="settings-20000101-000000001.json"
|
||||||
|
ln -s "$work/outside.json" "$backups/$link_name"
|
||||||
|
run restore "$link_name" >/dev/null 2>&1 && fail 'a snapshot symlink escaping the backup directory was accepted'
|
||||||
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
|
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
|
||||||
|
|
||||||
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_test() {
|
||||||
|
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
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