diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-09 20:35:26 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-09 20:35:26 +0100 |
| commit | edaad8e9f6e918c783fd8fb9237fa33032194ed5 (patch) | |
| tree | 6c28c38cde47bd640531f6140d0d5dabc7c95813 | |
| parent | 6b323fc06ec255153e86eafa4dffa67ad8c21f8c (diff) | |
| download | deck-it-edaad8e9f6e918c783fd8fb9237fa33032194ed5.tar.gz deck-it-edaad8e9f6e918c783fd8fb9237fa33032194ed5.zip | |
feat: launcher icons come from the configured icon theme
| -rw-r--r-- | NOTICE | 8 | ||||
| -rw-r--r-- | config.toml | 20 | ||||
| -rw-r--r-- | deckit/server.py | 20 | ||||
| -rw-r--r-- | static/deck.css | 13 | ||||
| -rw-r--r-- | static/logos/firefox.svg | 1 | ||||
| -rw-r--r-- | static/logos/folder.svg | 15 | ||||
| -rw-r--r-- | static/logos/obsstudio.svg | 1 | ||||
| -rw-r--r-- | static/logos/signal.svg | 1 | ||||
| -rw-r--r-- | static/logos/terminal.svg | 16 | ||||
| -rw-r--r-- | static/modules/windows.js | 6 | ||||
| -rw-r--r-- | tests/test_server.py | 14 |
11 files changed, 54 insertions, 61 deletions
@@ -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 @@ -<svg fill="#FF7139" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Firefox</title><path d="M20.452 3.445a11.002 11.002 0 00-2.482-1.908C16.944.997 15.098.093 12.477.032c-.734-.017-1.457.03-2.174.144-.72.114-1.398.292-2.118.56-1.017.377-1.996.975-2.574 1.554.583-.349 1.476-.733 2.55-.992a10.083 10.083 0 013.729-.167c2.341.34 4.178 1.381 5.48 2.625a8.066 8.066 0 011.298 1.587c1.468 2.382 1.33 5.376.184 7.142-.85 1.312-2.67 2.544-4.37 2.53-.583-.023-1.438-.152-2.25-.566-2.629-1.343-3.021-4.688-1.118-6.306-.632-.136-1.82.13-2.646 1.363-.742 1.107-.7 2.816-.242 4.028a6.473 6.473 0 01-.59-1.895 7.695 7.695 0 01.416-3.845A8.212 8.212 0 019.45 5.399c.896-1.069 1.908-1.72 2.75-2.005-.54-.471-1.411-.738-2.421-.767C8.31 2.583 6.327 3.061 4.7 4.41a8.148 8.148 0 00-1.976 2.414c-.455.836-.691 1.659-.697 1.678.122-1.445.704-2.994 1.248-4.055-.79.413-1.827 1.668-2.41 3.042C.095 9.37-.2 11.608.14 13.989c.966 5.668 5.9 9.982 11.843 9.982C18.62 23.971 24 18.591 24 11.956a11.93 11.93 0 00-3.548-8.511z"/></svg>
\ 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 @@ -<!-- @license lucide-static v0.460.0 - ISC --> -<svg - class="lucide lucide-folder" - xmlns="http://www.w3.org/2000/svg" - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="1.5" - stroke-linecap="round" - stroke-linejoin="round" -> - <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /> -</svg> 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 @@ -<svg fill="#302E31" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OBS Studio</title><path d="M12,24C5.383,24,0,18.617,0,12S5.383,0,12,0s12,5.383,12,12S18.617,24,12,24z M12,1.109 C5.995,1.109,1.11,5.995,1.11,12C1.11,18.005,5.995,22.89,12,22.89S22.89,18.005,22.89,12C22.89,5.995,18.005,1.109,12,1.109z M6.182,5.99c0.352-1.698,1.503-3.229,3.05-3.996c-0.269,0.273-0.595,0.483-0.844,0.78c-1.02,1.1-1.48,2.692-1.199,4.156 c0.355,2.235,2.455,4.06,4.732,4.028c1.765,0.079,3.485-0.937,4.348-2.468c1.848,0.063,3.645,1.017,4.7,2.548 c0.54,0.799,0.962,1.736,0.991,2.711c-0.342-1.295-1.202-2.446-2.375-3.095c-1.135-0.639-2.529-0.802-3.772-0.425 c-1.56,0.448-2.849,1.723-3.293,3.293c-0.377,1.25-0.216,2.628,0.377,3.772c-0.825,1.429-2.315,2.449-3.932,2.756 c-1.244,0.261-2.551,0.059-3.709-0.464c1.036,0.302,2.161,0.355,3.191-0.011c1.381-0.457,2.522-1.567,3.024-2.935 c0.556-1.49,0.345-3.261-0.591-4.54c-0.7-1.007-1.803-1.717-3.002-1.969c-0.38-0.068-0.764-0.098-1.148-0.134 c-0.611-1.231-0.834-2.66-0.528-3.996L6.182,5.99z"/></svg>
\ 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 @@ -<svg fill="#3B45FD" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Signal</title><path d="M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125"/></svg>
\ 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 @@ -<!-- @license lucide-static v0.460.0 - ISC --> -<svg - class="lucide lucide-terminal" - xmlns="http://www.w3.org/2000/svg" - width="24" - height="24" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="1.5" - stroke-linecap="round" - stroke-linejoin="round" -> - <polyline points="4 17 10 11 4 5" /> - <line x1="12" x2="20" y1="19" y2="19" /> -</svg> 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("<svg/>") + 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")): |
