aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--AGENTS.md15
-rw-r--r--README.md19
-rw-r--r--config.toml22
-rwxr-xr-xdeck-it63
-rw-r--r--deckit/__main__.py9
-rw-r--r--deckit/config.py32
-rw-r--r--deckit/modules/windows.py80
-rw-r--r--static/modules/windows.js2
-rw-r--r--tests/test_config.py10
-rw-r--r--tests/test_launcher.py2
-rw-r--r--tests/test_windows.py34
11 files changed, 200 insertions, 88 deletions
diff --git a/AGENTS.md b/AGENTS.md
index c25dd1f..e1d804e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/README.md b/README.md
index c84bbb2..42f16ae 100644
--- a/README.md
+++ b/README.md
@@ -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"]
diff --git a/deck-it b/deck-it
index 8e0ed21..5e77ac7 100755
--- a/deck-it
+++ b/deck-it
@@ -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__":