diff options
Diffstat (limited to 'deckit')
| -rw-r--r-- | deckit/__main__.py | 9 | ||||
| -rw-r--r-- | deckit/config.py | 32 | ||||
| -rw-r--r-- | deckit/modules/windows.py | 80 |
3 files changed, 77 insertions, 44 deletions
diff --git a/deckit/__main__.py b/deckit/__main__.py index 5b6cda8..98a7169 100644 --- a/deckit/__main__.py +++ b/deckit/__main__.py @@ -14,7 +14,7 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -"""`python3 -m deckit [setup-display | env]`.""" +"""`python3 -m deckit [setup-display | env | defaults]`.""" import sys @@ -27,7 +27,12 @@ def main(): cfg = config.load_config() d, p = cfg.get("display", {}), cfg.get("ports", {}) print(f"MON={d.get('name', 'TAB')} PORT={p.get('command_center', 8765)} " - f"VNC_PORT={p.get('display', 5900)}") + f"VNC_PORT={p.get('display', 5900)} EXTEND={int(d.get('enabled', True))} " + f"WIFI_ADDR={cfg.get('wifi', {}).get('address', '')}") + elif args == ["defaults"]: + # For keystone's settings page: the shipped values a user file overrides. + import json + print(json.dumps(config.defaults())) elif args == ["setup-display"]: from .display import setup_display setup_display(config.load_config()) diff --git a/deckit/config.py b/deckit/config.py index aac5ad9..e20bc00 100644 --- a/deckit/config.py +++ b/deckit/config.py @@ -24,6 +24,8 @@ 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: @@ -34,6 +36,8 @@ 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" @@ -44,13 +48,35 @@ def config_error(cfg: dict) -> str | None: 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: - path = user_config_path() if user_config_path().is_file() else ROOT / "config.toml" - with open(path, "rb") as f: - cfg = tomllib.load(f) + 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 diff --git a/deckit/modules/windows.py b/deckit/modules/windows.py index 5457b16..1907b59 100644 --- a/deckit/modules/windows.py +++ b/deckit/modules/windows.py @@ -14,7 +14,7 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -"""Hyprland windows, workspaces, and launching config-declared commands.""" +"""Hyprland windows, workspaces, and launching config-listed apps.""" import json import re @@ -24,7 +24,8 @@ from pathlib import Path from ..module import Module, ModuleError ADDR = re.compile(r"0x[0-9a-f]{1,16}") -DESKTOP_BASES = (Path("/usr/share/applications"), Path.home() / ".local/share/applications") +# The user's own entries first, so they win over the system's, as in a launcher. +DESKTOP_BASES = (Path.home() / ".local/share/applications", Path("/usr/share/applications")) def _run(cmd, timeout=2): @@ -46,40 +47,47 @@ def _dispatch(cmd): pass -def _desktop_icons(bases=DESKTOP_BASES) -> dict: - """Window class -> candidate icon names, from the installed .desktop files.""" - mapping = {} +def _desktop_entries(bases=DESKTOP_BASES) -> dict: + """Desktop id -> {name, icon, wmclass}, from each [Desktop Entry] section.""" + entries = {} for base in bases: if not base.is_dir(): continue for entry in base.glob("*.desktop"): + if entry.stem in entries: + continue try: text = entry.read_text(errors="replace") except OSError: continue - section = None - icon = wmclass = None + found, inside = {}, False for line in text.splitlines(): line = line.strip() - if line.startswith("[") and line.endswith("]"): - section = line - if section != "[Desktop Entry]": + if line.startswith("["): + if inside: break - continue - if section != "[Desktop Entry]": - continue - if line.startswith("Icon="): - icon = line.split("=", 1)[1].strip() - elif line.startswith("StartupWMClass="): - wmclass = line.split("=", 1)[1].strip() - if not icon: - continue - candidates = [icon] - if wmclass and wmclass != icon: - candidates.append(wmclass) - for key in (entry.stem, wmclass): - if key: - mapping.setdefault(key.lower(), candidates) + inside = line == "[Desktop Entry]" + elif inside and "=" in line: + key, value = line.split("=", 1) + if key in ("Name", "Icon", "StartupWMClass"): + found.setdefault(key, value.strip()) + entries[entry.stem] = {"name": found.get("Name"), "icon": found.get("Icon"), + "wmclass": found.get("StartupWMClass")} + return entries + + +def _desktop_icons(entries: dict) -> dict: + """Window class -> candidate icon names, by desktop id and StartupWMClass.""" + mapping = {} + for stem, e in entries.items(): + if not e["icon"]: + continue + candidates = [e["icon"]] + if e["wmclass"] and e["wmclass"] != e["icon"]: + candidates.append(e["wmclass"]) + for key in (stem, e["wmclass"]): + if key: + mapping.setdefault(key.lower(), candidates) return mapping @@ -124,14 +132,10 @@ def _state(run): def make(cfg: dict) -> Module: run = cfg.get("run", _run) lua = hypr_lua(cfg) - launch = cfg.get("launch", {}) + apps = [str(a) for a in cfg.get("launch", {}).get("apps", [])] workspaces = cfg.get("windows", {}).get("workspaces", list(range(1, 11))) - icons = _desktop_icons() - - def launch_spec(spec): - if isinstance(spec, dict): - return spec.get("command"), spec.get("icon"), spec.get("label") - return spec, None, None + entries = _desktop_entries() + icons = _desktop_icons(entries) def workspace(body): n = body.get("n") @@ -164,21 +168,19 @@ def make(cfg: dict) -> Module: _dispatch(["hyprctl", "dispatch", "movetoworkspacesilent", f"{n},address:{addr}"]) def launch_key(body): + # Only a configured, installed desktop id; gtk-launch runs its Exec. key = str(body.get("key", "")) - command, _, _ = launch_spec(launch.get(key)) - if command is None: + if key not in apps or key not in entries: raise ModuleError("unknown launch key") - _popen(["sh", "-c", command] if isinstance(command, str) else command) + _popen(["gtk-launch", key]) def state(): st = _state(run) for win in st["windows"]: win["icons"] = icons.get(win["class"].lower(), [win["class"].lower()]) known = sorted(set(int(n) for n in workspaces) | set(st["workspaces"])) - launches = [] - for key, spec in launch.items(): - _, icon, label = launch_spec(spec) - launches.append({"key": key, "icon": icon, "label": label or key}) + launches = [{"key": a, "icon": entries[a]["icon"], "label": entries[a]["name"] or a} + for a in apps if a in entries] return {**st, "workspaces": known, "launches": launches} return Module(name="windows", title="Windows", state=state, |
