Files
Panama/config/dot/quickshell/scripts/panama-settings-sync
T

350 lines
14 KiB
Python
Executable File

#!/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"}
# A preview is something a person reads before pressing Import. Past a screen or
# two it stops being read and starts being scrolled, so the list is capped and
# the count says how many there really are -- the import itself still applies
# every change, because the cap is about what is shown, not what is done.
CHANGE_LIMIT = 40
# Long enough for a wallpaper path or a theme name, short enough that no single
# row can push the rest off the screen. Values are rendered for display here,
# never re-parsed, so a truncated one costs nothing.
VALUE_LIMIT = 120
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 render(value) -> str:
"""One setting's value as a line of text a person can compare.
Rendered here rather than in the page because the page would have to know
the difference between a JSON setting and a scalar one to do it, and that
knowledge already lives in the schema on this side. A value with no entry
at all is "not set" rather than "null": the two look identical in JSON and
mean quite different things to somebody reading a diff.
"""
if value is None:
return "not set"
if isinstance(value, bool):
return "on" if value else "off"
if isinstance(value, (int, float)):
return f"{value:g}" if isinstance(value, float) else str(value)
if isinstance(value, str):
text = value
else:
text = json.dumps(value, separators=(",", ":"), sort_keys=True)
text = " ".join(text.split())
return text if len(text) <= VALUE_LIMIT else text[:VALUE_LIMIT - 1] + "…"
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": render(current.get(key)),
"to": render(value)})
return {
"path": str(Path(path).expanduser()),
"exportedFrom": str(bundle.get("exportedFrom", "")),
"exportedAt": int(bundle.get("exportedAt", 0)),
"changes": changes[:CHANGE_LIMIT],
# What the list would have held uncapped, so the page can say "and 12
# more" rather than quietly showing forty of fifty-two.
"changeCount": len(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:]))