From edaad8e9f6e918c783fd8fb9237fa33032194ed5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 20:35:26 +0100 Subject: feat: launcher icons come from the configured icon theme --- NOTICE | 8 ++++---- config.toml | 20 ++++++++++++-------- deckit/server.py | 20 ++++++++++++++++++++ static/deck.css | 13 +------------ static/logos/firefox.svg | 1 - static/logos/folder.svg | 15 --------------- static/logos/obsstudio.svg | 1 - static/logos/signal.svg | 1 - static/logos/terminal.svg | 16 ---------------- static/modules/windows.js | 6 +++--- tests/test_server.py | 14 ++++++++++++++ 11 files changed, 54 insertions(+), 61 deletions(-) delete mode 100644 static/logos/firefox.svg delete mode 100644 static/logos/folder.svg delete mode 100644 static/logos/obsstudio.svg delete mode 100644 static/logos/signal.svg delete mode 100644 static/logos/terminal.svg diff --git a/NOTICE b/NOTICE index 7aa17d1..c0897fe 100644 --- a/NOTICE +++ b/NOTICE @@ -11,7 +11,7 @@ Gridstack.js (https://gridstackjs.com) is vendored under `static/vendor/gridstack/`, MIT License. Copyright (c) 2019-2025 Alain Dumesny and contributors. -The brand marks under `static/logos/` (Claude, OpenCode, OBS Studio, Firefox, -Signal) are from Simple Icons (https://simpleicons.org), released under CC0 -1.0. The `terminal` and `folder` icons are from Lucide (https://lucide.dev), -ISC. The marks themselves are trademarks of their respective owners. +The Claude and OpenCode brand marks under `static/logos/` are from Simple Icons +(https://simpleicons.org), released under CC0 1.0. Launcher icons are read from +the installed icon theme (see `[icons] theme`), not bundled. The marks are +trademarks of their respective owners. diff --git a/config.toml b/config.toml index 9a50512..66ece24 100644 --- a/config.toml +++ b/config.toml @@ -60,12 +60,16 @@ host = "buildsystem" # Workspaces shown on the Windows tab, including empty ones. workspaces = [1, 2, 3, 4, 5, 6, 7, 8] +[icons] +# Launcher icons are read from this installed icon theme. +theme = "candy-icons" + [launch] -# Buttons on the Windows tab. `icon` is an SVG name from static/logos; 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 = "terminal", command = "kitty" } -files = { icon = "folder", command = "xdg-open ~" } -obs = { icon = "obsstudio", command = "obs" } -firefox = { icon = "firefox", command = "firefox" } -signal = { icon = "signal", command = "signal-desktop" } +# 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" } diff --git a/deckit/server.py b/deckit/server.py index 638322f..01e7ec1 100644 --- a/deckit/server.py +++ b/deckit/server.py @@ -9,6 +9,7 @@ import hmac import json import mimetypes import os +import re import secrets import socketserver import subprocess @@ -24,8 +25,11 @@ ROOT = Path(__file__).resolve().parent.parent STATIC = ROOT / "static" RUNDIR = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "deck-it" SOCKET = RUNDIR / "http.sock" +ICON = re.compile(r"[A-Za-z0-9._-]{1,64}") +ICON_BASES = (Path.home() / ".local/share/icons", Path("/usr/share/icons"), Path.home() / ".icons") CFG = config.load_config() +ICON_THEME = CFG.get("icons", {}).get("theme", "candy-icons") MONITOR = CFG.get("display", {}).get("name", "TAB") PORT = CFG.get("ports", {}).get("command_center", 8765) VNC_PORT = CFG.get("ports", {}).get("display", 5900) @@ -47,6 +51,18 @@ def _run(cmd, timeout=2): return "" +def icon_path(name: str, theme: str = ICON_THEME, bases=ICON_BASES): + """An app icon from the installed icon theme, so launchers follow the theme.""" + if not ICON.fullmatch(name): + return None + for base in bases: + for sub in ("apps/scalable", "apps/64", "apps/48", "apps/32"): + path = Path(base) / theme / sub / f"{name}.svg" + if path.is_file(): + return path + return None + + def art(): """The current track's cover art, if the player exposes a local file.""" url = _run(["playerctl", "metadata", "mpris:artUrl"]) @@ -114,6 +130,10 @@ class Handler(BaseHTTPRequestHandler): return if path == "/theme.css": return self._send(200, theme.load().encode(), "text/css") + if path.startswith("/icons/"): + name = path[len("/icons/"):].removesuffix(".svg") + icon = icon_path(name) + return self._send(200, icon.read_bytes(), "image/svg+xml", cache=True) if icon else self._send(404) if path.startswith("/api/"): if not self._authed(): return self._send(401) diff --git a/static/deck.css b/static/deck.css index 1a09141..c56a7e5 100644 --- a/static/deck.css +++ b/static/deck.css @@ -250,18 +250,7 @@ select { } .wtile.on { background: var(--udt-accent); color: var(--udt-crust); border-color: var(--udt-accent); } .wtile.launch { min-width: 64px; min-height: 64px; padding: 0 12px; } -.laticon { - display: block; - width: 30px; - height: 30px; - background: var(--udt-text); - -webkit-mask-repeat: no-repeat; - -webkit-mask-position: center; - -webkit-mask-size: contain; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; -} +.laticon { display: block; width: 32px; height: 32px; object-fit: contain; } .windows .winlist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; } .wrow { display: flex; diff --git a/static/logos/firefox.svg b/static/logos/firefox.svg deleted file mode 100644 index b32c6a4..0000000 --- a/static/logos/firefox.svg +++ /dev/null @@ -1 +0,0 @@ -Firefox \ No newline at end of file diff --git a/static/logos/folder.svg b/static/logos/folder.svg deleted file mode 100644 index e45b3ce..0000000 --- a/static/logos/folder.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/static/logos/obsstudio.svg b/static/logos/obsstudio.svg deleted file mode 100644 index 090e856..0000000 --- a/static/logos/obsstudio.svg +++ /dev/null @@ -1 +0,0 @@ -OBS Studio \ No newline at end of file diff --git a/static/logos/signal.svg b/static/logos/signal.svg deleted file mode 100644 index 81bc449..0000000 --- a/static/logos/signal.svg +++ /dev/null @@ -1 +0,0 @@ -Signal \ No newline at end of file diff --git a/static/logos/terminal.svg b/static/logos/terminal.svg deleted file mode 100644 index f91c114..0000000 --- a/static/logos/terminal.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/static/modules/windows.js b/static/modules/windows.js index 018cc6a..002bb5b 100644 --- a/static/modules/windows.js +++ b/static/modules/windows.js @@ -67,10 +67,10 @@ export default { b.setAttribute("aria-label", item.label || item.key); b.title = item.label || item.key; if (item.icon) { - const icon = document.createElement("span"); + const icon = document.createElement("img"); icon.className = "laticon"; - icon.style.webkitMaskImage = `url(/logos/${item.icon}.svg)`; - icon.style.maskImage = `url(/logos/${item.icon}.svg)`; + icon.src = `/icons/${item.icon}.svg`; + icon.alt = ""; b.append(icon); } else { b.textContent = item.label || item.key; diff --git a/tests/test_server.py b/tests/test_server.py index 3340398..1e942c2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -91,6 +91,20 @@ class Server(unittest.TestCase): h = {**HOST, **ORIGIN, "Content-Type": "application/json"} self.assertEqual(self.req("POST", "/api/demo/boom", h, '{"x": null}')[0], 400) + def test_icons_route_rejects_traversal(self): + self.assertEqual(self.req("GET", "/icons/..%2Fconfig.toml", HOST)[0], 404) + + def test_icon_path_resolves_and_rejects(self): + import tempfile + from pathlib import Path + base = Path(tempfile.mkdtemp()) + d = base / "candy-icons" / "apps" / "scalable" + d.mkdir(parents=True) + (d / "obs.svg").write_text("") + self.assertEqual(srv.icon_path("obs", "candy-icons", [base]), d / "obs.svg") + self.assertIsNone(srv.icon_path("../x", "candy-icons", [base])) + self.assertIsNone(srv.icon_path("nope", "candy-icons", [base])) + def test_art_route(self): from unittest import mock with mock.patch.object(srv, "art", return_value=(b"PNGDATA", "image/png")): -- cgit v1.2.3