aboutsummaryrefslogtreecommitdiffstats
path: root/deckit
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-10 10:03:00 +0100
committerDanilo M. <danix@danix.xyz>2026-11-10 10:03:00 +0100
commit819745a39220b49e848422b4bf0ee71dc317fc4d (patch)
tree17475ac554a44eec1f9adee45ad0be3a437509d3 /deckit
parent5ebfb5850713c038e37baddbadde3320aca2c4e4 (diff)
downloaddeck-it-819745a39220b49e848422b4bf0ee71dc317fc4d.tar.gz
deck-it-819745a39220b49e848422b4bf0ee71dc317fc4d.zip
feat: dashboard-only mode and keystone settings integration
The second display is now optional: [display] enabled = false skips the headless output, wayvnc, AVNC and the VNC tunnel, so the tablet runs the dashboard alone. `deck-it env` reports it as EXTEND. Keystone's new deck-it page (quickshell, desktop/modules/deckit) edits the config and the autostart line, which needed: - The user config is merged over the shipped config.toml key by key within each table instead of replacing it whole, so the page writes a sparse file and an unset key falls back to the default. - `deck-it defaults` prints the shipped config as JSON for the page's placeholders, so keystone never copies the defaults. - `deck-it status` prints one JSON line (server, display, waiting, tablet, extend) for the page's status line and restart check. - [launch] is now `apps`, a list of desktop ids run with gtk-launch, with name and icon read from the .desktop file, so the page can pick launchers from the installed apps. The old command tables are gone; the client still sends only an id, which must be listed and installed. A launcher whose icon the theme lacks shows its name. - [wifi] address gives the tablet's address in config. The last address that worked is kept in ~/.config/deck-it/last-wifi: `stop` deletes the runtime serial file, so a restart from the page used to wait forever. - `stop` also ends a login's `wifi --wait`, matched by its own command line only, so repeated restarts do not pile up waiters. Also fixed: `deck-it env` fell through into the usage text, and the usage text printed the license header since the header was added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'deckit')
-rw-r--r--deckit/__main__.py9
-rw-r--r--deckit/config.py32
-rw-r--r--deckit/modules/windows.py80
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,