// Sync & Backup. // // Three answers to three different questions, which is why they sit together: // carrying settings to a different machine, putting this machine's settings // back as they were, and throwing them away. // // The backups here are copies of the preferences file. They are not the btrfs // snapshots one tab over in System › Snapshots, which put the whole filesystem // back -- the card says so, because the two used to share the word "Snapshots" // and nothing distinguished them. // // Everything on this page that cannot be undone asks twice. Restoring a backup // and restoring defaults both used to happen on a single press, which is the // one interaction on this page nobody can take back: the two-stage confirm is // the same one Snapshots and Privacy use, where the first press only arms the // second and the second is the one wearing the danger color. import QtQuick import qs.config import qs.services SettingsPage { id: root objectName: "sync" title: "Sync & Backup" lede: "Carry this desktop with you, and come back from anything." // "restore:", "delete:", or "reset". One at a time: two armed // destructive actions on screen at once is how the wrong one gets pressed. property string confirming: "" function arm(token: string): void { root.confirming = root.confirming === token ? "" : token; } // Diff values come from a JSON file somebody may have edited by hand, so // they are whatever they are: booleans, numbers, objects, missing. Long // ones are cut rather than allowed to push the row off the card. function shortValue(value: var): string { if (value === null || value === undefined) return "not set"; const text = typeof value === "object" ? JSON.stringify(value) : String(value); return text.length > 44 ? text.slice(0, 43) + "…" : text; } // The helper already caps what it hands over and reports the true total // separately; this caps again for the rows, so the count in the copy is // the real one either way. readonly property int previewLimit: 8 readonly property var shownChanges: (SettingsSync.changes ?? []).slice(0, root.previewLimit) // Distinct from the backups below, which put THIS machine back as it was. // This carries settings to a different one, and deliberately leaves behind // anything that describes hardware. SettingsCard { title: "Carry settings to another machine" subtitle: SettingsSync.lastError !== "" ? SettingsSync.lastError : "Everything except what describes this machine: the display arrangement stays here." ActionRow { label: "Export" detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0 ? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath : "Writes " + SettingsSync.defaultPath action: "Export" enabled: !SettingsSync.busy onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath) } ActionRow { label: "See what an import would change" detail: SettingsSync.previewed ? SettingsSync.changeCount + " would change, " + SettingsSync.skipped.length + " skipped" : "Reads " + SettingsSync.defaultPath + " without applying anything" action: "Preview" enabled: !SettingsSync.busy divider: !SettingsSync.previewed onTriggered: SettingsSync.preview(SettingsSync.defaultPath) } // Key by key, old value and new. "14 would change" is a number nobody // can act on; this is the same information in the form somebody can // actually read before agreeing to it. Item { width: parent.width visible: SettingsSync.previewed && SettingsSync.changeCount > 0 implicitHeight: visible ? diffFrame.implicitHeight + 18 : 0 Rectangle { id: diffFrame anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.topMargin: 4 implicitHeight: diffRows.implicitHeight + 20 radius: Theme.cardRadius color: Theme.alpha(Theme.bgDark, 0.55) border.width: 1 border.color: Theme.alpha(Theme.fg, 0.07) Column { id: diffRows anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 10 spacing: 0 Repeater { model: root.shownChanges Column { required property var modelData width: parent.width spacing: 0 bottomPadding: 6 Text { width: parent.width text: "− " + String(modelData.key ?? "") + " " + root.shortValue(modelData.from) color: Theme.danger font.family: Theme.fontMono font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } Text { width: parent.width text: "+ " + String(modelData.key ?? "") + " " + root.shortValue(modelData.to) color: Theme.ok font.family: Theme.fontMono font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } } Text { width: parent.width visible: SettingsSync.changeCount > root.shownChanges.length text: "· " + (SettingsSync.changeCount - root.shownChanges.length) + " more would change" color: Theme.fgMuted font.family: Theme.fontMono font.pixelSize: Theme.fontSizeSmall } Text { width: parent.width visible: SettingsSync.skipped.length > 0 text: "· " + SettingsSync.skipped.length + " skipped — they describe this machine rather than your taste" color: Theme.fgMuted font.family: Theme.fontMono font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } } } // Only offered once a preview has said what it would do. Importing // settings sight unseen is how somebody ends up wondering why their // desktop changed. ActionRow { visible: SettingsSync.previewed && SettingsSync.changeCount > 0 label: "Apply those " + SettingsSync.changeCount + " changes" detail: "Settings the file does not mention are left alone" action: "Import" enabled: !SettingsSync.busy divider: false onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath) } Repeater { model: SettingsSync.previewed ? SettingsSync.skipped : [] TextRow { required property var modelData label: String(modelData.key ?? "") detail: "Skipped: " + String(modelData.reason ?? "") value: "" } } TextRow { visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === "" label: SettingsSync.applied === 0 ? "Nothing needed changing" : SettingsSync.applied + " settings applied" detail: "From " + (SettingsSync.exportedFrom || "the export") value: "" divider: false } } // ── Backups ────────────────────────────────────────────────────────────── SettingsCard { title: "Settings backups" subtitle: SettingsBackup.lastError !== "" ? SettingsBackup.lastError : "Copies of your preferences, not of the filesystem — System › Snapshots keeps the btrfs ones. Your whole desktop configuration is a single file, so a backup is a copy of it. Restoring also backs up what it replaces, so it is itself undoable." // Naming one is optional and worth it. "Before the displays // experiment" is findable a week later; a timestamp is not. FieldActionRow { id: nameField label: "Back up current settings" detail: SettingsBackup.snapshots.length === 0 ? "No backups yet" : SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first" placeholder: "Name (optional)" action: SettingsBackup.busy ? "Working…" : "Back up" enabled: !SettingsBackup.busy divider: SettingsBackup.snapshots.length > 0 onSubmitted: value => { // An unnamed backup is the plain save the automatic path uses; // naming one goes through create, which is the verb that knows // how to sanitise a name. if (value === "") SettingsBackup.save(); else SettingsBackup.create(value); nameField.clear(); } } Repeater { id: snapshotRows model: SettingsBackup.snapshots SettingRow { id: backupRow required property var modelData required property int index readonly property string backupName: String(backupRow.modelData.name ?? "") readonly property bool confirmingRestore: root.confirming === "restore:" + backupRow.backupName readonly property bool confirmingDelete: root.confirming === "delete:" + backupRow.backupName // The helper names a file by its timestamp with whatever the // person called it appended, so what is left after the // timestamp is the name they gave it. An unnamed backup falls // back to when it was taken rather than showing a filename. readonly property string displayName: { const given = String(backupRow.modelData.label ?? ""); if (given !== "") return given; const stem = backupRow.backupName.replace(/\.json$/, ""); const named = stem .replace(/^[0-9][0-9T:_.\-]*/, "") .replace(/^[-_]+/, "") .replace(/[-_]+/g, " ") .trim(); return named === "" ? String(backupRow.modelData.when ?? stem) : named; } width: parent.width label: backupRow.displayName detail: { const when = String(backupRow.modelData.when ?? ""); const parts = when === backupRow.displayName ? [] : [when]; if (backupRow.modelData.keys) parts.push(backupRow.modelData.keys + " settings"); const bytes = Number(backupRow.modelData.bytes ?? 0); if (bytes > 0) parts.push(root.formatBytes(bytes)); if (backupRow.confirmingRestore) parts.push("Restoring replaces every setting on this machine, and reloads the desktop"); if (backupRow.confirmingDelete) parts.push("Deleting a backup cannot be undone"); return parts.filter(part => part !== "").join(" · "); } controlWidth: 250 divider: backupRow.index < snapshotRows.count - 1 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { visible: !backupRow.confirmingDelete text: backupRow.confirmingRestore ? "Cancel" : "Restore…" enabled: !SettingsBackup.busy onClicked: root.arm("restore:" + backupRow.backupName) } SettingsButton { visible: backupRow.confirmingRestore text: "Restore it" tone: "danger" enabled: !SettingsBackup.busy onClicked: { root.confirming = ""; SettingsBackup.restore(backupRow.backupName); } } SettingsButton { visible: !backupRow.confirmingRestore text: backupRow.confirmingDelete ? "Cancel" : "Delete…" enabled: !SettingsBackup.busy onClicked: root.arm("delete:" + backupRow.backupName) } SettingsButton { visible: backupRow.confirmingDelete text: "Delete it" tone: "danger" enabled: !SettingsBackup.busy onClicked: { root.confirming = ""; SettingsBackup.deleteBackup(backupRow.backupName); } } } } } } // ── Reset ──────────────────────────────────────────────────────────────── SettingsCard { title: "Reset" // Every claim here is checked against PreferenceSchema.defaults(), since // a reset is exactly the schema store going back to it. The dock's // pinned applications ARE a schema key (dockPinned), so the shipped // sixteen come back — this sentence used to promise the opposite. Saved // themes (themeProfiles) are a schema key too, and go with them. Files // and Bluetooth pairings are not in the schema and not in the Home // store, and backups live in their own state directory, so all three // genuinely survive. subtitle: "Restores the appearance and theme, the dock and the applications pinned to it, the clock, focus, and display policy, removes any themes you saved, and clears your Home accessory arrangement. Your files, paired devices, and settings backups are not changed." SettingRow { id: resetRow // Stays armed while the safety backup is still being written, so // the confirmation does not collapse into a page that looks // untouched during the one window where nothing has happened yet. readonly property bool armed: root.confirming === "reset" || SystemSettings.resetPending label: "Restore defaults" detail: resetRow.armed ? "Every setting goes back to how Panama ships, immediately and including the compositor. A backup is taken first, so this is itself undoable from the card above." : "Applies immediately, including to the compositor. A backup is taken first." controlWidth: 240 divider: false Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: resetRow.armed ? "Cancel" : "Reset…" enabled: !SystemSettings.resetPending onClicked: root.arm("reset") } SettingsButton { visible: resetRow.armed // Nothing is wiped until the backup lands, so the button // stays occupied for as long as that takes rather than // looking like it did nothing. text: SystemSettings.resetPending ? "Backing up…" : "Restore defaults" tone: "danger" enabled: !SystemSettings.resetPending && !SettingsBackup.busy onClicked: { root.confirming = ""; SystemSettings.restoreDefaults(); } } } } } // Decimal units, the way both the Storage page and About report sizes. function formatBytes(bytes: real): string { if (!(bytes > 0)) return ""; if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + " MB"; if (bytes >= 1e3) return Math.round(bytes / 1e3) + " KB"; return Math.round(bytes) + " B"; } }