# deck-it - configuration loading and validation # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Configuration loading and validation for deck-it.""" import os import re import sys import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent MONITOR_NAME = re.compile(r"[A-Za-z0-9_-]{1,32}") WIFI_ADDR = re.compile(r"[A-Za-z0-9.-]{1,253}:[0-9]{1,5}") DESKTOP_ID = re.compile(r"[A-Za-z0-9._-]{1,255}") def user_config_path() -> Path: return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "deck-it" / "config.toml" def config_error(cfg: dict) -> str | None: d, ports = cfg.get("display", {}), cfg.get("ports", {}) if not MONITOR_NAME.fullmatch(str(d.get("name", "TAB"))): return "display.name must be letters, digits, - or _" if not isinstance(d.get("enabled", True), bool): return "display.enabled must be true or false" refresh = d.get("refresh", 60) if not isinstance(refresh, int) or not 1 <= refresh <= 240: return "display.refresh must be a number from 1 to 240" for key in ("command_center", "display"): if key in ports: v = ports[key] if not isinstance(v, int) or not 1024 <= v <= 65535: return f"ports.{key} must be a number from 1024 to 65535" if ports.get("command_center", 8765) == ports.get("display", 5900): return "ports.command_center and ports.display must differ" addr = cfg.get("wifi", {}).get("address", "") if addr and not (isinstance(addr, str) and WIFI_ADDR.fullmatch(addr)): return "wifi.address must be host:port, like 192.0.2.10:37099" apps = cfg.get("launch", {}).get("apps", []) if not isinstance(apps, list) or not all(isinstance(a, str) and DESKTOP_ID.fullmatch(a) for a in apps): return "launch.apps must be a list of desktop ids, like \"kitty\"" return None def merge(base: dict, over: dict) -> dict: """The user's file over the defaults, key by key within each table.""" out = dict(base) for key, value in over.items(): both = isinstance(value, dict) and isinstance(base.get(key), dict) out[key] = {**base[key], **value} if both else value return out def defaults() -> dict: with open(ROOT / "config.toml", "rb") as f: return tomllib.load(f) def load_config() -> dict: cfg, path = defaults(), ROOT / "config.toml" if user_config_path().is_file(): path = user_config_path() with open(path, "rb") as f: cfg = merge(cfg, tomllib.load(f)) if err := config_error(cfg): sys.exit(f"{path}: {err}") return cfg