aboutsummaryrefslogtreecommitdiffstats
path: root/deckit/config.py
blob: e20bc0045ba4db3ae0a6c3ef219d0ddd7b9a8769 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# deck-it - configuration loading and validation
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# 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