Make the Dock editable and add settings snapshots

The Dock's pinned applications were a sixteen-entry literal in
Settings.qml, so changing what sits in the Dock meant editing QML. They
are now an ordered list in the shared store, with move up, move down,
unpin, and a filtered picker for adding installed applications. Keeping
them in the shared store rather than a file of their own means they are
covered by Restore defaults like everything else.

This needed a "json" schema type for values the schema stores and resets
but does not validate field by field. It exists so structured settings
can live in the one file rather than growing a fourth preference store;
the owning service validates the contents.

Snapshots make the settings app safe to experiment with. The whole
configuration is one file, so a backup is a copy and a restore is an
overwrite, and restoring snapshots what it replaces so it is itself
undoable. A snapshot is validated as JSON before it can be restored over
a working configuration, and a name that is not a plain snapshot
filename from the backup directory is refused.

Snapshot 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 file it was about to read --
found by the contract, which restores immediately after saving.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 00:59:08 -04:00
parent 6377bfb8fd
commit 150f3cdb09
10 changed files with 541 additions and 19 deletions
+77
View File
@@ -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'