The schema is the single source of truth for what a setting IS. It cannot express what a setting USED to be -- and renaming a key, changing its units, or splitting one setting into two all leave a stored value the new schema does not recognise. Unrecognised keys are deliberately carried through untouched so that rolling back to an older Panama does not discard a newer version's settings, which means the user's choice silently stops taking effect with nothing to explain it. settings.json now carries a schemaVersion, and load() runs every pending migration before anything reads a value. The list is empty: the point is that the first breaking schema change becomes a routine edit rather than an emergency, and Omarchy carries eighty of these. The behaviours that make it safe to run against a real user's file: A file with no schemaVersion predates this and is STAMPED, not migrated -- running the list against it would apply upgrades designed for schemas it never had. A file from a NEWER Panama is left completely alone. Downgrading keys is not something this can do correctly, and unknown keys already survive, so an older build simply ignores what it does not understand. A step that throws stops at the last good version. Skipping past it would lose that conversion forever; failing the whole load would cost the user every setting. The list being empty is exactly why this is tested now: the first time it runs for real will be against somebody's actual settings during an upgrade, which is a poor moment to find out how it behaves. The harness supplies fixture steps including one that throws, and the contract pins all four behaviours above plus the promise that unknown keys survive. One thing the contract earned its place on: stamping a pre-versioning file changes it without running any step, so writing only on "migrated" left the stamp in memory to be redone on every launch. It now writes whenever the version moves, and explicitly does not write a file from the future. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
178 lines
6.8 KiB
QML
178 lines
6.8 KiB
QML
pragma Singleton
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
|
|
//
|
|
// This object owns reading, validating, and writing. It knows nothing about
|
|
// which settings exist -- that is the schema's job -- so adding a setting never
|
|
// requires touching this file. That is the point: the previous implementation
|
|
// restated every key four times (a property alias, a JSON adapter property, a
|
|
// change handler, and a line in reset), and each omission failed silently.
|
|
//
|
|
// Reads go through get(), writes through set(). Typed accessors for the values
|
|
// the shell reads on every frame live in Settings.qml, which remains the stable
|
|
// public surface.
|
|
//
|
|
// The store lives at ~/.config/panama/settings.json rather than inside
|
|
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
|
|
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
|
|
// able to back it up, diff it, or keep it in a dotfiles repo.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
|
|
|
|
// Bumped on every accepted change. get() reads it so bindings built on get()
|
|
// have something to invalidate; a bare function call would otherwise capture
|
|
// no dependency and every reader would silently go stale.
|
|
property int revision: 0
|
|
|
|
// Everything currently on disk, including keys this build does not know
|
|
// about. Unknown keys are carried through untouched so that rolling back to
|
|
// an older Panama does not discard a newer version's settings.
|
|
property var values: ({})
|
|
|
|
property bool loaded: false
|
|
|
|
function get(key: string): var {
|
|
root.revision;
|
|
const stored = root.values[key];
|
|
if (stored === undefined)
|
|
return PreferenceSchema.defaultFor(key);
|
|
const coerced = PreferenceSchema.coerce(key, stored);
|
|
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
|
|
}
|
|
|
|
// Returns false when the key is unknown or the value cannot be represented,
|
|
// so a caller can surface the rejection instead of assuming it took.
|
|
function set(key: string, value: var): bool {
|
|
if (!PreferenceSchema.has(key))
|
|
return false;
|
|
const coerced = PreferenceSchema.coerce(key, value);
|
|
if (coerced === undefined)
|
|
return false;
|
|
if (root.values[key] === coerced)
|
|
return true;
|
|
|
|
// Reassign rather than mutate: QML does not notify on in-place changes
|
|
// to a var property's contents.
|
|
const next = Object.assign({}, root.values);
|
|
next[key] = coerced;
|
|
root.values = next;
|
|
root.revision++;
|
|
persistTimer.restart();
|
|
return true;
|
|
}
|
|
|
|
// Restores every schema default in one write. Complete by construction --
|
|
// there is no hand-maintained list to fall out of sync with the schema.
|
|
function resetDesktopDefaults(): void {
|
|
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
|
|
root.values = next;
|
|
root.revision++;
|
|
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 {
|
|
const text = preferencesFile.text();
|
|
if (text && text.trim().length > 0)
|
|
parsed = JSON.parse(text);
|
|
} catch (error) {
|
|
// A corrupt file must not cost the user a working desktop. Fall
|
|
// back to shipped defaults and let the next write replace it.
|
|
parsed = {};
|
|
}
|
|
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
|
|
|
// Upgrade before anything reads a value. A stored key the current
|
|
// schema no longer recognises is carried through untouched and silently
|
|
// stops taking effect, so the conversion has to happen here rather than
|
|
// being noticed later by whoever owns that setting.
|
|
const result = Migrations.apply(raw);
|
|
root.values = result.values;
|
|
|
|
if (result.migrated)
|
|
console.info("Settings migrated from version", result.from, "to", result.to + ":",
|
|
result.applied.join("; "));
|
|
|
|
// Write whenever the version moved, which includes stamping a file
|
|
// written before versioning existed. Left unwritten, the stamp lives
|
|
// only in memory and is redone on every launch, and a migration that is
|
|
// not idempotent would compound.
|
|
if (result.changed)
|
|
persistTimer.restart();
|
|
|
|
root.revision++;
|
|
root.loaded = true;
|
|
}
|
|
|
|
FileView {
|
|
id: preferencesFile
|
|
|
|
path: root.path
|
|
blockLoading: true
|
|
printErrors: false
|
|
atomicWrites: true
|
|
|
|
onLoaded: root.load()
|
|
// No file yet is the normal first-run case, not an error.
|
|
onLoadFailed: root.load()
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
migration.adopt();
|
|
root.load();
|
|
}
|
|
|
|
// Coalesce a burst of changes into one atomic write. Writing on every
|
|
// assignment races a second assignment and can reload the older value.
|
|
Timer {
|
|
id: persistTimer
|
|
interval: 0
|
|
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
|
}
|
|
|
|
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
|
// directory. Reads the old file only when the new one does not exist yet, so
|
|
// it can never overwrite newer settings, and never deletes the original.
|
|
QtObject {
|
|
id: migration
|
|
|
|
function adopt(): void {
|
|
if (preferencesFile.text())
|
|
return;
|
|
try {
|
|
const legacy = legacyFile.text();
|
|
if (legacy && legacy.trim().length > 0)
|
|
preferencesFile.setText(legacy);
|
|
} catch (error) {
|
|
// Nothing to migrate.
|
|
}
|
|
}
|
|
}
|
|
|
|
FileView {
|
|
id: legacyFile
|
|
|
|
path: Quickshell.stateDir + "/panama-settings.json"
|
|
blockLoading: true
|
|
printErrors: false
|
|
}
|
|
}
|