diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml index 552ca6e..089c962 100644 --- a/config/dot/quickshell/config/DesktopPreferences.qml +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -79,6 +79,14 @@ Singleton { persistTimer.restart(); } + // Re-read the file from disk. Used after something outside the shell has + // rewritten it -- restoring a snapshot, or a hand edit -- so the running + // desktop reflects the new contents without waiting for the next change. + function reload(): void { + preferencesFile.reload(); + root.load(); + } + function load(): void { let parsed = {}; try { diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 4270e1a..95c8d8b 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -15,7 +15,7 @@ pragma Singleton // // Entry fields // key unique identifier; also the JSON key on disk -// type "bool" | "int" | "real" | "string" | "enum" +// type "bool" | "int" | "real" | "string" | "enum" | "json" // def shipped default, used when the file is absent or a value is invalid // min/max inclusive bounds for int and real; values outside are clamped // step UI increment for int and real @@ -25,6 +25,13 @@ pragma Singleton // detail one line explaining what changing it does // internal true for state the shell keeps but the user never edits directly // pattern for "string": a regular expression the value must match in full +// +// "json" holds a structured value -- a list or an object -- that the schema +// stores and resets but does not validate field by field. It exists so that +// settings like the dock's pinned applications live in the same file, and are +// covered by the same reset, as everything else rather than growing a fourth +// preference store. The service that owns such a value is responsible for +// validating it; see services/Dock-related consumers. // hypr present when the setting maps onto an Hyprland option: // path the hl.config table path, e.g. ["decoration","blur","size"] // option the getoption path used to read the value back @@ -465,6 +472,26 @@ Singleton { ] }, + // ── Dock contents ─────────────────────────────────────────────────── + // A "json" value: the ordered list of desktop entry ids pinned to the + // dock. Kept in the shared store so that reordering the dock is covered + // by Restore defaults like everything else, rather than living in its + // own file. The shipped order is the GNOME dash it replaced. + { + key: "dockPinned", type: "json", group: "dock", + label: "Pinned applications", + detail: "Applications that stay in the Dock whether or not they are running", + def: [ + "org.gnome.Settings", "kitty", "org.gnome.Nautilus", + "com.bitwarden.desktop", "org.gnome.Software", "helium", + "org.mozilla.thunderbird_esr", "com.slack.Slack", + "app.bluebubbles.BlueBubbles", "rustdesk", + "io.podman_desktop.PodmanDesktop", "claude-desktop", + "codex-desktop", "md.obsidian.Obsidian", + "com.obsproject.Studio", "steam" + ] + }, + // ── Internal ──────────────────────────────────────────────────────── { key: "lastPage", type: "string", def: "home", group: "internal", @@ -542,6 +569,13 @@ Singleton { case "enum": return entry.options.some(option => option.value === value) ? value : undefined; + case "json": + // Accepted as-is. Anything JSON.parse produced is representable, + // and per-field meaning belongs to the owning service rather than + // here. A scalar is rejected so a corrupt file falls back to the + // default instead of handing a list-shaped consumer a number. + return (typeof value === "object") ? value : undefined; + case "string": { const text = typeof value === "string" ? value : String(value); // A constrained string is rejected rather than sanitised. Several diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 494351b..7d09c2f 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -63,24 +63,7 @@ Singleton { // ── Dock ──────────────────────────────────────────────────────────────── // Pinned apps, in order, taken from the GNOME dash favourites. - readonly property list dockPinned: [ - "org.gnome.Settings", - "kitty", - "org.gnome.Nautilus", - "com.bitwarden.desktop", - "org.gnome.Software", - "helium", - "org.mozilla.thunderbird_esr", - "com.slack.Slack", - "app.bluebubbles.BlueBubbles", - "rustdesk", - "io.podman_desktop.PodmanDesktop", - "claude-desktop", - "codex-desktop", - "md.obsidian.Obsidian", - "com.obsproject.Studio", - "steam" - ] + readonly property var dockPinned: DesktopPreferences.get("dockPinned") // Dash-to-Dock was set to intellihide against all windows: the dock hides // when any window would overlap it, and comes back on hover. diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 01f3e94..dafdf01 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -25,6 +25,26 @@ SettingsPage { SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false } } + SettingsCard { + title: "Pinned applications" + subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen." + + DockPinsEditor { + id: pins + width: parent.width + } + } + + SettingsCard { + title: "Pin another application" + + DockAppPicker { + width: parent.width + pinned: pins.pinned + onPicked: id => pins.add(id) + } + } + SettingsCard { title: "Window layout" subtitle: "Panama follows the Forge mental model with native Hyprland tiling." @@ -54,6 +74,41 @@ SettingsPage { SliderRow { setting: "focusDurationMinutes"; divider: false } } + SettingsCard { + title: "Snapshots" + subtitle: SettingsBackup.lastError !== "" + ? SettingsBackup.lastError + : "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable." + + ActionRow { + label: "Back up current settings" + detail: SettingsBackup.snapshots.length === 0 + ? "No snapshots yet" + : SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first" + action: "Back up now" + enabled: !SettingsBackup.busy + divider: SettingsBackup.snapshots.length > 0 + onTriggered: SettingsBackup.save() + } + + Repeater { + id: snapshotRows + model: SettingsBackup.snapshots + + ActionRow { + required property var modelData + required property int index + + label: modelData.when + detail: modelData.keys + " settings" + action: "Restore" + enabled: !SettingsBackup.busy + divider: index < snapshotRows.count - 1 + onTriggered: SettingsBackup.restore(modelData.name) + } + } + } + SettingsCard { title: "Reset" subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed." diff --git a/config/dot/quickshell/modules/settings/DockAppPicker.qml b/config/dot/quickshell/modules/settings/DockAppPicker.qml new file mode 100644 index 0000000..8409573 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DockAppPicker.qml @@ -0,0 +1,71 @@ +// Adds an installed application to the Dock. +// +// Filtered rather than a full list: there are several hundred desktop entries +// on a normal system, and rendering them all into a page that is already +// scrolling is both slow and useless. Typing narrows; nothing shows until you +// do, which also keeps the card short when you are not using it. + +import QtQuick +import Quickshell +import qs.config +import qs.modules.clipboard + +Column { + id: root + + spacing: 0 + + required property var pinned + + signal picked(string id) + + readonly property var matches: { + const needle = search.text.trim().toLowerCase(); + if (needle === "") + return []; + const out = []; + for (const entry of DesktopEntries.applications.values) { + if (entry.noDisplay) + continue; + if (root.pinned.indexOf(entry.id) >= 0) + continue; + if (String(entry.name).toLowerCase().indexOf(needle) >= 0) + out.push(entry); + if (out.length >= 8) + break; + } + return out; + } + + SearchField { + id: search + width: parent.width + placeholder: "Search installed applications" + } + + Repeater { + model: root.matches + + SettingRow { + id: candidate + + required property var modelData + required property int index + + label: candidate.modelData.name + detail: candidate.modelData.id + divider: candidate.index < root.matches.length - 1 + controlWidth: 86 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Pin" + onClicked: { + root.picked(candidate.modelData.id); + search.text = ""; + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DockPinsEditor.qml b/config/dot/quickshell/modules/settings/DockPinsEditor.qml new file mode 100644 index 0000000..b0fdd9b --- /dev/null +++ b/config/dot/quickshell/modules/settings/DockPinsEditor.qml @@ -0,0 +1,112 @@ +// The Dock's pinned applications: reorder, remove, and add. +// +// The list was a sixteen-entry literal in Settings.qml, so changing what sits +// in the Dock meant editing a QML file and reloading the shell. It is a plain +// ordered list of desktop entry ids, stored in the shared settings file, which +// means it is covered by Restore defaults like everything else. +// +// Move up / move down rather than drag-and-drop. Dragging inside a Flickable +// that is itself inside a scrolling page is a genuinely hard interaction to get +// right, and it fails in a way the user reads as the app being broken; two +// buttons are unambiguous and keyboard-reachable. + +import QtQuick +import Quickshell +import qs.config +import qs.services +import qs.widgets + +Column { + id: root + + spacing: 0 + + readonly property var pinned: { + const stored = DesktopPreferences.get("dockPinned"); + return Array.isArray(stored) ? stored : []; + } + + // DesktopEntries populates asynchronously, so this must be read as a + // binding rather than looked up inside one -- byId() called during + // evaluation registers no dependency and answers from an empty list. + readonly property var entriesById: { + const index = {}; + for (const entry of DesktopEntries.applications.values) + index[entry.id] = entry; + return index; + } + + function nameFor(id: string): string { + const entry = root.entriesById[id]; + return entry ? entry.name : id; + } + + function commit(next: var): void { + DesktopPreferences.set("dockPinned", next); + } + + function move(from: int, to: int): void { + if (to < 0 || to >= root.pinned.length) + return; + const next = root.pinned.slice(); + const moved = next.splice(from, 1)[0]; + next.splice(to, 0, moved); + root.commit(next); + } + + function remove(index: int): void { + const next = root.pinned.slice(); + next.splice(index, 1); + root.commit(next); + } + + function add(id: string): void { + if (root.pinned.indexOf(id) >= 0) + return; + root.commit(root.pinned.concat([id])); + } + + Repeater { + model: root.pinned + + SettingRow { + id: pin + + required property var modelData + required property int index + + label: root.nameFor(pin.modelData) + detail: pin.modelData + divider: pin.index < root.pinned.length - 1 + controlWidth: 132 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + SettingsButton { + text: "↑" + enabled: pin.index > 0 + onClicked: root.move(pin.index, pin.index - 1) + } + SettingsButton { + text: "↓" + enabled: pin.index < root.pinned.length - 1 + onClicked: root.move(pin.index, pin.index + 1) + } + SettingsButton { + text: "Unpin" + onClicked: root.remove(pin.index) + } + } + } + } + + SettingRow { + visible: root.pinned.length === 0 + label: "Nothing is pinned" + detail: "The Dock will only show running applications" + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index e6634d7..04c7a18 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -32,3 +32,5 @@ DateTimePage 1.0 DateTimePage.qml AccessibilityPage 1.0 AccessibilityPage.qml WallpaperPicker 1.0 WallpaperPicker.qml ApplicationsPage 1.0 ApplicationsPage.qml +DockPinsEditor 1.0 DockPinsEditor.qml +DockAppPicker 1.0 DockAppPicker.qml diff --git a/config/dot/quickshell/scripts/panama-settings-backup b/config/dot/quickshell/scripts/panama-settings-backup new file mode 100755 index 0000000..1c0c280 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-settings-backup @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +# Snapshots of the Panama settings store. +# +# The whole desktop configuration is one JSON file, which makes a backup a copy +# and a restore an overwrite. That is worth exposing: the settings app now +# changes real things -- compositor geometry, idle timeouts, the dock -- and +# being able to get back to a known-good state without hunting through git is +# the difference between experimenting freely and being cautious. +# +# panama-settings-backup save snapshot the current settings +# panama-settings-backup list JSON list of snapshots, newest first +# panama-settings-backup restore replace settings with a snapshot +# +# Snapshots are validated as JSON on the way in and on the way out, so a +# truncated file can never be restored over a working configuration. +# +# Names carry milliseconds. At one-second resolution a save followed promptly by +# a restore produced the same filename twice, and the restore's own safety +# snapshot overwrote the very file it was about to read. + +set -euo pipefail + +settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" +backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups" +keep=15 + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +case "${1:-list}" in + save) + [[ -r "$settings" ]] || fail "No settings file to back up." + jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON." + mkdir -p "$backup_dir" + stamp="$(date +%Y%m%d-%H%M%S%3N)" + cp "$settings" "$backup_dir/settings-$stamp.json" + # Keep the most recent few. A snapshot per change would otherwise grow + # without bound in a directory nobody ever looks at. + ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do + rm -f "$old" + done + printf '{"saved":"settings-%s.json"}\n' "$stamp" + ;; + + list) + mkdir -p "$backup_dir" + first=true + printf '[' + for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do + name="$(basename "$file")" + # settings-20260818-004512.json -> 2026-08-18 00:45 + raw="${name#settings-}"; raw="${raw%.json}" + pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}" + keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)" + [[ "$first" == true ]] || printf ',' + first=false + printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys" + done + printf ']\n' + ;; + + restore) + name="${2:-}" + [[ -n "$name" ]] || fail "Which snapshot?" + # Only a bare filename from the backup directory, so a caller cannot + # walk out of it with a path. + [[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name." + source_file="$backup_dir/$name" + [[ -r "$source_file" ]] || fail "That snapshot is missing." + jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON." + + # Snapshot what is being replaced, so restore is itself undoable. + if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then + mkdir -p "$backup_dir" + cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json" + fi + + mkdir -p "$(dirname "$settings")" + cp "$source_file" "$settings.tmp" + mv "$settings.tmp" "$settings" + printf '{"restored":"%s"}\n' "$name" + ;; + + *) + fail "usage: panama-settings-backup [save|list|restore ]" + ;; +esac diff --git a/config/dot/quickshell/services/SettingsBackup.qml b/config/dot/quickshell/services/SettingsBackup.qml new file mode 100644 index 0000000..d4e712b --- /dev/null +++ b/config/dot/quickshell/services/SettingsBackup.qml @@ -0,0 +1,90 @@ +pragma Singleton + +// Snapshots of the settings store. +// +// The whole desktop configuration is one JSON file, so a backup is a copy and a +// restore is an overwrite. Worth exposing now that the settings app changes +// real things -- compositor geometry, idle timeouts, the dock -- because being +// able to return to a known-good state is what makes experimenting feel safe. +// +// Restoring rewrites the file underneath the running shell, so the store is +// told to re-read afterwards rather than waiting for the next change. + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-backup" + + property var snapshots: [] + property string lastError: "" + property string lastAction: "" + + readonly property bool busy: listQuery.running || actionRun.running + + Process { + id: listQuery + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.snapshots = Array.isArray(parsed) ? parsed : []; + root.lastError = ""; + } catch (error) { + root.lastError = "Could not read the list of snapshots."; + } + } + } + } + + Process { + id: actionRun + property bool restoring: false + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.lastError = actionRun.restoring + ? "That snapshot could not be restored." + : "The settings could not be backed up."; + return; + } + root.lastError = ""; + root.lastAction = actionRun.restoring ? "restored" : "saved"; + if (actionRun.restoring) + DesktopPreferences.reload(); + root.refresh(); + } + } + + Component.onCompleted: root.refresh() + + function refresh(): void { + if (!listQuery.running) + listQuery.running = true; + } + + function save(): void { + if (actionRun.running) + return; + actionRun.restoring = false; + actionRun.exec([root.helperPath, "save"]); + } + + // The name is matched against the snapshot list rather than trusted, so no + // caller-supplied path reaches the helper even though it validates as well. + function restore(name: string): bool { + if (actionRun.running) + return false; + if (!root.snapshots.some(snapshot => snapshot.name === name)) { + root.lastError = "That snapshot is not in the list."; + return false; + } + actionRun.restoring = true; + actionRun.exec([root.helperPath, "restore", name]); + return true; + } +} diff --git a/tests/quickshell/settings-backup-contract.sh b/tests/quickshell/settings-backup-contract.sh new file mode 100755 index 0000000..f728939 --- /dev/null +++ b/tests/quickshell/settings-backup-contract.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Snapshots of the settings store. +# +# The restore path overwrites the file the whole desktop reads, so the +# properties that matter are: a corrupt snapshot is never restored over a +# working configuration, a restore snapshots what it replaces so it is itself +# undoable, and a snapshot name cannot be used to reach a file outside the +# backup directory. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-settings-backup" +work="$(mktemp -d /tmp/panama-backup-contract.XXXXXX)" + +fail() { + printf 'settings backup contract: %s\n' "$1" >&2 + exit 1 +} +cleanup() { rm -rf "$work"; } +trap cleanup EXIT + +settings="$work/config/panama/settings.json" +backups="$work/state/panama/backups" +mkdir -p "$(dirname "$settings")" + +run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; } + +# ── Nothing to back up ─────────────────────────────────────────────────────── +run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success' +[[ "$(run list)" == "[]" ]] || fail 'an empty backup directory did not list as empty' + +# ── A snapshot round-trips ─────────────────────────────────────────────────── +printf '{"gapsOut":24,"windowRounding":6}' >"$settings" +run save >/dev/null || fail 'save failed on a valid settings file' +name="$(run list | jq -r '.[0].name')" +[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name" +[[ "$(run list | jq -r '.[0].keys')" == "2" ]] || fail 'snapshot key count is wrong' + +printf '{"gapsOut":99}' >"$settings" +run restore "$name" >/dev/null || fail 'restore failed' +[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents' +[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key' + +# ── Restoring snapshots what it replaced, so it is undoable ────────────────── +count="$(run list | jq 'length')" +[[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)" + +# ── A corrupt snapshot is refused ──────────────────────────────────────────── +bad="settings-19990101-000000000.json" +mkdir -p "$backups" +printf '{ truncated' >"$backups/$bad" +run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored' +[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'a refused restore still damaged the settings file' + +# ── A snapshot cannot name a path outside the backup directory ─────────────── +printf '{"pwned":true}' >"$work/outside.json" +run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted' +run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted' +jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored' + +# ── A snapshot that is not listed is refused ───────────────────────────────── +run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored' + +# ── Snapshots are capped ───────────────────────────────────────────────────── +for _ in $(seq 1 20); do + printf '{"n":%s}' "$RANDOM" >"$settings" + run save >/dev/null +done +kept="$(run list | jq 'length')" +[[ "$kept" -le 15 ]] || fail "snapshots are not capped: $kept kept" +[[ "$kept" -ge 10 ]] || fail "snapshot pruning was too aggressive: only $kept kept" + +trap - EXIT +cleanup +printf 'settings backup contract: PASS\n'