From 18f78d2ebf70ea4dc3aa43e61119384958efd7f1 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:22:46 +0100 Subject: [PATCH 1/8] feat(settings): QR codes for network access addresses When server mode is on, show a scannable QR code for each local IP in the desktop settings panel. Each QR encodes http://{ip}:{port}/mobile/ so the phone camera opens the mobile UI directly. - Add segno (pure Python, no PIL) as a new dependency - Add GET /api/qr?url=... endpoint that returns an SVG QR code - Render one QR card per LAN address in the network settings section --- app/api/qr.py | 25 +++++++++++++++++++++++++ app/api/router.py | 2 ++ pyproject.toml | 3 +++ static/css/daw.css | 4 ++++ static/js/catalog.js | 23 +++++++++++++++++++++++ uv.lock | 11 +++++++++++ 6 files changed, 68 insertions(+) create mode 100644 app/api/qr.py diff --git a/app/api/qr.py b/app/api/qr.py new file mode 100644 index 0000000..e0087b0 --- /dev/null +++ b/app/api/qr.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import io + +import segno +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response + +router = APIRouter() + +_MAX_LEN = 500 + + +@router.get("/qr") +def get_qr(url: str) -> Response: + if not url or len(url) > _MAX_LEN: + raise HTTPException(status_code=422, detail="url required (max 500 chars)") + qr = segno.make_qr(url, error="m") + buf = io.BytesIO() + qr.save(buf, kind="svg", scale=5, border=2, dark="#1a1206", light="#ffffff") + return Response( + content=buf.getvalue(), + media_type="image/svg+xml", + headers={"Cache-Control": "public, max-age=3600"}, + ) diff --git a/app/api/router.py b/app/api/router.py index de9c3c8..1fbfbe8 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -5,6 +5,7 @@ from app.api.config import router as config_router from app.api.events import router as events_router from app.api.jobs import router as jobs_router +from app.api.qr import router as qr_router from app.api.stems import router as stems_router router = APIRouter() @@ -12,3 +13,4 @@ router.include_router(jobs_router, prefix="/jobs", tags=["jobs"]) router.include_router(events_router, tags=["events"]) router.include_router(stems_router, tags=["stems"]) +router.include_router(qr_router, tags=["qr"]) diff --git a/pyproject.toml b/pyproject.toml index 1ae36c2..904b3ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ dependencies = [ # decompression-bomb bypass fixed in 2.7.0. urllib3 is a transitive # dep via requests; pin floor to pull in the fix. "urllib3>=2.7.0", + # Pure-Python QR code generator used by the /api/qr endpoint (server + # access QR codes in the desktop settings panel). + "segno>=1.6", ] [project.optional-dependencies] diff --git a/static/css/daw.css b/static/css/daw.css index cfe24e6..527292a 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -811,6 +811,10 @@ input, textarea { font-family: inherit; } .settings-net-list { display: flex; flex-direction: column; gap: 5px; align-items: flex-start; } .settings-net-list code { color: var(--accent); background: rgba(244,183,64,0.1); padding: 3px 9px; border-radius: 5px; font-size: 11.5px; } .settings-net-empty { color: var(--muted); } +.settings-net-qr { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; } +.qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; } +.qr-card img { border-radius: 6px; border: 1px solid var(--border-strong); width: 130px; height: 130px; } +.qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } .settings-server-note { font-size: 10.5px; color: var(--muted); margin: 0 0 12px; line-height: 1.5; } .settings-subhead { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; margin: 4px 0 7px; } diff --git a/static/js/catalog.js b/static/js/catalog.js index c1ad1a4..5b33699 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1838,6 +1838,7 @@ function networkSettingsHtml() { `; @@ -1898,6 +1899,7 @@ async function wireNetworkSetting(overlay) { const input = overlay.querySelector(".net-access-input"); const netWrap = overlay.querySelector(".settings-net"); const list = overlay.querySelector(".settings-net-list"); + const qrWrap = overlay.querySelector(".settings-net-qr"); if (!input) return; let enabled = false; @@ -1929,6 +1931,27 @@ async function wireNetworkSetting(overlay) { } } + // QR codes: one per LAN address, each encodes the /mobile/ URL so the + // phone camera opens StemDeck directly. + if (qrWrap) { + qrWrap.textContent = ""; + for (const a of addresses) { + const mobileUrl = `${a}/mobile/`; + const card = document.createElement("div"); + card.className = "qr-card"; + const img = document.createElement("img"); + img.src = `/api/qr?url=${encodeURIComponent(mobileUrl)}`; + img.alt = `QR code for ${mobileUrl}`; + img.width = 130; + img.height = 130; + const label = document.createElement("div"); + label.className = "qr-label"; + label.textContent = mobileUrl; + card.append(img, label); + qrWrap.appendChild(card); + } + } + input.checked = enabled; const refresh = () => netWrap?.classList.toggle("hidden", !input.checked); refresh(); diff --git a/uv.lock b/uv.lock index a2a5c8e..2ac02c2 100644 --- a/uv.lock +++ b/uv.lock @@ -1666,6 +1666,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, ] +[[package]] +name = "segno" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -1782,6 +1791,7 @@ dependencies = [ { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "pyloudnorm" }, { name = "python-multipart" }, + { name = "segno" }, { name = "soundfile" }, { name = "torch", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, @@ -1813,6 +1823,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "segno", specifier = ">=1.6" }, { name = "soundfile", specifier = ">=0.12" }, { name = "torch", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=2.6,<2.7" }, { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=2.2,<2.3" }, From 51bc0a3e72a57d2ef459fcfbcdd807f84517b85e Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:28:26 +0100 Subject: [PATCH 2/8] feat(settings): remove IP list, blur QR codes with tap-to-reveal - Drop the yellow IP address chips; the QR label already shows the URL - QR codes start blurred so a nearby camera app can't scan them immediately; tap any card to toggle the blur - Add a hint line: "Blurred so your camera doesn't get too excited. Tap to reveal." --- static/css/daw.css | 12 ++++----- static/js/catalog.js | 60 +++++++++++++++++++++----------------------- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index 527292a..56c7a2b 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -807,13 +807,13 @@ input, textarea { font-family: inherit; } .settings-switch.disabled input { cursor: default; } .settings-net { margin-top: 10px; font-size: 11px; color: var(--muted); } .settings-net.hidden { display: none; } -.settings-net-label { margin-bottom: 7px; } -.settings-net-list { display: flex; flex-direction: column; gap: 5px; align-items: flex-start; } -.settings-net-list code { color: var(--accent); background: rgba(244,183,64,0.1); padding: 3px 9px; border-radius: 5px; font-size: 11.5px; } .settings-net-empty { color: var(--muted); } -.settings-net-qr { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; } -.qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; } -.qr-card img { border-radius: 6px; border: 1px solid var(--border-strong); width: 130px; height: 130px; } +.settings-net-qr { display: flex; flex-direction: column; gap: 14px; margin-top: 6px; } +.qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } +.qr-cards-row { display: flex; flex-wrap: wrap; gap: 16px; } +.qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } +.qr-card img { border-radius: 6px; border: 1px solid var(--border-strong); width: 130px; height: 130px; transition: filter 0.25s ease; } +.qr-card.qr-blurred img { filter: blur(8px); } .qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } .settings-server-note { font-size: 10.5px; color: var(--muted); margin: 0 0 12px; line-height: 1.5; } .settings-subhead { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; margin: 4px 0 7px; } diff --git a/static/js/catalog.js b/static/js/catalog.js index 5b33699..025f31f 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1836,8 +1836,6 @@ function networkSettingsHtml() { @@ -1898,7 +1896,6 @@ async function wireGeneralSettings(overlay) { async function wireNetworkSetting(overlay) { const input = overlay.querySelector(".net-access-input"); const netWrap = overlay.querySelector(".settings-net"); - const list = overlay.querySelector(".settings-net-list"); const qrWrap = overlay.querySelector(".settings-net-qr"); if (!input) return; @@ -1913,42 +1910,41 @@ async function wireNetworkSetting(overlay) { } } catch { /* leave defaults */ } - // Build the address list with textContent (URLs are server data, but never - // interpolate untrusted strings into innerHTML). - if (list) { - list.textContent = ""; + // QR codes: one per LAN address, each encodes the /mobile/ URL so the + // phone camera opens StemDeck directly. Cards start blurred so an open + // camera app on a nearby device doesn't scan them before you're ready. + if (qrWrap) { + qrWrap.textContent = ""; if (addresses.length) { + const hint = document.createElement("p"); + hint.className = "qr-hint"; + hint.textContent = "Blurred so your camera doesn't get too excited. Tap to reveal."; + qrWrap.appendChild(hint); + const row = document.createElement("div"); + row.className = "qr-cards-row"; for (const a of addresses) { - const code = document.createElement("code"); - code.textContent = a; - list.appendChild(code); + const mobileUrl = `${a}/mobile/`; + const card = document.createElement("div"); + card.className = "qr-card qr-blurred"; + card.title = "Tap to unblur"; + card.addEventListener("click", () => card.classList.toggle("qr-blurred")); + const img = document.createElement("img"); + img.src = `/api/qr?url=${encodeURIComponent(mobileUrl)}`; + img.alt = `QR code for ${mobileUrl}`; + img.width = 130; + img.height = 130; + const label = document.createElement("div"); + label.className = "qr-label"; + label.textContent = mobileUrl; + card.append(img, label); + row.appendChild(card); } + qrWrap.appendChild(row); } else { const span = document.createElement("span"); span.className = "settings-net-empty"; span.textContent = "No local network connection detected."; - list.appendChild(span); - } - } - - // QR codes: one per LAN address, each encodes the /mobile/ URL so the - // phone camera opens StemDeck directly. - if (qrWrap) { - qrWrap.textContent = ""; - for (const a of addresses) { - const mobileUrl = `${a}/mobile/`; - const card = document.createElement("div"); - card.className = "qr-card"; - const img = document.createElement("img"); - img.src = `/api/qr?url=${encodeURIComponent(mobileUrl)}`; - img.alt = `QR code for ${mobileUrl}`; - img.width = 130; - img.height = 130; - const label = document.createElement("div"); - label.className = "qr-label"; - label.textContent = mobileUrl; - card.append(img, label); - qrWrap.appendChild(card); + qrWrap.appendChild(span); } } From 1f6457899cca0ba584f11a0ea3b9c6592340ce3c Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:54 +0100 Subject: [PATCH 3/8] fix(settings): increase gap between QR cards --- static/css/daw.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/daw.css b/static/css/daw.css index 56c7a2b..7e263ee 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -810,7 +810,7 @@ input, textarea { font-family: inherit; } .settings-net-empty { color: var(--muted); } .settings-net-qr { display: flex; flex-direction: column; gap: 14px; margin-top: 6px; } .qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } -.qr-cards-row { display: flex; flex-wrap: wrap; gap: 16px; } +.qr-cards-row { display: flex; flex-wrap: wrap; gap: 28px; } .qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } .qr-card img { border-radius: 6px; border: 1px solid var(--border-strong); width: 130px; height: 130px; transition: filter 0.25s ease; } .qr-card.qr-blurred img { filter: blur(8px); } From 0e3832d65dea1336c20d0672884e15d898e9f3c5 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:37:15 +0100 Subject: [PATCH 4/8] fix(settings): clip QR blur bleed with overflow hidden wrapper --- static/css/daw.css | 5 +++-- static/js/catalog.js | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index 7e263ee..2fd3358 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -812,8 +812,9 @@ input, textarea { font-family: inherit; } .qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } .qr-cards-row { display: flex; flex-wrap: wrap; gap: 28px; } .qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } -.qr-card img { border-radius: 6px; border: 1px solid var(--border-strong); width: 130px; height: 130px; transition: filter 0.25s ease; } -.qr-card.qr-blurred img { filter: blur(8px); } +.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 1px solid var(--border-strong); overflow: hidden; } +.qr-card img { width: 130px; height: 130px; display: block; transition: filter 0.25s ease, scale 0.25s ease; } +.qr-card.qr-blurred img { filter: blur(10px); scale: 1.12; } .qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } .settings-server-note { font-size: 10.5px; color: var(--muted); margin: 0 0 12px; line-height: 1.5; } .settings-subhead { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; margin: 4px 0 7px; } diff --git a/static/js/catalog.js b/static/js/catalog.js index 025f31f..5438c3c 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1936,7 +1936,10 @@ async function wireNetworkSetting(overlay) { const label = document.createElement("div"); label.className = "qr-label"; label.textContent = mobileUrl; - card.append(img, label); + const imgWrap = document.createElement("div"); + imgWrap.className = "qr-img-wrap"; + imgWrap.appendChild(img); + card.append(imgWrap, label); row.appendChild(card); } qrWrap.appendChild(row); From bf1cabe50c260b9481522f8cb2fb6f92f10aeeb0 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:38:39 +0100 Subject: [PATCH 5/8] fix(settings): accent color border on QR cards --- static/css/daw.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/daw.css b/static/css/daw.css index 2fd3358..a4b7a2e 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -812,7 +812,7 @@ input, textarea { font-family: inherit; } .qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } .qr-cards-row { display: flex; flex-wrap: wrap; gap: 28px; } .qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } -.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 1px solid var(--border-strong); overflow: hidden; } +.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 2px solid var(--accent); overflow: hidden; } .qr-card img { width: 130px; height: 130px; display: block; transition: filter 0.25s ease, scale 0.25s ease; } .qr-card.qr-blurred img { filter: blur(10px); scale: 1.12; } .qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } From af9a53ff16d53c8d1ac648598eb71b03abb31f6a Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:55:53 +0100 Subject: [PATCH 6/8] fix(settings): thicker accent border on QR cards --- static/css/daw.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/daw.css b/static/css/daw.css index a4b7a2e..7f80940 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -812,7 +812,7 @@ input, textarea { font-family: inherit; } .qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } .qr-cards-row { display: flex; flex-wrap: wrap; gap: 28px; } .qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } -.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 2px solid var(--accent); overflow: hidden; } +.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 3px solid var(--accent); overflow: hidden; } .qr-card img { width: 130px; height: 130px; display: block; transition: filter 0.25s ease, scale 0.25s ease; } .qr-card.qr-blurred img { filter: blur(10px); scale: 1.12; } .qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } From 8afd4de4d603f93cdff1eb23ef6845e619d52bd9 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:16:27 +0100 Subject: [PATCH 7/8] fix(settings): box-sizing border-box on QR wrap to stop corner clipping --- static/css/daw.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index 7f80940..57bad16 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -812,8 +812,8 @@ input, textarea { font-family: inherit; } .qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } .qr-cards-row { display: flex; flex-wrap: wrap; gap: 28px; } .qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } -.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 3px solid var(--accent); overflow: hidden; } -.qr-card img { width: 130px; height: 130px; display: block; transition: filter 0.25s ease, scale 0.25s ease; } +.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 3px solid var(--accent); overflow: hidden; box-sizing: border-box; } +.qr-card img { width: 100%; height: 100%; display: block; transition: filter 0.25s ease, scale 0.25s ease; } .qr-card.qr-blurred img { filter: blur(10px); scale: 1.12; } .qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } .settings-server-note { font-size: 10.5px; color: var(--muted); margin: 0 0 12px; line-height: 1.5; } From 15f2dfacc64ae517369205dc12758032cefecac4 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:18:14 +0100 Subject: [PATCH 8/8] fix(settings): advanced pane scrolls so Done footer stays fixed at bottom --- static/css/daw.css | 1 + 1 file changed, 1 insertion(+) diff --git a/static/css/daw.css b/static/css/daw.css index 57bad16..25c253c 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -779,6 +779,7 @@ input, textarea { font-family: inherit; } .settings-tab.active { color: var(--fg); border-bottom-color: var(--accent); } .settings-pane { display: flex; flex-direction: column; min-height: 0; } .settings-pane[data-pane="general"] { flex: 1; } +.settings-pane[data-pane="advanced"] { flex: 1; overflow-y: auto; } .settings-pane.hidden { display: none; } .settings-pane[data-pane="advanced"] .library-editor-table-wrap { flex: none; max-height: 240px; margin-bottom: 2px; } .settings-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 28px 10px; }