Add a snapshot manager, and start covering home
The machine already had snapper running hourly on btrfs, so the tool was never missing. What was missing is that snapper's only configuration covered / -- and /home is a separate subvolume with no configuration at all. Six hundred and forty-three snapshots existed and not one of them contained a document. Anyone reaching for file history would have found their system and none of their files. /home now has a configuration on the same hourly timeline, with deliberately conservative retention: Steam's 1.2 TB lives on that subvolume and churns on every game update, so keeping five hourly and seven daily bounds what those updates can pin. Per volume, because on this machine "one is covered and the important one is not" was the news, and a timeline opening on system snapshots would have buried it. Inside a volume the timeline is the familiar view: points in time, newest first, each openable as a folder tree to take a file out of. Restoring sets the current version aside as .before-restore-N rather than overwriting it. A restore that destroys the thing you were about to compare against is how someone loses the work they were trying to save. Rollback is deliberately absent. 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 not having one, and making it work means editing fstab and the bootloader, whose failure cannot be repaired from inside the desktop. Per-snapshot size is reported as not measured, because measuring it needs btrfs quotas that cost performance on every write. Free space is shown instead, which is the number that decides anything. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -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 {} }
|
||||
|
||||
@@ -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}" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+384
@@ -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:]))
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user