350 lines
14 KiB
Python
Executable File
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:]))
|