#!/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:]))