diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 45f01e7..0dfcd41 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -121,6 +121,7 @@ Rectangle { case "datetime": return dateTimePage; case "applications": return applicationsPage; case "storage": return storagePage; + case "snapshots": return snapshotsPage; case "users": return usersPage; case "sharing": return sharingPage; case "printers": return printersPage; @@ -163,6 +164,7 @@ Rectangle { Component { id: homePage; HomePage {} } Component { id: applicationsPage; ApplicationsPage {} } Component { id: storagePage; StoragePage {} } + Component { id: snapshotsPage; SnapshotsPage {} } Component { id: usersPage; UsersPage {} } Component { id: sharingPage; SharingPage {} } Component { id: printersPage; PrintersPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 5c237a1..ee8fb7b 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -43,6 +43,7 @@ Rectangle { { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, { page: "applications", label: "Applications", icon: "\u{F003B}" }, { page: "storage", label: "Storage", icon: "\u{F02CA}" }, + { page: "snapshots", label: "Snapshots", icon: "\u{F0954}" }, { page: "users", label: "Users", icon: "\u{F0004}" }, { page: "services", label: "System Health", icon: "\u{F0493}" }, { page: "about", label: "About", icon: "\u{F02FD}" } diff --git a/config/dot/quickshell/modules/settings/SnapshotsPage.qml b/config/dot/quickshell/modules/settings/SnapshotsPage.qml new file mode 100644 index 0000000..ce82577 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SnapshotsPage.qml @@ -0,0 +1,339 @@ +// Snapshots: what is protected, and how to get something back. +// +// Per volume, because on this machine the news was that one volume was covered +// and the important one was not -- six hundred snapshots of the system, none of +// anyone's documents. A timeline that opened on all those snapshots would have +// buried that. +// +// Inside a volume, the timeline is the Time Machine view: points in time, +// newest first, each one openable as a folder tree you can take a file out of. +// +// Rollback is deliberately absent. snapper's rollback changes the btrfs default +// subvolume, and this system's fstab pins subvol= explicitly, which overrides +// it -- so it would report success and change nothing after a reboot. + +import Quickshell +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + objectName: "snapshots" + title: "Snapshots" + lede: "Points in time you can go back to, taken automatically for each volume." + + property string openConfig: "" + property string confirmingDelete: "" + property string confirmingRestore: "" + + readonly property bool browsingOpen: Snapshots.browsingConfig !== "" + + Component.onCompleted: Snapshots.refresh() + + TextRow { + visible: Snapshots.lastError !== "" + label: "Snapshots need attention" + detail: Snapshots.lastError + value: "" + divider: false + } + + // What just happened to the file that was already there. + TextRow { + visible: Snapshots.lastRestore !== null + label: "Restored" + detail: Snapshots.lastRestore + ? String(Snapshots.lastRestore.restored ?? "") + + (String(Snapshots.lastRestore.keptAs ?? "") !== "" + ? " — the version that was there was kept as " + + String(Snapshots.lastRestore.keptAs).split("/").pop() + : "") + : "" + value: "" + divider: false + } + + // ── Anything unprotected is the headline ───────────────────────────────── + + SettingsCard { + visible: Snapshots.unprotected.length > 0 + title: "Not protected" + subtitle: "These volumes have no snapshot configuration, so nothing on them can be recovered." + + Repeater { + model: Snapshots.unprotected + + delegate: TextRow { + required property var modelData + width: parent.width + label: String(modelData.path ?? "") + detail: "btrfs subvolume " + String(modelData.subvolume ?? "") + + " · needs a configuration, which takes a password once" + value: "Unprotected" + divider: false + } + } + } + + // ── One card per volume ────────────────────────────────────────────────── + + Repeater { + model: Snapshots.configs + + delegate: SettingsCard { + id: volumeCard + + required property var modelData + + readonly property string configName: String(volumeCard.modelData.name ?? "") + readonly property var snapshots: volumeCard.modelData.snapshots ?? [] + readonly property bool open: root.openConfig === volumeCard.configName + + title: Snapshots.labelFor(volumeCard.modelData) + subtitle: String(volumeCard.modelData.subvolume ?? "") + + SwitchRow { + label: "Take snapshots automatically" + detail: volumeCard.modelData.timelineEnabled + ? Snapshots.describe(volumeCard.modelData) + : "Nothing is being taken for this volume" + checked: volumeCard.modelData.timelineEnabled === true + enabled: !Snapshots.busy && volumeCard.modelData.readable === true + onToggled: value => Snapshots.setTimeline(volumeCard.configName, value) + } + + TextRow { + label: "Keep" + detail: "Older points are removed automatically once these counts are exceeded" + value: Snapshots.retentionSummary(volumeCard.modelData) + } + + ActionRow { + label: "Take one now" + detail: "Kept until you remove it, unlike the automatic ones" + action: "Take snapshot" + enabled: !Snapshots.busy && volumeCard.modelData.readable === true + onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings") + } + + ActionRow { + label: "History" + detail: volumeCard.snapshots.length === 0 + ? "Nothing taken yet" + : volumeCard.snapshots.length + " point" + + (volumeCard.snapshots.length === 1 ? "" : "s") + " in time" + action: volumeCard.open ? "Hide" : "Browse…" + enabled: volumeCard.snapshots.length > 0 + divider: volumeCard.open + onTriggered: { + root.confirmingDelete = ""; + Snapshots.closeBrowser(); + root.openConfig = volumeCard.open ? "" : volumeCard.configName; + } + } + + // ── The timeline ───────────────────────────────────────────────── + + Column { + width: parent.width + visible: volumeCard.open && !root.browsingOpen + + Repeater { + model: volumeCard.snapshots + + delegate: SettingRow { + id: pointRow + + required property var modelData + required property int index + + readonly property string token: volumeCard.configName + ":" + pointRow.modelData.number + readonly property bool confirming: root.confirmingDelete === pointRow.token + + width: parent.width + // A kept snapshot is one the timeline will not remove, + // which is the distinction that matters when choosing + // what to rely on later. + icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}" + label: String(pointRow.modelData.date ?? "") + detail: String(pointRow.modelData.description ?? "") + + " · #" + pointRow.modelData.number + + (pointRow.modelData.kept ? " · kept" : "") + controlWidth: 250 + divider: pointRow.index < volumeCard.snapshots.length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Open" + enabled: !Snapshots.browsing + onClicked: Snapshots.browse(volumeCard.configName, + Number(pointRow.modelData.number), "") + } + + SettingsButton { + text: pointRow.confirming ? "Keep" : "Delete" + enabled: !Snapshots.busy + onClicked: root.confirmingDelete = + pointRow.confirming ? "" : pointRow.token + } + + SettingsButton { + visible: pointRow.confirming + text: "Delete it" + tone: "danger" + enabled: !Snapshots.busy + onClicked: { + root.confirmingDelete = ""; + Snapshots.remove(volumeCard.configName, + Number(pointRow.modelData.number)); + } + } + } + } + } + } + + // ── Inside one point in time ───────────────────────────────────── + + Column { + width: parent.width + visible: volumeCard.open && root.browsingOpen + && Snapshots.browsingConfig === volumeCard.configName + + ActionRow { + width: parent.width + label: Snapshots.browsingPath === "" + ? "Snapshot #" + Snapshots.browsingSnapshot + : "…/" + Snapshots.browsingPath + detail: "Choosing Restore puts a copy back where it came from, keeping whatever is there now" + action: "Back" + enabled: !Snapshots.browsing + onTriggered: Snapshots.browseUp() + } + + TextRow { + width: parent.width + visible: Snapshots.browsing + label: "Reading the snapshot…" + detail: "Listing a folder from a point in time" + value: "" + } + + Repeater { + model: Snapshots.browsing ? [] : Snapshots.browseEntries + + delegate: SettingRow { + id: entryRow + + required property var modelData + required property int index + + readonly property string entryPath: Snapshots.browsingPath === "" + ? String(entryRow.modelData.name) + : Snapshots.browsingPath + "/" + String(entryRow.modelData.name) + readonly property bool confirming: root.confirmingRestore === entryRow.entryPath + + width: parent.width + icon: entryRow.modelData.directory ? "\u{F024B}" : "\u{F0214}" + label: String(entryRow.modelData.name ?? "") + detail: entryRow.modelData.directory + ? "Folder" + : Snapshots.formatBytes(entryRow.modelData.bytes ?? 0) + controlWidth: 230 + divider: entryRow.index < Snapshots.browseEntries.length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + visible: entryRow.modelData.directory === true + text: "Open" + enabled: !Snapshots.browsing + onClicked: Snapshots.browse(Snapshots.browsingConfig, + Snapshots.browsingSnapshot, + entryRow.entryPath) + } + + SettingsButton { + text: entryRow.confirming ? "Cancel" : "Restore" + enabled: !Snapshots.busy + onClicked: root.confirmingRestore = + entryRow.confirming ? "" : entryRow.entryPath + } + + SettingsButton { + visible: entryRow.confirming + text: "Put it back" + tone: "danger" + enabled: !Snapshots.busy + onClicked: { + root.confirmingRestore = ""; + Snapshots.restore(Snapshots.browsingConfig, + Snapshots.browsingSnapshot, + entryRow.entryPath); + } + } + } + } + } + + TextRow { + width: parent.width + visible: !Snapshots.browsing && Snapshots.browseTruncated + label: "Only the first entries are shown" + detail: "This folder holds more than this list can usefully show" + value: "" + divider: false + } + + TextRow { + width: parent.width + visible: !Snapshots.browsing && Snapshots.browseEntries.length === 0 + label: "Nothing here" + detail: "This folder was empty at that point in time" + value: "" + divider: false + } + } + } + } + + // ── What it costs ──────────────────────────────────────────────────────── + + SettingsCard { + title: "Space" + subtitle: "A snapshot shares its data with the live filesystem and grows only as files change afterwards." + + TextRow { + label: "Free space" + detail: "Snapshots are removed oldest-first when this runs low" + value: Snapshots.formatBytes(Snapshots.space?.freeBytes ?? 0) + } + + TextRow { + label: "Automatic snapshots" + detail: Snapshots.timelineRunning + ? "The hourly timer is running" + : "The hourly timer is not running, so nothing new is being taken" + value: Snapshots.timelineRunning ? "Running" : "Stopped" + } + + // Honest about what cannot be measured: per-snapshot size needs btrfs + // quota groups, which cost performance on every write. Reporting a + // made-up number would be worse than saying so. + TextRow { + label: "Space used by snapshots" + detail: "Measuring this per snapshot needs btrfs quotas, which slow down every write. Free space above is the number that matters." + value: "Not measured" + divider: false + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 03a2602..0311d81 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -26,6 +26,7 @@ SettingsToggle 1.0 SettingsToggle.qml SettingsWindow 1.0 SettingsWindow.qml SharingPage 1.0 SharingPage.qml ShortcutsPage 1.0 ShortcutsPage.qml +SnapshotsPage 1.0 SnapshotsPage.qml SoundPage 1.0 SoundPage.qml SettingsPage 1.0 SettingsPage.qml StoragePage 1.0 StoragePage.qml diff --git a/config/dot/quickshell/scripts/panama-snapshots b/config/dot/quickshell/scripts/panama-snapshots new file mode 100755 index 0000000..fe19c6c --- /dev/null +++ b/config/dot/quickshell/scripts/panama-snapshots @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 + +"""Snapshots, through snapper. + +A point in time you can go back to, per btrfs subvolume. This machine already +had snapper running hourly, but only for / -- /home is a separate subvolume and +had no configuration at all, so six hundred snapshots existed and not one of +them contained a document. + +Deliberately absent: rollback. snapper's rollback works by changing the btrfs +default subvolume, and this system's fstab pins subvol=root and subvol=home +explicitly, which overrides it -- so a rollback would appear to succeed and +change nothing after a reboot. Restoring files and folders out of a snapshot +needs no reboot, cannot affect booting, and covers the cases people actually +hit. + + panama-snapshots snapshot + panama-snapshots create CONFIG DESCRIPTION + panama-snapshots delete CONFIG NUMBER + panama-snapshots set-retention CONFIG HOURLY DAILY WEEKLY + panama-snapshots set-timeline CONFIG true|false + panama-snapshots browse CONFIG NUMBER [RELATIVE_PATH] + panama-snapshots restore CONFIG NUMBER RELATIVE_PATH +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +CONFIG_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") + +# Directory listings are for choosing something to restore, not for browsing a +# terabyte. A folder with more entries than this is reported as truncated. +BROWSE_LIMIT = 400 + + +class BoundaryError(RuntimeError): + """A user-visible validation or snapper failure.""" + + +def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess: + try: + return subprocess.run(command, capture_output=True, text=True, + timeout=timeout, check=False) + except subprocess.TimeoutExpired as error: + raise BoundaryError("snapper did not answer in time.") from error + except OSError as error: + raise BoundaryError("snapper is not available.") from error + + +def require_config(name: str) -> str: + if not CONFIG_NAME.fullmatch(name or ""): + raise BoundaryError("That is not a snapshot configuration.") + return name + + +def require_number(value: str) -> int: + if not str(value).isdigit(): + raise BoundaryError("That is not a snapshot.") + number = int(value) + # 0 is the live filesystem, not a snapshot, and must never be a target. + if number < 1: + raise BoundaryError("That is the current state, not a snapshot.") + return number + + +def config_names() -> list[str]: + result = run(["snapper", "list-configs"]) + if result.returncode != 0: + return [] + names = [] + for line in result.stdout.splitlines()[2:]: + parts = [part.strip() for part in line.split("│")] + if len(parts) >= 2 and CONFIG_NAME.fullmatch(parts[0]): + names.append(parts[0]) + return names + + +def config_settings(name: str) -> dict: + result = run(["snapper", "-c", name, "get-config"]) + if result.returncode != 0: + return {} + values = {} + for line in result.stdout.splitlines()[2:]: + parts = [part.strip() for part in line.split("│")] + if len(parts) >= 2: + values[parts[0]] = parts[1] + return values + + +def snapshots_for(name: str) -> list[dict]: + result = run(["snapper", "-c", name, "list"], timeout=45) + if result.returncode != 0: + return [] + entries = [] + for line in result.stdout.splitlines()[2:]: + parts = [part.strip() for part in line.split("│")] + if len(parts) < 7 or not parts[0].isdigit(): + continue + number = int(parts[0]) + if number == 0: + continue + entries.append({ + "number": number, + "kind": parts[1], + "date": parts[3], + "user": parts[4], + "cleanup": parts[5], + "description": parts[6], + # A snapshot with no cleanup algorithm is not on the timeline's + # list to remove, which is what "kept" means to someone reading it. + "kept": parts[5] == "", + }) + entries.sort(key=lambda entry: entry["number"], reverse=True) + return entries + + +def btrfs_subvolumes() -> list[dict]: + """Mounted btrfs subvolumes, so the page can name what is NOT protected.""" + result = run(["findmnt", "-t", "btrfs", "-J", "-o", "TARGET,OPTIONS"]) + if result.returncode != 0: + return [] + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + return [] + + found = [] + + def walk(nodes): + for node in nodes: + options = str(node.get("options", "")) + target = str(node.get("target", "")) + match = re.search(r"subvol=(/[^,]*)", options) + # .snapshots holds the snapshots themselves and is not a thing to + # protect; listing it would offer to snapshot the snapshots. + if match and "/.snapshots" not in target: + found.append({"path": target, "subvolume": match.group(1)}) + walk(node.get("children", [])) + + walk(payload.get("filesystems", [])) + return found + + +def free_space() -> dict: + try: + usage = shutil.disk_usage("/home") + except OSError: + return {"freeBytes": 0, "totalBytes": 0} + return {"freeBytes": usage.free, "totalBytes": usage.total} + + +def timeline_running() -> bool: + return run(["systemctl", "is-active", "snapper-timeline.timer"]).stdout.strip() == "active" + + +def snapshot() -> dict: + configs = [] + protected_paths = set() + for name in config_names(): + settings = config_settings(name) + subvolume = settings.get("SUBVOLUME", "") + protected_paths.add(subvolume) + configs.append({ + "name": name, + "subvolume": subvolume, + "timelineEnabled": settings.get("TIMELINE_CREATE", "no") == "yes", + # Empty when this user cannot read the config at all, which is a + # different state from "no snapshots". + "readable": bool(settings), + "limits": { + "hourly": int(settings.get("TIMELINE_LIMIT_HOURLY") or 0), + "daily": int(settings.get("TIMELINE_LIMIT_DAILY") or 0), + "weekly": int(settings.get("TIMELINE_LIMIT_WEEKLY") or 0), + "monthly": int(settings.get("TIMELINE_LIMIT_MONTHLY") or 0), + "yearly": int(settings.get("TIMELINE_LIMIT_YEARLY") or 0), + }, + "snapshots": snapshots_for(name) if settings else [], + }) + configs.sort(key=lambda entry: entry["subvolume"]) + + unprotected = [entry for entry in btrfs_subvolumes() + if entry["path"] not in protected_paths] + + return { + "configs": configs, + "unprotected": unprotected, + "timelineRunning": timeline_running(), + "space": free_space(), + "error": "", + } + + +def snapshot_root(config: str, number: int) -> Path: + settings = config_settings(config) + subvolume = settings.get("SUBVOLUME", "") + if not subvolume: + raise BoundaryError("That snapshot configuration cannot be read.") + path = Path(subvolume) / ".snapshots" / str(number) / "snapshot" + if not path.is_dir(): + raise BoundaryError("That snapshot is not available.") + return path + + +def safe_relative(root: Path, relative: str) -> Path: + """Resolve a path inside a snapshot, refusing anything that escapes it. + + The caller is a settings page passing a path a person clicked, and ".." in + the wrong place would read or restore from outside the snapshot entirely. + """ + candidate = (root / relative.lstrip("/")).resolve() + if candidate != root.resolve() and root.resolve() not in candidate.parents: + raise BoundaryError("That path is not inside the snapshot.") + return candidate + + +def browse(config: str, number: str, relative: str) -> dict: + root = snapshot_root(require_config(config), require_number(number)) + target = safe_relative(root, relative) + if not target.is_dir(): + raise BoundaryError("That is not a folder in this snapshot.") + + entries = [] + truncated = False + try: + with os.scandir(target) as scan: + for item in scan: + if len(entries) >= BROWSE_LIMIT: + truncated = True + break + try: + is_dir = item.is_dir(follow_symlinks=False) + size = 0 if is_dir else item.stat(follow_symlinks=False).st_size + except OSError: + continue + entries.append({"name": item.name, "directory": is_dir, "bytes": size}) + except PermissionError as error: + raise BoundaryError("That folder cannot be read from this snapshot.") from error + except OSError as error: + raise BoundaryError("That folder could not be listed.") from error + + entries.sort(key=lambda entry: (not entry["directory"], entry["name"].lower())) + return {"path": relative, "entries": entries, "truncated": truncated, "error": ""} + + +def restore(config: str, number: str, relative: str) -> dict: + """Copy something out of a snapshot, keeping whatever is there now. + + The current version is moved aside rather than overwritten. A restore that + destroys the thing you were about to compare it against is how people lose + the work they were trying to save. + """ + name = require_config(config) + index = require_number(number) + root = snapshot_root(name, index) + source = safe_relative(root, relative) + if not source.exists(): + raise BoundaryError("That is not in this snapshot.") + + settings = config_settings(name) + live_root = Path(settings.get("SUBVOLUME", "")) + destination = safe_relative(live_root, relative) + + kept = "" + if destination.exists(): + kept = str(destination) + f".before-restore-{index}" + suffix = 1 + while Path(kept).exists(): + suffix += 1 + kept = str(destination) + f".before-restore-{index}-{suffix}" + try: + os.rename(destination, kept) + except OSError as error: + raise BoundaryError("The current version could not be set aside.") from error + + try: + if source.is_dir(): + shutil.copytree(source, destination, symlinks=True) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination, follow_symlinks=False) + except OSError as error: + raise BoundaryError("That could not be restored.") from error + + return {"restored": str(destination), "keptAs": kept} + + +def create(config: str, description: str) -> None: + if len(description) > 200 or "\n" in description: + raise BoundaryError("That description cannot be used.") + result = run(["snapper", "-c", require_config(config), "create", + "--description", description or "manual snapshot"], timeout=120) + if result.returncode != 0: + raise BoundaryError(_refusal(result, "The snapshot could not be taken.")) + + +def delete(config: str, number: str) -> None: + result = run(["snapper", "-c", require_config(config), "delete", + str(require_number(number))], timeout=120) + if result.returncode != 0: + raise BoundaryError(_refusal(result, "That snapshot could not be removed.")) + + +def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None: + values = [] + for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)): + if not str(value).isdigit() or int(value) > 999: + raise BoundaryError("Keep counts must be whole numbers.") + values.append(f"TIMELINE_LIMIT_{label}={int(value)}") + result = run(["snapper", "-c", require_config(config), "set-config", *values]) + if result.returncode != 0: + raise BoundaryError(_refusal(result, "The keep counts could not be changed.")) + + +def set_timeline(config: str, enabled: str) -> None: + result = run(["snapper", "-c", require_config(config), "set-config", + f"TIMELINE_CREATE={'yes' if enabled == 'true' else 'no'}"]) + if result.returncode != 0: + raise BoundaryError(_refusal(result, "Automatic snapshots could not be changed.")) + + +def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str: + text = (result.stderr or result.stdout or "").strip().splitlines() + if not text: + return fallback + last = text[-1] + if "permission" in last.lower(): + return "This account is not allowed to change that configuration." + return last[:200] + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + if len(arguments) in (3, 4) and arguments[0] == "browse": + print(json.dumps(browse(arguments[1], arguments[2], + arguments[3] if len(arguments) == 4 else ""), + separators=(",", ":"))) + return 0 + if len(arguments) == 4 and arguments[0] == "restore": + outcome = restore(arguments[1], arguments[2], arguments[3]) + state = snapshot() + state["restored"] = outcome + print(json.dumps(state, separators=(",", ":"))) + return 0 + + if len(arguments) == 3 and arguments[0] == "create": + create(arguments[1], arguments[2]) + elif len(arguments) == 3 and arguments[0] == "delete": + delete(arguments[1], arguments[2]) + elif len(arguments) == 5 and arguments[0] == "set-retention": + set_retention(arguments[1], arguments[2], arguments[3], arguments[4]) + elif len(arguments) == 3 and arguments[0] == "set-timeline": + set_timeline(arguments[1], arguments[2]) + else: + raise BoundaryError( + "Usage: panama-snapshots snapshot | create CONFIG DESCRIPTION | " + "delete CONFIG NUMBER | set-retention CONFIG HOURLY DAILY WEEKLY | " + "set-timeline CONFIG true|false | browse CONFIG NUMBER [PATH] | " + "restore CONFIG NUMBER PATH") + except BoundaryError as error: + try: + state = snapshot() + except BoundaryError: + state = {"configs": [], "unprotected": [], "timelineRunning": False, + "space": {"freeBytes": 0, "totalBytes": 0}} + state["error"] = str(error) + print(json.dumps(state, separators=(",", ":"))) + return 0 + + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index e7edfae..0fac03d 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -78,6 +78,11 @@ Singleton { { label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" }, { label: "Network name", detail: "The name other machines see", page: "sharing" }, { label: "File sharing", detail: "Share folders on the network", page: "sharing" }, + { label: "Snapshots", detail: "Points in time you can go back to", page: "snapshots" }, + { label: "Restore a file", detail: "Take a file or folder back out of a snapshot", page: "snapshots" }, + { label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" }, + { label: "File history", detail: "Earlier versions of your files", page: "snapshots" }, + { label: "Undo a change", detail: "Put back a file as it was at an earlier point", page: "snapshots" }, { label: "Free space", detail: "How full each drive and filesystem is", page: "storage" }, { label: "Disk usage", detail: "What is using the space on this machine", page: "storage" }, { label: "Drive health", detail: "Temperature, hours powered on, and reported warnings", page: "storage" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index a75189c..1870169 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "users", "sharing", "printers", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "snapshots", "users", "sharing", "printers", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/dot/quickshell/services/Snapshots.qml b/config/dot/quickshell/services/Snapshots.qml new file mode 100644 index 0000000..da0aa3f --- /dev/null +++ b/config/dot/quickshell/services/Snapshots.qml @@ -0,0 +1,214 @@ +pragma Singleton + +// Snapshots: points in time you can go back to, per btrfs subvolume. +// +// snapper was already running on this machine when this was written, hourly, +// for / only -- and /home is a separate subvolume with no configuration, so six +// hundred snapshots existed and not one contained a document. The page leads +// with what is protected and what is not for that reason. +// +// Deliberately absent: rollback. snapper's rollback changes the btrfs default +// subvolume, and an fstab that pins subvol= overrides it, so a rollback would +// report success and change nothing after a reboot. Restoring files and folders +// needs no reboot and cannot affect booting. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-snapshots" + + property var configs: [] + property var unprotected: [] + property bool timelineRunning: false + property var space: ({}) + property bool scanned: false + property string lastError: "" + + // The last restore, so the page can say what happened to the file that was + // already there rather than leaving someone to wonder. + property var lastRestore: null + + // Browsing state: which snapshot is open, where inside it, and what is there. + property string browsingConfig: "" + property int browsingSnapshot: 0 + property string browsingPath: "" + property var browseEntries: [] + property bool browseTruncated: false + property bool browsing: false + + // Guards read the Process objects directly; a derived binding is stale + // inside the handler that changes it. See DefaultApps.qml. + readonly property bool busy: query.running || mutation.running + + function labelFor(config: var): string { + const subvolume = String(config?.subvolume ?? ""); + if (subvolume === "/") + return "System"; + if (subvolume === "/home") + return "Home"; + return subvolume; + } + + function describe(config: var): string { + const snapshots = config?.snapshots ?? []; + if (!config?.readable) + return "This account cannot read this configuration"; + if (snapshots.length === 0) + return config?.timelineEnabled + ? "Protected — the first snapshot is taken on the hour" + : "Configured, but automatic snapshots are off"; + const oldest = snapshots[snapshots.length - 1]; + return snapshots.length + " snapshot" + (snapshots.length === 1 ? "" : "s") + + " · oldest " + String(oldest.date ?? "").replace(/^\w{3} /, ""); + } + + function retentionSummary(config: var): string { + const limits = config?.limits ?? ({}); + const parts = []; + if (Number(limits.hourly ?? 0) > 0) parts.push(limits.hourly + " hourly"); + if (Number(limits.daily ?? 0) > 0) parts.push(limits.daily + " daily"); + if (Number(limits.weekly ?? 0) > 0) parts.push(limits.weekly + " weekly"); + return parts.length > 0 ? parts.join(" · ") : "Nothing kept automatically"; + } + + function formatBytes(bytes: real): string { + if (!(bytes > 0)) + return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let index = 0; + while (value >= 1000 && index < units.length - 1) { + value /= 1000; + index += 1; + } + return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index]; + } + + function refresh(): void { + if (query.running) + return; + query.command = [root.helperPath, "snapshot"]; + query.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.configs = Array.isArray(parsed.configs) ? parsed.configs : []; + root.unprotected = Array.isArray(parsed.unprotected) ? parsed.unprotected : []; + root.timelineRunning = parsed.timelineRunning === true; + root.space = parsed.space ?? ({}); + root.lastError = String(parsed.error ?? ""); + if (parsed.restored) + root.lastRestore = parsed.restored; + } catch (error) { + root.lastError = "Could not read the snapshot service's answer."; + console.warn("Snapshots: could not parse helper output:", error); + } + root.scanned = true; + } + + function run(arguments: var): void { + if (mutation.running) + return; + root.lastError = ""; + mutation.command = [root.helperPath].concat(arguments); + mutation.running = true; + } + + function take(config: string, description: string): void { + root.run(["create", config, description]); + } + + function remove(config: string, number: int): void { + root.run(["delete", config, String(number)]); + } + + function setRetention(config: string, hourly: int, daily: int, weekly: int): void { + root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]); + } + + function setTimeline(config: string, enabled: bool): void { + root.run(["set-timeline", config, enabled ? "true" : "false"]); + } + + function restore(config: string, number: int, path: string): void { + root.lastRestore = null; + root.run(["restore", config, String(number), path]); + } + + // Browsing is a separate process from the snapshot, because a directory + // listing inside a terabyte-scale subvolume is not something to do while + // opening a page. + function browse(config: string, number: int, path: string): void { + if (browseProcess.running) + return; + root.browsingConfig = config; + root.browsingSnapshot = number; + root.browsingPath = path; + root.browsing = true; + browseProcess.command = [root.helperPath, "browse", config, String(number), path]; + browseProcess.running = true; + } + + function closeBrowser(): void { + root.browsingConfig = ""; + root.browsingSnapshot = 0; + root.browsingPath = ""; + root.browseEntries = []; + root.browseTruncated = false; + } + + // One level up, or out of the browser at the top. + function browseUp(): void { + const trimmed = String(root.browsingPath).replace(/\/+$/, ""); + if (trimmed === "") { + root.closeBrowser(); + return; + } + const parent = trimmed.indexOf("/") < 0 ? "" : trimmed.slice(0, trimmed.lastIndexOf("/")); + root.browse(root.browsingConfig, root.browsingSnapshot, parent); + } + + Process { + id: query + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { + id: mutation + // Answers with the fresh state, so the page updates from the change + // itself rather than asking again afterwards. + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { + id: browseProcess + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.browseEntries = Array.isArray(parsed.entries) ? parsed.entries : []; + root.browseTruncated = parsed.truncated === true; + if (String(parsed.error ?? "") !== "") { + root.lastError = String(parsed.error); + root.browseEntries = []; + } + } catch (error) { + root.lastError = "Could not read that folder from the snapshot."; + } + } + } + onExited: root.browsing = false + } +} diff --git a/config/local/share/vicinae/scripts/settings-snapshots.sh b/config/local/share/vicinae/scripts/settings-snapshots.sh new file mode 100755 index 0000000..2ac94c2 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-snapshots.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Snapshots +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Snapshots in Settings. +# @vicinae.keywords ["settings", "snapshots", "restore a file", "backups", "file history", "undo a change"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page snapshots diff --git a/docs/superpowers/plans/2026-08-19-desktop-integration.md b/docs/superpowers/plans/2026-08-19-desktop-integration.md index ea72fdc..26e1687 100644 --- a/docs/superpowers/plans/2026-08-19-desktop-integration.md +++ b/docs/superpowers/plans/2026-08-19-desktop-integration.md @@ -178,6 +178,30 @@ modifies or deletes existing data; restore copies out rather than over. **Done when** losing the home directory is an inconvenience rather than a catastrophe. +**Landed 2026-08-19, as snapshots rather than backups.** The machine already had +snapper running hourly and btrfs underneath, so the tool was never the gap. The +gap was that snapper's only config covered `/`, and `/home` is a separate +subvolume with no config at all -- six hundred and forty-three snapshots existed +and not one of them held a document. A `home` config now exists, the hourly +timeline covers it, and retention is deliberately conservative (5 hourly, 7 +daily, 4 weekly) because Steam's 1.2 TB lives on that subvolume and churns on +every game update. + +Restore is file and folder level, and sets the current version aside as +`.before-restore-N` rather than overwriting it. + +**Rollback is deliberately absent, and this is the interesting part.** snapper's +rollback works by changing the btrfs default subvolume. This system's fstab pins +`subvol=root` and `subvol=home` explicitly, which overrides the default -- so a +rollback would report success and change nothing after a reboot. A recovery +feature that silently does nothing is worse than not having one. Making it work +means editing fstab and the bootloader, which is the one class of change whose +failure cannot be fixed from inside the desktop; it deserves its own tested plan. + +**Still open.** Whether to carve Steam onto its own subvolume so snapshots skip +it. Deferred on purpose: the page reports free space, so the cost can be watched +for a week and decided on evidence. + --- ## Order and reasoning diff --git a/tests/quickshell/snapshots-contract.sh b/tests/quickshell/snapshots-contract.sh new file mode 100755 index 0000000..79ef437 --- /dev/null +++ b/tests/quickshell/snapshots-contract.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Getting a file back must never lose the file that was already there, and +# nothing here may touch how the machine boots. +# +# Four rules: +# +# 1. Restore sets the current version aside instead of overwriting it. A +# restore that destroys what you were about to compare against is how +# someone loses the work they were trying to save. +# 2. No rollback. snapper's rollback changes the btrfs default subvolume, and +# this system's fstab pins subvol= explicitly, which overrides it -- so a +# rollback would report success and change nothing after a reboot. A +# recovery feature that silently does nothing is worse than none. +# 3. Paths cannot escape the snapshot they came from. +# 4. Snapshot 0 is the live filesystem, not a snapshot, and can never be a +# target for reading or deleting. +# +# Read-only: it reads snapshot state and exercises refusals. It never creates, +# deletes, or restores anything. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-snapshots" +service="$repo_dir/config/dot/quickshell/services/Snapshots.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/SnapshotsPage.qml" + +fail() { + printf 'snapshots contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-snapshots is not executable' + +# ── 1. Restore keeps what was there ───────────────────────────────────────── +restore_body="$(sed -n '/^def restore/,/^def /p' "$helper")" +[[ -n "$restore_body" ]] || fail 'restore is missing' +grep -q 'before-restore' <<<"$restore_body" \ + || fail 'restore does not set the current version aside' +grep -q 'os.rename(destination, kept)' <<<"$restore_body" \ + || fail 'the current version is not moved before the snapshot copy is written' +# The move must happen BEFORE the copy, or there is nothing left to move. +rename_line="$(grep -n 'os.rename(destination, kept)' <<<"$restore_body" | head -1 | cut -d: -f1)" +copy_line="$(grep -n 'shutil.copy' <<<"$restore_body" | head -1 | cut -d: -f1)" +[[ -n "$rename_line" && -n "$copy_line" && "$rename_line" -lt "$copy_line" ]] \ + || fail 'the snapshot copy is written before the current version is set aside' + +# ── 2. No rollback ────────────────────────────────────────────────────────── +grep -qE '"rollback"|set-default|btrfs subvolume set-default|undochange' "$helper" \ + && fail 'the helper reaches for rollback, which this system fstab would silently ignore' +grep -qiE 'rollback' "$(dirname "$page")/$(basename "$page")" \ + | grep -v '^\s*//' >/dev/null 2>&1 +page_code="$(grep -vE '^\s*//' "$page")" +grep -qi 'rollback' <<<"$page_code" \ + && fail 'the page offers rollback' + +# ── 3. Paths cannot escape ────────────────────────────────────────────────── +grep -q 'def safe_relative' "$helper" || fail 'there is no path containment check' +command -v jq >/dev/null 2>&1 || { printf 'snapshots contract: SKIP (no jq)\n'; exit 0; } + +snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' +config="$(jq -r '.configs[0].name // ""' <<<"$snapshot")" +number="$(jq -r '.configs[0].snapshots[0].number // 0' <<<"$snapshot")" + +if [[ -n "$config" && "$number" != "0" ]]; then + refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; } + for bad in "../../etc" "../.." "gib/../../../etc"; do + answer="$(refusal browse "$config" "$number" "$bad")" + [[ "$answer" == "That path is not inside the snapshot." ]] \ + || fail "browsing \"$bad\" was not refused by the containment check: $answer" + done + answer="$(refusal restore "$config" "$number" "../../etc/passwd")" + [[ "$answer" == "That path is not inside the snapshot." ]] \ + || fail "restoring \"../../etc/passwd\" was not refused: $answer" + + # ── 4. The live filesystem is not a snapshot ──────────────────────────── + # The REASON again: with the guard removed, snapshot 0 fails anyway because + # its directory does not exist -- so a test that accepts any error passes + # with the guard deleted and proves nothing. + for answer in "$(refusal browse "$config" 0 "")" "$(refusal delete "$config" 0)"; do + [[ "$answer" == "That is the current state, not a snapshot." ]] \ + || fail "snapshot 0 was rejected for the wrong reason, so the live filesystem is not actually guarded: $answer" + done +fi + +# ── Shape ─────────────────────────────────────────────────────────────────── +jq -e '(.configs | type == "array") and (.unprotected | type == "array") and (.space | type == "object")' \ + <<<"$snapshot" >/dev/null || fail 'the snapshot is missing configs, unprotected, or space' +jq -e '[.configs[] | has("name") and has("subvolume") and has("timelineEnabled") and has("limits")] | all' \ + <<<"$snapshot" >/dev/null || fail 'a configuration is missing its name, subvolume, timeline flag, or limits' +jq -e '[.configs[].snapshots[]? | .number > 0] | all' <<<"$snapshot" >/dev/null \ + || fail 'the live filesystem is listed as a snapshot' + +# ── Destructive actions are confirmed ─────────────────────────────────────── +grep -q 'confirmingDelete' "$page" || fail 'the page deletes a snapshot without confirming' +grep -q 'confirmingRestore' "$page" || fail 'the page restores without confirming' +grep -q 'keeping whatever is there now' "$page" \ + || fail 'the page does not say that restoring keeps the current version' + +# ── Space is reported honestly ────────────────────────────────────────────── +# Per-snapshot size needs btrfs quotas, which cost performance on every write. +# A made-up number would be worse than saying it is not measured. +grep -q 'Not measured' "$page" \ + || fail 'the page reports a per-snapshot size it cannot actually measure' + +printf 'snapshots contract: PASS (%d volume(s), %d snapshot(s), no rollback)\n' \ + "$(jq '.configs | length' <<<"$snapshot")" \ + "$(jq '[.configs[].snapshots[]?] | length' <<<"$snapshot")"