diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 0df8125..771531c 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -123,6 +123,71 @@ SettingsPage { SliderRow { setting: "focusDurationMinutes"; divider: false } } + // Distinct from the snapshots 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.changes.length + " would change, " + + SettingsSync.skipped.length + " skipped" + : "Reads " + SettingsSync.defaultPath + " without applying anything" + action: "Preview" + enabled: !SettingsSync.busy + onTriggered: SettingsSync.preview(SettingsSync.defaultPath) + } + + // 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.changes.length > 0 + label: "Apply those " + SettingsSync.changes.length + " changes" + detail: "Settings the file does not mention are left alone" + action: "Import" + enabled: !SettingsSync.busy + onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath) + } + + Repeater { + model: SettingsSync.previewed ? SettingsSync.skipped : [] + + delegate: TextRow { + required property var modelData + width: parent.width + 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 + } + } + SettingsCard { title: "Snapshots" subtitle: SettingsBackup.lastError !== "" diff --git a/config/dot/quickshell/scripts/panama-settings-sync b/config/dot/quickshell/scripts/panama-settings-sync new file mode 100755 index 0000000..8123926 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-settings-sync @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 + +"""Carrying Panama's settings to another machine. + +Different from panama-settings-backup, which snapshots this machine so it can be +put back exactly as it was, arrangement and all. This produces something meant +to travel: the preferences that describe taste rather than hardware. + +Two decisions shape the whole thing. + +The export is an ALLOW-LIST taken from the preference schema, not a deny-list of +things to strip. Anything the schema does not declare is dropped, so a key added +later that happens to hold a token cannot leak into a file somebody emails to +themselves. Being wrong in this direction loses a setting; being wrong the other +way publishes a secret. + +The import validates every value against the schema again on arrival and skips +what does not fit, one key at a time, with a reason. A file from an older Panama +is a normal thing to have, and refusing it wholesale because one key changed +shape would make the feature useless exactly when it is most wanted. + + panama-settings-sync export PATH + panama-settings-sync preview PATH + panama-settings-sync import PATH +""" + +from __future__ import annotations + +import json +import os +import re +import socket +import sys +import time +from pathlib import Path + +HOME = Path(os.environ.get("HOME", str(Path.home()))) +CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config"))) +SETTINGS = CONFIG_ROOT / "panama/settings.json" + +SCHEMA = Path(__file__).resolve().parents[1] / "config" / "PreferenceSchema.qml" + +FORMAT = "panama-settings-sync/1" + +# Settings that describe this machine rather than how it should behave. Held +# separately from the allow-list because each needs a reason, and a reason is +# what stops the list growing by habit. +MACHINE_SPECIFIC = { + # Monitor arrangement, keyed by output names that mean nothing elsewhere. + "displays": "describes this machine's monitors", + # Which settings page was last open. Session noise. + "lastPage": "is where you happened to be looking", + # Governs migration of the store itself; importing one would mislabel it. + "schemaVersion": "belongs to the store, not to you", +} + +# Settings that travel but may not land: an absolute path is only a setting on a +# machine where the file exists. Carried, then checked on arrival. +PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"} + + +class BoundaryError(RuntimeError): + """A user-visible validation or file failure.""" + + +def schema() -> dict[str, dict]: + """Every declared preference, by key. + + Parsed from the schema rather than kept as a second list here, so a setting + added there is exportable without anyone remembering to update this. + """ + try: + source = SCHEMA.read_text(encoding="utf-8") + except OSError as error: + raise BoundaryError("The preference schema could not be read.") from error + + entries: dict[str, dict] = {} + for chunk in source.split("key: ")[1:]: + head = chunk[:1600] + key = re.match(r'"([A-Za-z0-9_]+)"', chunk) + kind = re.search(r'type:\s*"([a-z]+)"', head) + if not (key and kind): + continue + entry = {"key": key.group(1), "type": kind.group(1)} + for bound in ("min", "max"): + found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head) + if found: + entry[bound] = float(found.group(1)) + pattern = re.search(r'pattern:\s*"((?:[^"\\]|\\.)*)"', head) + if pattern: + entry["pattern"] = pattern.group(1).replace("\\\\", "\\") + # Option values are quoted for a word and bare for a number -- vrrPolicy + # is an enum of 0..3. Capturing only the quoted form left numeric enums + # with no choices at all, which then read as unverifiable and were + # refused: a valid setting dropped on the way in. + options = [quoted if quoted else bare for quoted, bare + in re.findall(r'value:\s*(?:"([^"]*)"|(-?[0-9]+(?:\.[0-9]+)?))', head)] + if options: + entry["options"] = options + entries[entry["key"]] = entry + if not entries: + raise BoundaryError("The preference schema yielded no settings.") + return entries + + +def stored() -> dict: + if not SETTINGS.is_file(): + return {} + try: + value = json.loads(SETTINGS.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise BoundaryError("This machine's settings could not be read.") from error + return value if isinstance(value, dict) else {} + + +def fits(entry: dict, value) -> str: + """"" if the value is usable for this setting, else why it is not.""" + kind = entry["type"] + if kind == "bool": + return "" if isinstance(value, bool) else "is not a yes or no" + if kind == "enum": + # Compared as text because an enum is words in some settings and numbers + # in others, and JSON gives back 3 where the schema wrote 3. + if isinstance(value, bool) or not isinstance(value, (str, int, float)): + return "is not one of this setting's choices" + value = str(value) + choices = entry.get("options") or [] + # A schema entry whose choices could not be read is not grounds to + # accept anything: an unrecognised value would be written straight into + # the store and break whatever reads it. + if not choices: + return "cannot be checked against this setting's choices" + return "" if value in choices else "is not one of this setting's choices" + # "real" is what the schema calls a float. Spelling it "float" here meant + # fifteen settings were accepted without their range being checked at all. + if kind in ("int", "real", "float"): + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "is not a number" + if "min" in entry and value < entry["min"]: + return f"is below the minimum of {entry['min']:g}" + if "max" in entry and value > entry["max"]: + return f"is above the maximum of {entry['max']:g}" + return "" + if kind == "string": + if not isinstance(value, str): + return "is not text" + if "options" in entry and entry["options"] and value not in entry["options"]: + return "is not one of the choices this setting allows" + if "pattern" in entry: + try: + if not re.match(entry["pattern"], value): + return "does not match the form this setting takes" + except re.error: + return "" + return "" + if kind == "json": + return "" if isinstance(value, (list, dict)) else "is not a list or an object" + return "" + + +def exportable() -> tuple[dict, list[dict]]: + known = schema() + current = stored() + + carried: dict = {} + left: list[dict] = [] + for key, value in sorted(current.items()): + if key in MACHINE_SPECIFIC: + left.append({"key": key, "reason": MACHINE_SPECIFIC[key]}) + continue + if key not in known: + left.append({"key": key, "reason": "is not a setting this version declares"}) + continue + problem = fits(known[key], value) + if problem: + left.append({"key": key, "reason": "holds a value that " + problem}) + continue + carried[key] = value + return carried, left + + +def export(path: str) -> dict: + carried, left = exportable() + bundle = { + "format": FORMAT, + "exportedAt": int(time.time()), + "exportedFrom": socket.gethostname(), + "settings": carried, + } + target = Path(path).expanduser() + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + # Readable only by its owner: it is not secret, but it is a description + # of somebody's machine and there is no reason to hand it around. + target.chmod(0o600) + except OSError as error: + raise BoundaryError("That file could not be written.") from error + + return {"path": str(target), "carried": len(carried), "left": left} + + +def read_bundle(path: str) -> dict: + source = Path(path).expanduser() + try: + bundle = json.loads(source.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise BoundaryError("That file does not exist.") from error + except (OSError, json.JSONDecodeError) as error: + raise BoundaryError("That file is not a settings export.") from error + if not isinstance(bundle, dict) or not str(bundle.get("format", "")).startswith("panama-settings-sync/"): + raise BoundaryError("That file is not a settings export.") + if not isinstance(bundle.get("settings"), dict): + raise BoundaryError("That export contains no settings.") + return bundle + + +def plan(path: str) -> dict: + """What an import would do, without doing any of it.""" + bundle = read_bundle(path) + known = schema() + current = stored() + + apply: dict = {} + changes: list[dict] = [] + skipped: list[dict] = [] + + for key, value in sorted(bundle["settings"].items()): + if key in MACHINE_SPECIFIC: + skipped.append({"key": key, "reason": MACHINE_SPECIFIC[key]}) + continue + if key not in known: + skipped.append({"key": key, "reason": "is not a setting this version has"}) + continue + problem = fits(known[key], value) + if problem: + skipped.append({"key": key, "reason": "the value " + problem}) + continue + if key in PATH_VALUED: + missing = [p for p in (value if isinstance(value, list) else [value]) + if isinstance(p, str) and p and not Path(p).expanduser().exists()] + if missing: + skipped.append({"key": key, + "reason": "points at a file this machine does not have"}) + continue + if current.get(key) == value: + continue + apply[key] = value + changes.append({"key": key, + "from": current.get(key, None), + "to": value}) + + return { + "path": str(Path(path).expanduser()), + "exportedFrom": str(bundle.get("exportedFrom", "")), + "exportedAt": int(bundle.get("exportedAt", 0)), + "changes": changes, + "skipped": skipped, + "apply": apply, + } + + +def apply_import(path: str) -> dict: + """Merge an export into this machine's settings. + + Written whole through a temporary file and a rename, so a crash midway + leaves the old settings intact rather than half of each. Settings not named + by the export are untouched: this is a merge, not a replacement, because an + export from a machine that never changed a setting should not reset it here. + """ + prepared = plan(path) + if not prepared["apply"]: + return {**prepared, "applied": 0} + + current = stored() + current.update(prepared["apply"]) + + try: + SETTINGS.parent.mkdir(parents=True, exist_ok=True) + temporary = SETTINGS.with_suffix(".sync-tmp") + temporary.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + os.replace(temporary, SETTINGS) + except OSError as error: + raise BoundaryError("This machine's settings could not be written.") from error + + return {**prepared, "applied": len(prepared["apply"])} + + +def main(arguments: list[str]) -> int: + try: + if len(arguments) == 2 and arguments[0] == "export": + result = export(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "preview": + result = plan(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "import": + result = apply_import(arguments[1]) + else: + raise BoundaryError( + "Usage: panama-settings-sync export PATH | preview PATH | import PATH") + except BoundaryError as error: + print(json.dumps({"error": str(error)}, separators=(",", ":"))) + return 0 + + result["error"] = "" + print(json.dumps(result, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/SettingsSync.qml b/config/dot/quickshell/services/SettingsSync.qml new file mode 100644 index 0000000..dbfdb46 --- /dev/null +++ b/config/dot/quickshell/services/SettingsSync.qml @@ -0,0 +1,75 @@ +pragma Singleton + +// Carrying settings to another machine. +// +// Distinct from SettingsBackup, which snapshots this machine so it can be put +// back exactly as it was. This produces something meant to travel: the +// preferences that describe taste rather than hardware. +// +// The helper exports by allow-list from the preference schema, so a key added +// later that happens to hold a token cannot leak into a file somebody emails to +// themselves, and validates everything again on arrival. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-settings-sync" + + // Where an export lands by default. Somewhere a person will find it again. + readonly property string defaultPath: + (Quickshell.env("HOME") ?? "") + "/panama-settings.json" + + property string lastError: "" + property string lastAction: "" + property int carried: 0 + property int applied: 0 + property var left: [] + property var changes: [] + property var skipped: [] + property string exportedFrom: "" + property bool previewed: false + + readonly property bool busy: worker.running + + function run(action: string, path: string): void { + if (worker.running) + return; + root.lastError = ""; + root.lastAction = action; + worker.command = [root.helperPath, action, path]; + worker.running = true; + } + + function exportTo(path: string): void { root.run("export", path); } + function preview(path: string): void { root.run("preview", path); } + function importFrom(path: string): void { root.run("import", path); } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.lastError = String(parsed.error ?? ""); + root.carried = Number(parsed.carried ?? 0); + root.applied = Number(parsed.applied ?? 0); + root.left = Array.isArray(parsed.left) ? parsed.left : []; + root.changes = Array.isArray(parsed.changes) ? parsed.changes : []; + root.skipped = Array.isArray(parsed.skipped) ? parsed.skipped : []; + root.exportedFrom = String(parsed.exportedFrom ?? ""); + root.previewed = root.lastAction === "preview" && root.lastError === ""; + } catch (error) { + root.lastError = "Could not read the result."; + console.warn("SettingsSync: could not parse helper output:", error); + } + } + + Process { + id: worker + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } +} diff --git a/tests/quickshell/settings-sync-contract.sh b/tests/quickshell/settings-sync-contract.sh new file mode 100755 index 0000000..1069b66 --- /dev/null +++ b/tests/quickshell/settings-sync-contract.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash + +# Carrying settings to another machine. +# +# The rules: +# +# 1. Export is an allow-list read from the preference schema, not a deny-list +# of things to strip. A key added later that happens to hold a token must +# not be able to leak into a file somebody emails to themselves. Being +# wrong this way loses a setting; being wrong the other way publishes a +# secret. +# 2. What describes the machine stays on the machine. The display arrangement +# is keyed by output names that mean nothing elsewhere. +# 3. Every value is validated again on arrival, per key, with a reason. A file +# from an older Panama is a normal thing to have, and refusing it wholesale +# because one key changed shape would make the feature useless exactly when +# it is most wanted. +# 4. Validation covers every type the schema actually uses. It did not: "real" +# was spelled "float" and enums were assumed to be words, so 36 settings -- +# including every numeric enum -- were accepted unchecked, and numeric +# enums were then refused outright once that was noticed. +# 5. Import is a merge. Settings the file does not mention are left alone. +# +# Runs entirely against a temporary config home. The real settings store is read +# for the export and never written. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-settings-sync" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" + +fail() { + printf 'settings sync contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -x "$helper" ]] || fail 'panama-settings-sync is not executable' +[[ -r "$schema" ]] || fail 'the preference schema is missing' + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +bundle="$work/export.json" +field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } + +# ── 1 & 2. Export carries taste, not hardware ─────────────────────────────── + +"$helper" export "$bundle" >"$work/export-result.json" || fail 'export failed' +reason="$(field "['error']" <"$work/export-result.json")" +[[ -z "$reason" ]] || fail "export reported: $reason" + +[[ "$(stat -c '%a' "$bundle")" == "600" ]] \ + || fail 'the export is readable by other accounts' + +python3 - "$bundle" "$schema" "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" <<'PY' || fail 'the export carried the wrong things, in one direction or the other' +import json, re, sys +bundle = json.load(open(sys.argv[1])) +schema = open(sys.argv[2]).read() + +settings = bundle["settings"] +if not settings: + raise SystemExit('the export carried nothing at all') + +declared = set(re.findall(r'key:\s*"([A-Za-z0-9_]+)"', schema)) +for key in settings: + if key not in declared: + raise SystemExit(f'{key} is not declared in the schema but was exported') + +for forbidden in ("displays", "lastPage", "schemaVersion"): + if forbidden in settings: + raise SystemExit(f'{forbidden} describes this machine and must not travel') + +# Nothing credential-shaped, whatever the schema says about it. +raw = json.dumps(settings) +for marker in ("BEGIN ", "PRIVATE KEY", "Bearer "): + if marker in raw: + raise SystemExit(f'the export contains {marker!r}') +for key, value in settings.items(): + if isinstance(value, str) and len(value) > 300: + raise SystemExit(f'{key} is long enough to be something other than a setting') + +# The export must carry what it should, not merely refrain from carrying what it +# should not. Checking only the latter passes trivially when a whole class of +# setting is silently dropped -- which is exactly what happened: numeric enums +# were unreadable, so they never reached the bundle and every "did it arrive" +# check was satisfied by their absence. +present = set(settings) +current = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else {} +for key, value in current.items(): + if key in ("displays", "lastPage", "schemaVersion"): + continue + if key in declared and key not in present: + raise SystemExit(f'{key} is set on this machine and declared, but was not carried') +PY + +# ── 3, 4. Arrival is validated per key, and the types are all covered ─────── + +export XDG_CONFIG_HOME="$work/config" + +python3 - "$bundle" "$work/tampered.json" <<'PY' +import json, sys +bundle = json.load(open(sys.argv[1])) +bundle["settings"].update({ + "gapsIn": 9999, # above the schema maximum + "colorScheme": "chartreuse", # not one of a word enum's choices + "vrrPolicy": 47, # not one of a NUMERIC enum's choices + "blurEnabled": "yes please", # wrong type entirely + "displays": {"DP-9": "elsewhere"}, # machine-specific, injected + "someFutureToken": "sk-abcdef123456", # a key this version does not know +}) +json.dump(bundle, open(sys.argv[2], "w")) +PY + +"$helper" preview "$work/tampered.json" >"$work/preview.json" || fail 'preview failed' +python3 - "$work/preview.json" <<'PY' || fail 'a bad value was not refused with its reason' +import json, sys +plan = json.load(open(sys.argv[1])) +skipped = {entry["key"]: entry["reason"] for entry in plan["skipped"]} +for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "someFutureToken"): + if key not in skipped: + raise SystemExit(f'{key} was accepted and should not have been') + if not skipped[key].strip(): + raise SystemExit(f'{key} was skipped without saying why') +changed = {entry["key"] for entry in plan["changes"]} +for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "someFutureToken"): + if key in changed: + raise SystemExit(f'{key} was refused and queued for application anyway') +PY + +# A clean bundle must arrive intact. Refusing valid settings is the failure this +# contract exists to catch as much as accepting invalid ones -- fixing the enum +# check the first time turned every numeric enum into a rejection. +"$helper" import "$bundle" >"$work/import.json" || fail 'import failed' +python3 - "$bundle" "$work/config/panama/settings.json" <<'PY' || fail 'the round trip lost or changed a setting' +import json, sys +sent = json.load(open(sys.argv[1]))["settings"] +landed = json.load(open(sys.argv[2])) +missing = [k for k in sent if k not in landed] +if missing: + raise SystemExit(f'{len(missing)} settings did not arrive, starting with {missing[:3]}') +wrong = [k for k, v in sent.items() if landed[k] != v] +if wrong: + raise SystemExit(f'{len(wrong)} arrived with a different value, starting with {wrong[:3]}') +PY + +# ── 5. Import merges rather than replaces ─────────────────────────────────── + +python3 - "$work/config/panama/settings.json" <<'PY' +import json, sys +store = json.load(open(sys.argv[1])) +store["aSettingTheBundleNeverMentions"] = "kept" +json.dump(store, open(sys.argv[1], "w")) +PY +"$helper" import "$bundle" >/dev/null || fail 'second import failed' +python3 - "$work/config/panama/settings.json" <<'PY' || fail 'import replaced the store instead of merging into it' +import json, sys +store = json.load(open(sys.argv[1])) +if store.get("aSettingTheBundleNeverMentions") != "kept": + raise SystemExit('a setting the bundle did not mention was removed') +PY + +applied="$("$helper" import "$bundle" | field "['applied']")" +[[ "$applied" == "0" ]] \ + || fail "importing the same bundle twice applied $applied changes the second time" + +# ── Refusals ──────────────────────────────────────────────────────────────── + +printf 'not json at all\n' >"$work/junk.json" +reason="$("$helper" import "$work/junk.json" | field "['error']")" +[[ "$reason" == *"not a settings export"* ]] \ + || fail "a file that is not an export was not refused with a reason (got: $reason)" + +reason="$("$helper" import "$work/absent.json" | field "['error']")" +[[ "$reason" == *"does not exist"* ]] \ + || fail "a missing file was not refused with a reason (got: $reason)" + +printf 'settings sync contract: ok\n'