diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-10 10:03:00 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-10 10:03:00 +0100 |
| commit | 819745a39220b49e848422b4bf0ee71dc317fc4d (patch) | |
| tree | 17475ac554a44eec1f9adee45ad0be3a437509d3 | |
| parent | 5ebfb5850713c038e37baddbadde3320aca2c4e4 (diff) | |
| download | deck-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>
| -rw-r--r-- | AGENTS.md | 15 | ||||
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | config.toml | 22 | ||||
| -rwxr-xr-x | deck-it | 63 | ||||
| -rw-r--r-- | deckit/__main__.py | 9 | ||||
| -rw-r--r-- | deckit/config.py | 32 | ||||
| -rw-r--r-- | deckit/modules/windows.py | 80 | ||||
| -rw-r--r-- | static/modules/windows.js | 2 | ||||
| -rw-r--r-- | tests/test_config.py | 10 | ||||
| -rw-r--r-- | tests/test_launcher.py | 2 | ||||
| -rw-r--r-- | tests/test_windows.py | 34 |
11 files changed, 200 insertions, 88 deletions
@@ -33,8 +33,19 @@ Keep the server dependency-free apart from that one package. monitor for `position = "auto-below"`, and paints it with the first monitor's swaybg image. - The launcher (`deck-it`) handles USB and Wi-Fi (`pair`/`wifi`), the tunnels, - wayvnc and AVNC. `adb mdns` may be unsupported; `deck-it wifi <ip:port>` takes - an explicit address. + wayvnc and AVNC. `adb mdns` may be unsupported; `deck-it wifi <ip:port>` or + `[wifi] address` gives an explicit one; otherwise the last address that + worked (`~/.config/deck-it/last-wifi`, kept across `stop` and reboots). + `[display] enabled = false` skips the headless output, wayvnc and AVNC. + `deck-it status` prints one JSON line for keystone's page; keep its keys. +- Config: `deckit/config.py` merges `~/.config/deck-it/config.toml` over the + shipped `config.toml` per key within each table (a list is replaced whole). + Keystone's deck-it page (quickshell repo, `desktop/modules/deckit`) writes + that sparse file and reads the defaults from `deck-it defaults`; keep that + JSON shape stable. +- Launchers are `[launch] apps`, desktop ids run with `gtk-launch`; label and + icon come from the `.desktop` file. The client sends the id, which must be + configured and installed. ## Conventions @@ -27,13 +27,14 @@ there is no cloud, account or app-store install. ```sh git clone https://git.danix.xyz/deck-it ~/Programming/GIT/deck-it -cp ~/Programming/GIT/deck-it/config.toml ~/.config/deck-it/config.toml # optional -~/Programming/GIT/deck-it/deck-it +ln -s ~/Programming/GIT/deck-it/deck-it ~/bin/deck-it +deck-it ``` The launcher creates the headless output, starts wayvnc and the server, tunnels them to the tablet, and opens the dashboard and AVNC. `deck-it stop` tears it -down. +down. For the dashboard alone, set `enabled = false` under `[display]`: no +headless output, wayvnc or AVNC. Over Wi-Fi, pair once and pass the tablet's Wireless debugging address: @@ -42,13 +43,19 @@ deck-it pair deck-it wifi 192.168.1.50:41234 ``` +Keystone's settings window has a deck-it page: a Start at login switch (it +adds `deck-it wifi --wait` to hypr-theme's autostart) and every config key, +launchers included, with a Restart button to apply a save. + ## Usage - Tabs: **Dashboard** (draggable/resizable cards), **Windows**, **OBS**. Swipe the content sideways to change tab. -- `deck-it install` prints the `exec-once` line for hypr-theme's autostart. -- Configuration lives in `config.toml`: workspaces, launcher icons, OBS - websocket, llm-cost-dashboard URL, build VM host, hardware overrides. +- `deck-it install` prints the `exec-once` line for USB autostart. +- `config.toml` holds the defaults: display, Wi-Fi address, workspaces, + launchers (desktop ids), OBS websocket, llm-cost-dashboard URL, build VM + host, hardware overrides. `~/.config/deck-it/config.toml` overrides any of + them key by key and needs only what differs; keystone writes it. - The OBS tab is a stream-deck: scenes, sources, audio sliders, transitions. Long-press a source to open its properties dialog in OBS. - The Windows tab has workspaces, icon launchers, and a per-window `⋮` menu to diff --git a/config.toml b/config.toml index 797d3d8..17a907d 100644 --- a/config.toml +++ b/config.toml @@ -1,10 +1,13 @@ # deck-it configuration. -# Copy to ~/.config/deck-it/config.toml to customize; that file replaces this one entirely. +# Override any key in ~/.config/deck-it/config.toml: that file needs only the +# keys it changes, and keystone's deck-it page writes it. # "auto" picks "lua" when ~/.config/hypr/hyprland.lua exists, otherwise "classic". hyprland = "auto" [display] +# false runs the dashboard only: no virtual monitor, wayvnc or AVNC. +enabled = true # Resolution of the virtual monitor. "auto" reads it from the tablet over adb. resolution = "auto" # "auto-below" centers the tablet under the first monitor; any Hyprland @@ -15,6 +18,11 @@ refresh = 60 # Hyprland output name for the virtual monitor. name = "TAB" +[wifi] +# The tablet's Wireless debugging address, ip:port, tried first by +# `deck-it wifi`. Empty: the last address used, then mDNS discovery. +address = "" + [ports] # Ports on the tablet's localhost, tunneled with adb reverse. command_center = 8765 @@ -66,11 +74,7 @@ workspaces = [1, 2, 3, 4, 5, 6, 7, 8] theme = "candy-icons" [launch] -# Buttons on the Windows tab. `icon` is an app icon name from the icon theme; -# with one, the button is icon-only. `command` is a string (sh -c) or a list -# (run direct). The client sends the key, never a command. -terminal = { icon = "kitty", command = "kitty" } -files = { icon = "file-manager", command = "xdg-open ~" } -obs = { icon = "obs", command = "obs" } -firefox = { icon = "firefox-icon", command = "firefox" } -signal = { icon = "signal-desktop", command = "signal-desktop" } +# Buttons on the Windows tab: desktop ids, the .desktop file names without the +# extension, in order. Each shows its app's icon and runs through gtk-launch; +# the client sends the id, never a command. +apps = ["kitty", "pcmanfm-qt", "com.obsproject.Studio", "firefox", "signal-desktop"] @@ -17,9 +17,11 @@ # Usage: deck-it start the dashboard + extended display over USB # deck-it stop tear it all down -# deck-it env print MON/PORT/VNC_PORT for this config +# deck-it status print what is running, as JSON +# deck-it env print MON/PORT/VNC_PORT/EXTEND/WIFI_ADDR for this config +# deck-it defaults print the shipped config as JSON # deck-it pair pair the tablet for Wi-Fi by scanning a QR code -# deck-it wifi start over Wi-Fi +# deck-it wifi [ip:port] start over Wi-Fi; --wait keeps looking (for autostart) # deck-it install print the exec-once line for hypr-theme's autostart # deck-it uninstall forget the generated trigger # deck-it watch-usb start on plug-in, tear down on unplug (run from exec-once) @@ -30,11 +32,11 @@ CONF="${XDG_CONFIG_HOME:-$HOME/.config}/deck-it" VNC="$CONF/vnc" RUN="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/deck-it" -if [[ "${1:-}" == "env" ]]; then - (cd "$DIR" && exec python3 -m deckit env) +if [[ "${1:-}" == env || "${1:-}" == defaults ]]; then + cd "$DIR" && exec python3 -m deckit "$1" fi -MON=TAB PORT=8765 VNC_PORT=5900 +MON=TAB PORT=8765 VNC_PORT=5900 EXTEND=1 WIFI_ADDR= env_out=$(cd "$DIR" && python3 -m deckit env 2>&1) && eval "$env_out" || echo "Config problem: $env_out" >&2 teardown() { @@ -51,8 +53,10 @@ teardown() { } tunnel() { - adb -s "$1" reverse "tcp:$VNC_PORT" "localfilesystem:$RUN/vnc.sock" >/dev/null && - adb -s "$1" reverse "tcp:$PORT" "localfilesystem:$RUN/http.sock" >/dev/null + if [[ "$EXTEND" == 1 ]]; then + adb -s "$1" reverse "tcp:$VNC_PORT" "localfilesystem:$RUN/vnc.sock" >/dev/null || return 1 + fi + adb -s "$1" reverse "tcp:$PORT" "localfilesystem:$RUN/http.sock" >/dev/null } # The display stream needs a password over TLS; both live in a 0700 dir and AVNC gets them in the link. @@ -69,6 +73,7 @@ vnc_setup() { } open_avnc() { + [[ "$EXTEND" == 1 ]] || return 0 adb -s "$1" shell am start -a android.intent.action.VIEW \ -d "'vnc://127.0.0.1:$VNC_PORT?VncUsername=deck-it&VncPassword=$(cat "$VNC/password")'" com.gaurav.avnc >/dev/null 2>&1 } @@ -80,9 +85,10 @@ alive() { wifi_connect() { local addr - # An explicit address, or the last one used. Never "any TCP device": a TV - # or another device with ADB on the network would be picked instead. - for addr in ${WIFI_ADDR:-} $(cat "$RUN/serial" 2>/dev/null); do + # An explicit address, or the last one used (this session's, then the last + # that worked, which survives a stop and a reboot). Never "any TCP device": + # a TV or another device with ADB on the network would be picked instead. + for addr in ${WIFI_ADDR:-} $(cat "$RUN/serial" "$CONF/last-wifi" 2>/dev/null); do [ -n "$addr" ] || continue timeout 5 adb connect "$addr" 2>/dev/null | grep -q '^connected\|already connected' || true alive "$addr" && { echo "$addr"; return 0; } @@ -99,7 +105,24 @@ usb_present() { } case "${1:-}" in +status) + # One JSON line for keystone's deck-it page. tablet is the device whose + # adb reverse carries the dashboard port, "" when none does. + server=false display=false waiting=false tablet="" + pgrep -f '[p]ython3 -m deckit' >/dev/null && server=true + pgrep -x wayvnc >/dev/null && display=true + pgrep -f '^bash [^ ]*/deck-it wifi --wait' >/dev/null && waiting=true + for s in $(timeout 3 adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" {print $1}'); do + timeout 3 adb -s "$s" reverse --list 2>/dev/null | grep -q "tcp:$PORT " && { tablet=$s; break; } + done + printf '{"server": %s, "display": %s, "waiting": %s, "tablet": "%s", "extend": %s}\n' \ + "$server" "$display" "$waiting" "$tablet" "$([[ $EXTEND == 1 ]] && echo true || echo false)" + exit 0 + ;; stop) + # A login's `wifi --wait` may still be looking; stop that too, by its own + # command line only, never a shell that merely mentions it. + pkill -f '^bash [^ ]*/deck-it wifi --wait' 2>/dev/null teardown echo "Stopped" exit 0 @@ -192,7 +215,7 @@ wifi) ANDROID_SERIAL=$(adb -d get-serialno) ;; *) - sed -n '2,9p' "$SELF" | sed 's/^# \{0,1\}//' + sed -n '/^# Usage:/,/^[^#]/s/^# \{0,1\}//p' "$SELF" exit 1 ;; esac @@ -200,13 +223,14 @@ export ANDROID_SERIAL for _ in {1..30}; do hyprctl monitors >/dev/null 2>&1 && break; sleep 1; done -(cd "$DIR" && python3 -m deckit setup-display) || exit 1 - # Both servers listen on sockets in a 0700 dir, so only this user and its adb tunnel can reach them. mkdir -p -m 700 "$RUN" -vnc_setup -# wayvnc leaves its socket behind when killed and then refuses to bind, so clear it first. -pgrep -x wayvnc >/dev/null || { rm -f "$RUN/vnc.sock"; setsid wayvnc -C "$VNC/config" -u -o "$MON" "$RUN/vnc.sock" >/dev/null 2>&1 & } +if [[ "$EXTEND" == 1 ]]; then + (cd "$DIR" && python3 -m deckit setup-display) || exit 1 + vnc_setup + # wayvnc leaves its socket behind when killed and then refuses to bind, so clear it first. + pgrep -x wayvnc >/dev/null || { rm -f "$RUN/vnc.sock"; setsid wayvnc -C "$VNC/config" -u -o "$MON" "$RUN/vnc.sock" >/dev/null 2>&1 & } +fi pgrep -f '[p]ython3 -m deckit' >/dev/null || (cd "$DIR" && setsid python3 -m deckit >server.log 2>&1 &) sleep 1 @@ -221,7 +245,12 @@ adb shell am start -a android.intent.action.VIEW -d "http://127.0.0.1:$PORT/?t=$ if [[ "${1:-}" == wifi ]]; then echo "$ANDROID_SERIAL" > "$RUN/serial" + mkdir -p "$CONF" && echo "$ANDROID_SERIAL" > "$CONF/last-wifi" pgrep -f "^bash $SELF watch" >/dev/null || setsid bash "$SELF" watch >/dev/null 2>&1 & fi -echo "Ready. Display is in AVNC, dashboard at http://127.0.0.1:$PORT" +if [[ "$EXTEND" == 1 ]]; then + echo "Ready. Display is in AVNC, dashboard at http://127.0.0.1:$PORT" +else + echo "Ready. Dashboard at http://127.0.0.1:$PORT (display disabled)" +fi 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, diff --git a/static/modules/windows.js b/static/modules/windows.js index d0a5a6c..d3e882e 100644 --- a/static/modules/windows.js +++ b/static/modules/windows.js @@ -130,6 +130,8 @@ export default { icon.className = "laticon"; icon.src = `/icons/${item.icon}.svg`; icon.alt = ""; + // An app whose icon the theme lacks shows its name instead. + icon.onerror = () => { b.textContent = item.label || item.key; }; b.append(icon); } else { b.textContent = item.label || item.key; diff --git a/tests/test_config.py b/tests/test_config.py index 879917c..928a8e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,11 +35,19 @@ class Config(unittest.TestCase): def test_rejects_bad_values(self): bad = [{"display": {"name": "TAB;rm"}}, {"display": {"refresh": 0}}, + {"display": {"enabled": "no"}}, {"ports": {"command_center": 80}}, - {"ports": {"command_center": 5900, "display": 5900}}] + {"ports": {"command_center": 5900, "display": 5900}}, + {"wifi": {"address": "x; rm -rf ~"}}, + {"launch": {"apps": ["ok", "../evil"]}}] for cfg in bad: self.assertIsNotNone(config.config_error(cfg), cfg) + def test_user_file_overrides_key_by_key(self): + cfg = config.merge({"display": {"enabled": True, "scale": 1}, "launch": {"apps": ["a", "b"]}}, + {"display": {"enabled": False}, "launch": {"apps": ["c"]}}) + self.assertEqual(cfg, {"display": {"enabled": False, "scale": 1}, "launch": {"apps": ["c"]}}) + def test_user_config_path_honours_xdg(self): self.assertEqual(config.user_config_path().name, "config.toml") self.assertEqual(config.user_config_path().parent.name, "deck-it") diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 0c16b46..06e5724 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -28,7 +28,7 @@ class Launcher(unittest.TestCase): def test_env_prints_keys(self): out = subprocess.run(["bash", str(ROOT / "deck-it"), "env"], capture_output=True, text=True, cwd=ROOT).stdout - for key in ("MON=", "PORT=", "VNC_PORT="): + for key in ("MON=", "PORT=", "VNC_PORT=", "EXTEND="): self.assertIn(key, out) diff --git a/tests/test_windows.py b/tests/test_windows.py index e52e23d..e632406 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -31,10 +31,12 @@ class DesktopIcons(unittest.TestCase): from pathlib import Path base = Path(tempfile.mkdtemp()) (base / "com.obsproject.Studio.desktop").write_text( - "[Desktop Entry]\nIcon=com.obsproject.Studio\nStartupWMClass=obs\n") - mapping = windows._desktop_icons([base]) + "[Desktop Entry]\nName=OBS Studio\nIcon=com.obsproject.Studio\nStartupWMClass=obs\n" + "[Desktop Action x]\nName=Other\n") + mapping = windows._desktop_icons(windows._desktop_entries([base])) self.assertEqual(mapping["com.obsproject.studio"], ["com.obsproject.Studio", "obs"]) self.assertEqual(mapping["obs"], ["com.obsproject.Studio", "obs"]) + self.assertEqual(windows._desktop_entries([base])["com.obsproject.Studio"]["name"], "OBS Studio") class Move(unittest.TestCase): @@ -86,14 +88,30 @@ class Commands(unittest.TestCase): ("hyprctl", "activewindow", "-j"): "{}", ("hyprctl", "workspaces", "-j"): '[{"id": 3}]', } - m = windows.make({ - "windows": {"workspaces": [1, 2, 3, 4]}, - "launch": {"obs": {"icon": "obsstudio", "command": "obs"}}, - "run": lambda cmd: responses[tuple(cmd)], - }) + entries = {"com.obsproject.Studio": {"name": "OBS Studio", "icon": "obs", "wmclass": None}} + with mock.patch.object(windows, "_desktop_entries", lambda: entries): + m = windows.make({ + "windows": {"workspaces": [1, 2, 3, 4]}, + "launch": {"apps": ["com.obsproject.Studio", "not-installed"]}, + "run": lambda cmd: responses[tuple(cmd)], + }) st = m.state() self.assertEqual(st["workspaces"], [1, 2, 3, 4]) - self.assertEqual(st["launches"], [{"key": "obs", "icon": "obsstudio", "label": "obs"}]) + self.assertEqual(st["launches"], + [{"key": "com.obsproject.Studio", "icon": "obs", "label": "OBS Studio"}]) + + def test_launch_runs_only_listed_installed_apps(self): + entries = {"kitty": {"name": "kitty", "icon": "kitty", "wmclass": None}, + "other": {"name": "Other", "icon": None, "wmclass": None}} + calls = [] + with mock.patch.object(windows, "_desktop_entries", lambda: entries): + m = windows.make({"launch": {"apps": ["kitty", "gone"]}, "run": lambda cmd: "[]"}) + with mock.patch.object(windows, "_popen", calls.append): + m.actions["launch"]({"key": "kitty"}) + for bad in ("other", "gone", "kitty; rm"): + with self.assertRaises(windows.ModuleError): + m.actions["launch"]({"key": bad}) + self.assertEqual(calls, [["gtk-launch", "kitty"]]) if __name__ == "__main__": |
