aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--NOTICE8
-rw-r--r--config.toml20
-rw-r--r--deckit/server.py20
-rw-r--r--static/deck.css13
-rw-r--r--static/logos/firefox.svg1
-rw-r--r--static/logos/folder.svg15
-rw-r--r--static/logos/obsstudio.svg1
-rw-r--r--static/logos/signal.svg1
-rw-r--r--static/logos/terminal.svg16
-rw-r--r--static/modules/windows.js6
-rw-r--r--tests/test_server.py14
11 files changed, 54 insertions, 61 deletions
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 @@
-<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")):