From 83391e045385656e9cdbed9595c9709c92428615 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 02:17:39 -0400 Subject: [PATCH] Make settings restore crash-safe --- .../quickshell/scripts/panama-settings-backup | 922 +++++++++++------- .../quickshell/services/SettingsBackup.qml | 58 +- .../quickshell/settings-backup-harness.qml | 76 ++ tests/quickshell/settings-backup-contract.sh | 72 +- .../settings-backup-live-contract.sh | 139 ++- 5 files changed, 880 insertions(+), 387 deletions(-) create mode 100644 config/dot/quickshell/settings-backup-harness.qml diff --git a/config/dot/quickshell/scripts/panama-settings-backup b/config/dot/quickshell/scripts/panama-settings-backup index ca6548b..db63077 100755 --- a/config/dot/quickshell/scripts/panama-settings-backup +++ b/config/dot/quickshell/scripts/panama-settings-backup @@ -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 ]" - ;; -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 ]") + + +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 diff --git a/config/dot/quickshell/services/SettingsBackup.qml b/config/dot/quickshell/services/SettingsBackup.qml index a940eb4..3526706 100644 --- a/config/dot/quickshell/services/SettingsBackup.qml +++ b/config/dot/quickshell/services/SettingsBackup.qml @@ -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; } diff --git a/config/dot/quickshell/settings-backup-harness.qml b/config/dot/quickshell/settings-backup-harness.qml new file mode 100644 index 0000000..6ae117a --- /dev/null +++ b/config/dot/quickshell/settings-backup-harness.qml @@ -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 + }); + } + } +} diff --git a/tests/quickshell/settings-backup-contract.sh b/tests/quickshell/settings-backup-contract.sh index dc71dc6..08c1e78 100755 --- a/tests/quickshell/settings-backup-contract.sh +++ b/tests/quickshell/settings-backup-contract.sh @@ -24,9 +24,24 @@ trap cleanup EXIT settings="$work/config/panama/settings.json" home="$work/state/panama/panama-home.json" backups="$work/state/panama/backups" +transaction_dir="$work/state/panama/transactions/settings-restore" mkdir -p "$(dirname "$settings")" run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; } +run_with() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" env "$@"; } + +assert_transaction_clean() { + if [[ -d "$transaction_dir" ]] && find "$transaction_dir" -mindepth 1 -print -quit | rg -q .; then + fail 'restore left staged, rollback, or journal files behind' + fi + if find "$work" -type f \( \ + -name '.settings-restore.*' -o -name '.home-restore.*' \ + -o -name '*rollback*' -o -name '.journal.json.*' \ + -o -name '.settings.json.*' -o -name '.panama-home.json.*' \ + \) -print -quit | rg -q .; then + fail 'restore left a temporary target or journal file behind' + fi +} # ── Nothing to back up ─────────────────────────────────────────────────────── run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success' @@ -62,6 +77,22 @@ absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snap jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \ || fail 'restore did not return absent Home state for the live service to reload' +# Desktop absence is symmetric: a Home-only snapshot removes a desktop file +# created later and restores the Home store. +rm -f "$settings" +printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home" +run save >/dev/null || fail 'save failed when desktop settings were absent' +desktop_absent_name="$(run list | jq -r '.[0].name')" +printf '{"gapsOut":47}' >"$settings" +printf '{"initialized":false,"favorites":[]}' >"$home" +run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed' +[[ ! -e "$settings" ]] || fail 'restore did not preserve the snapshot’s absent desktop state' +[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \ + || fail 'Home-only snapshot did not restore Home state' +assert_transaction_clean + +printf '{"gapsOut":17}' >"$settings" + # A legacy settings-only snapshot predates presence metadata. Its safest # interpretation is to restore desktop settings without deleting current Home # state that the old format knew nothing about. @@ -72,6 +103,43 @@ run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed' [[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings' [[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe' +# `version` is a valid unknown desktop preference. It is only an envelope when +# the complete v2 shape is present. +legacy_version="settings-20000101-010203005.json" +printf '{"version":77,"gapsOut":19}' >"$backups/$legacy_version" +run restore "$legacy_version" >/dev/null || fail 'a legacy snapshot with an unknown version key was rejected' +[[ "$(jq -r '.version' "$settings")" == "77" ]] || fail 'legacy version key was not restored as desktop data' +[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy version key changed Home state' + +# ── A durable journal recovers a process/power-loss split ──────────────────── +printf '{"gapsOut":28,"windowRounding":12}' >"$settings" +printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Snapshot"}]}' >"$home" +run save >/dev/null || fail 'could not create crash-recovery snapshot' +crash_name="$(run list | jq -r '.[0].name')" + +printf '{"gapsOut":91,"windowRounding":3}' >"$settings" +printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Before crash"}]}' >"$home" +run_with PANAMA_SETTINGS_BACKUP_TEST_CRASH=after-desktop "$helper" restore "$crash_name" >/dev/null 2>&1 \ + && fail 'crash injection completed restore instead of terminating after the first replacement' +[[ "$(jq -r '.gapsOut' "$settings")" == "28" ]] || fail 'crash did not occur after desktop replacement' +[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'crash unexpectedly replaced Home state' +[[ -f "$transaction_dir/journal.json" ]] || fail 'crash left no durable recovery journal' + +# Every entry point must recover before doing its own work. `list` is the least +# invasive proof and must put both stores back to the pre-restore generation. +run list >/dev/null || fail 'next invocation could not recover the interrupted restore' +[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'recovery did not roll desktop settings back' +[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'recovery did not keep Home state in the same generation' +assert_transaction_clean + +# Cleanup is installed before staging. A deterministic pre-journal failure +# must leave both destinations untouched and no hidden artifacts behind. +run_with PANAMA_SETTINGS_BACKUP_TEST_FAIL=after-desktop-stage "$helper" restore "$crash_name" >/dev/null 2>&1 \ + && fail 'staging failure injection unexpectedly restored the snapshot' +[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'staging failure changed desktop settings' +[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'staging failure changed Home state' +assert_transaction_clean + # ── Restoring snapshots what it replaced, so it is undoable ────────────────── count="$(run list | jq 'length')" [[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)" @@ -81,7 +149,7 @@ bad="settings-19990101-000000000.json" mkdir -p "$backups" printf '{ truncated' >"$backups/$bad" run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored' -[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'a refused restore still damaged the settings file' +[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file' invalid_home="settings-19990101-000000001.json" jq -n '{ @@ -96,7 +164,7 @@ jq -n '{ }} }' >"$backups/$invalid_home" run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored' -[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'an invalid Home snapshot still damaged desktop settings' +[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'an invalid Home snapshot still damaged desktop settings' printf '{ truncated' >"$home" run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up' diff --git a/tests/quickshell/settings-backup-live-contract.sh b/tests/quickshell/settings-backup-live-contract.sh index 05cd04d..dfeca31 100755 --- a/tests/quickshell/settings-backup-live-contract.sh +++ b/tests/quickshell/settings-backup-live-contract.sh @@ -1,47 +1,132 @@ #!/usr/bin/env bash -# The helper proves the two stores round-trip in an isolated XDG tree. This -# source contract pins the live handoff without launching a second copy of the -# daily-driver shell or invoking Hyprland during tests. +# Behavioral coverage for the QML handoff after the helper commits a restore. +# The harness has a unique shell identity, isolated XDG roots, and fake external +# consumers. It records the real SettingsBackup call order without touching the +# daily-driver shell, compositor, keymap, or wallpaper. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml" +harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml" +work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)" fail() { printf 'settings backup live contract: %s\n' "$1" >&2 exit 1 } -rg -q 'DesktopPreferences\.reload\(\)' "$service" \ - || fail 'restore does not reload DesktopPreferences' -rg -q 'HomePreferences\.resetHomeDefaults\(\)' "$service" \ - || fail 'restore does not clear current Home state before reloading it' -rg -q 'HomePreferences\.initialize\(' "$service" \ - || fail 'restore does not reload restored Home favourites through the public API' -rg -q 'HomePreferences\.setAlias\(' "$service" \ - || fail 'restore does not reload restored Home aliases through the public API' -if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$service"; then - fail 'restore bypasses the durable HomePreferences API with direct alias mutation' -fi -rg -q 'SystemSettings\.applyPersistedDisplayPolicy\(\)' "$service" \ - || fail 'restore does not reapply compositor-backed preferences' -rg -q 'Keybinds\.applyReload\(\)' "$service" \ - || fail 'restore does not regenerate and reload rebound shortcuts' -rg -q 'Wallpaper\.set\(' "$service" \ - || fail 'restore does not reapply the restored wallpaper' -rg -q 'Quickshell\.reload\(false\)' "$service" \ - || fail 'restore does not soft-reload HomePreferences and reactive theme state' -rg -q 'actionRun\.exec\(\[root\.helperPath, "save", root\.serialiseHomeState\(\)\]\)' "$service" \ - || fail 'save does not hand the live HomePreferences state to the canonical backup store' +qs_test() { + XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@" +} -# User-controlled snapshot names must remain argv values. Restoring through a -# shell command would make validation in the helper the only line of defence. -rg -q 'actionRun\.exec\(\[root\.helperPath, "restore", name\]\)' "$service" \ +cleanup() { + qs_test kill >/dev/null 2>&1 || true + rm -rf "$work" +} +trap cleanup EXIT + +# The production command boundary must remain argv-only. +rg -Fq 'actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);' "$service" \ + || fail 'save does not pass live Home state as one argument' +rg -Fq 'actionRun.exec([root.helperPath, "restore", name]);' "$service" \ || fail 'restore is not executed through an argument array' if rg -q 'bash.*-c|sh.*-c' "$service"; then fail 'the restore service constructs a shell command' fi +# The harness replaces these seams, while these mappings prove the production +# defaults still delegate to Panama's existing public service APIs. +for mapping in \ + 'HomePreferences.resetHomeDefaults();' \ + 'HomePreferences.initialize(ids);' \ + 'HomePreferences.setAlias(id, alias);' \ + 'DesktopPreferences.reload();' \ + 'SystemSettings.applyPersistedDisplayPolicy();' \ + 'Keybinds.applyReload();' \ + 'Wallpaper.set(path);' \ + 'Quickshell.reload(false);'; do + rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping" +done + +qs_test --daemonize >"$work/quickshell.log" 2>&1 +ready=false +for _ in $(seq 1 60); do + if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then + ready=true + break + fi + sleep 0.1 +done +if [[ "$ready" != true ]]; then + sed -n '1,200p' "$work/quickshell.log" >&2 + fail 'isolated SettingsBackup harness did not start' +fi + +qs_test ipc call settings-backup-behavior reset >/dev/null +payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}' +[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \ + || fail 'valid restore output was rejected' + +status="" +for _ in $(seq 1 50); do + status="$(qs_test ipc call settings-backup-behavior status)" + jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break + sleep 0.1 +done +jq -e ' + .calls == [ + "home.reset", + "home.initialize:light.desk,light.office", + "home.alias:light.desk=Desk", + "home.alias:light.office=Office", + "desktop.reload", + "system.apply", + "keybinds.reload", + "wallpaper.set:/tmp/restored-wallpaper.jpg", + "shell.reload" + ] + and .initialized == true + and .favorites == [ + {"id":"light.desk","alias":"Desk"}, + {"id":"light.office","alias":"Office"} + ] +' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status" + +# Invalid output is rejected before Home state or external consumers change. +qs_test ipc call settings-backup-behavior reset >/dev/null +invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}' +[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \ + || fail 'duplicate Home state was accepted' +status="$(qs_test ipc call settings-backup-behavior status)" +jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \ + || fail 'invalid restore output caused partial live mutations' + +# An absent Home generation uses the same ordered external handoff but leaves +# the live Home service reset rather than manufacturing an initialized store. +qs_test ipc call settings-backup-behavior reset >/dev/null +absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}' +[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \ + || fail 'absent Home restore output was rejected' +for _ in $(seq 1 50); do + status="$(qs_test ipc call settings-backup-behavior status)" + jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break + sleep 0.1 +done +jq -e ' + .calls == [ + "home.reset", + "desktop.reload", + "system.apply", + "keybinds.reload", + "wallpaper.set:/tmp/restored-wallpaper.jpg", + "shell.reload" + ] + and .initialized == false + and .favorites == [] +' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status" + +trap - EXIT +cleanup printf 'settings backup live contract: PASS\n'