Make StemDeck usable from another device - #568
Conversation
`test_the_installer_exists_and_is_executable` asked the filesystem whether packaging/linux/install.sh was executable. NTFS has no execute bit and this repo sets core.filemode=false, so on Windows a perfectly good 100755 file read back as 0o666 and the test failed on every clean checkout. A red suite on a clean tree teaches people to ignore the suite. It was also asking the wrong question. The tarball is built from what git has, not from the developer's working copy, so a script committed without the bit ships unrunnable even if its author had chmod-ed their own file and watched this pass. It now reads the mode git records, falls back to the filesystem when there is no working tree at all (an unpacked sdist), and names the fix in the failure message. Verified on Linux via WSL: a fresh checkout materialises install.sh as -rwxr-xr-x, and the full suite is green there. Closes #567
yt-dlp carries 939 site extractors. StemDeck downloads from YouTube and SoundCloud and rejects every other host before yt-dlp is called, so the rest are unreachable code. A large number of them are pornography sites, named as such in the filenames, and every desktop install put those files on the user's disk under their own account. This is not about package size. It is about not putting files on someone's computer that they would not want there and have no reason to have. scripts/prune_ytdlp_extractors.py removes 930 of them and keeps 9. The keep set is not hand-written: extractors import each other across the package, so the script starts from the seeds StemDeck actually needs and then *discovers* the cross-package imports (openload, adobepass, afreecatv) rather than trusting a list to stay correct. It deletes the lazy table, regenerates a minimal _extractors.py, and verifies the result in a subprocess that asserts it checked the tree it just modified. Wired into all four packaging paths (Windows, Linux, macOS, Docker), each of which fails the build if the pruned tree does not import or if YouTube and SoundCloud no longer match. Closes #563
Only the General pane had overflow-y, which was a bet that no other tab would outgrow the dialog's 540px. Network did, once it gained the secure-origin warning and a QR card per network interface. A pane with no overflow does not clip, it runs on underneath. The visible result was the Done button drawn on top of the Port setting, with the settings below it unreachable and no scrollbar to discover. Every pane now scrolls, and the footer gets flex-shrink: 0 so a tall pane squeezes itself rather than the footer. The dialog stays one size on every tab, which is why the panes are flex: 1 in the first place. The tests assert that a pane clips rather than measuring boxes. Measuring cannot tell the two cases apart: a pane that overflows keeps its own constrained box and merely paints its children outside it, and both look identical to getBoundingClientRect. Closes #565
There were two answers to "what is in my library". The desktop built its list
from per-device browser storage; the phone built its entire list from
GET /api/jobs. Deleting on the desktop removed the card locally and left the
job in the registry, so the phone kept listing tracks the user believed they
had thrown away.
Trashing is deliberately not deleting. Stems stay on disk and the job stays in
the registry, so restoring costs nothing and a mistaken tap on a phone never
destroys audio. Only emptying the Trash calls DELETE.
- `trashed_at` on the job, persisted, so it survives a restart
- GET /api/jobs?trashed=exclude|include|only, defaulting to exclude
- POST /{job_id}/trash and /restore, both idempotent because two devices can
bin the same track
- the desktop reconciles its local Trash up to the server on sync
- the phone's swipe-to-delete now trashes instead of deleting
A registry written before this existed has no such field and still loads.
Closes #562
Transpose is an AudioWorklet, which browsers grant only to a secure context. https and localhost qualify; http://192.168.x.x does not. So anyone opening StemDeck from their phone got working playback and a key control that silently did nothing, with no error and nothing on screen to say why. The ScriptProcessorNode fallback that would have avoided TLS was measured and rejected: a bare passthrough with no DSP in it loses audio at every buffer size (1024 drops 17.8% of windows, 4096 drops 5.4%, 16384 drops 1.0%, never zero). That trades a control that does nothing for one that produces broken audio. So the secure context is made real instead of worked around. - desktop/src-tauri/src/certs.rs generates the certificate on the user's own machine, into <data>/certs/, with the machine's real LAN IPs as SANs. Nothing is shipped: a certificate in the release would publish its private key to everyone who downloaded it, which is worse than plain http because it looks secure. It regenerates when the addresses change or expiry nears, and lasts 397 days because Safari rejects anything over 398. - app/core/tls_listener.py runs a second uvicorn listener on 8443 against the same app object, so there is one registry, one queue and one Demucs worker. lifespan="off" on it, or both would start and the first to stop would reap the worker out from under the other. Every failure path is non-fatal: a bad certificate costs LAN transpose, never the ability to open StemDeck. - the desktop keeps plain http on loopback for its own webview, which has no chrome to click a certificate interstitial through, and loses nothing because loopback is already a secure context. - server mode refuses a plaintext non-local origin with a 403 that explains itself and names three ways out. X-Forwarded-Proto and RFC 7239 Forwarded are honoured, so a reverse proxy that already terminates TLS is served normally. The host machine is always served, so this can never lock someone out of their own server. - Settings advertises the port that is actually listening, so a failed bind shows as no address rather than a QR code that cannot connect. Chosen so uv.lock is untouched: uvicorn's TLS is stdlib ssl and the generator is Rust, so the desktop in-app updater still offers this release to existing installs. Closes #560
main.py routes phones to static/mobile by user agent, and that shell had no transpose control at all. Whatever the engine could do was unreachable there. The control has three states, not two, and the third is the common one today: available, not yet loaded, and impossible on this origin. A stepper that is present but dead is indistinguishable from a broken build, so on an insecure origin it is visibly disabled and says why. Settings gains a red warning next to "Make StemDeck available on your network", because handing someone a LAN address has a consequence the address does not show. It says opposite things for the two cases: over plain http the key control will not work, and over https the phone will show a certificate warning that is expected and can be continued past. Both look like the app is broken otherwise. Closes #564
Opening the phone UI pulled about 456 KB of JavaScript, CSS and HTML in the clear, of which static/js/i18n.js alone is 328 KB. Invisible over loopback in the desktop webview; over Wi-Fi it is the load time. Measured on the LAN, i18n.js goes from 334 KB to 88 KB. TLS was measured first and ruled out: 200 MB/s against 171 MB/s on a 50 MB transfer, and 48 ms against 55 ms on an 880 KB range request. 15% on a link far faster than any Wi-Fi. Starlette's GZipMiddleware already declines text/event-stream, so the progress streams keep flowing. What it does not do is look at the status or the content type of anything else, and two responses here must not be touched: - A range window of audio comes back as 206. Compressing one rewrites Content-Length while Content-Range still describes the uncompressed bytes, which browsers do not read alike, and the payload is PCM so there was nothing to gain. This would have broken playback on the phone outright. - Whole audio files, for the same reason, on a machine that may be running Demucs at the time. So the rule is an allowlist by content type plus a hard 200-only gate, rather than a list of paths that would silently start compressing a stem the first time a route moved. No new dependency: gzip is stdlib and the middleware ships with Starlette, so uv.lock is untouched and the desktop updater is unaffected. Closes #561
Content: - Analog4Lyfe moves from Music Gear to Artists & Creators - Dead roses, Killah Trakz and Alexandre Borges added, with their images - a Writers & Storytellers category - the dialog now carries the whole disclaimer, not just a tagline. That StemDeck accepts no money, sponsorship or funding from anyone on the list is the point of the list, and it was only said in the README. Presentation: - categories and people are sorted alphabetically, resolved per language. Sorting on the English labels would be an arbitrary order in the other ten, and Intl.Collator puts accented names where a reader expects them. - the split between the two columns is measured rather than hardcoded at three sections, which only balanced while every category had the same number of people in it - the dialog is wider, so a four-person category is one row instead of two, which is what pushed the list past the bottom and put a scrollbar on it - cards are link tiles, so a drag across two of them no longer selects half the dialog The search placeholder is shortened to "Search or drop an audio file". Every string is in all eleven language tables; ptPT carries an override only where the European wording genuinely differs (argumentista, comentador, partilho, ficheiro). Closes #566
`# noqa: S104` is ruff's suppression. Bandit has its own and does not read it, so B104 (hardcoded_bind_all_interfaces) failed the SAST job on a bind that is the entire point of the listener: it exists so a phone can reach StemDeck over a secure origin. Adds `# nosec B104` alongside, matching the existing convention in app/api/search.py, and says in the comment why binding all interfaces is correct here. Whether another device is actually served is decided per request by the network gate in app/main.py, which defaults to off.
Both files reached for app.main two ways: `import app.main as main` for monkeypatching, and `from app.main import app` for the ASGI instance. CodeQL flags the inconsistency, and there is a real trap behind the rule -- `from X import Y` binds the object at import time, so a later monkeypatch of main.app would be invisible through the second name, and the test would quietly exercise the unpatched object. Nothing patches app itself today (only _secure_origin_required, _local_ips, SSL_CERTFILE, SSL_KEYFILE), so this is a latent trap rather than a live bug. main.app is the same object, so behaviour is unchanged.
|
Went through the five bot comments. Three applied, two declined. Applied: mixed import styles (3 comments)
Worth doing not just to quiet the rule: Declined: "unused global variable" on
|
Eight problems, one per commit. They ship together because the first five are the same story told from different angles: opening StemDeck from a phone should give you the whole app, not a viewer.
Closes #560
Closes #561
Closes #562
Closes #563
Closes #564
Closes #565
Closes #566
Closes #567
The main thread
Transpose only worked on the host machine. It is an
AudioWorklet, which browsers grant only to a secure context, so a phone onhttp://192.168.x.xgot working playback and a key control that silently did nothing.The
ScriptProcessorNodefallback that would have avoided TLS was measured and rejected. A bare passthrough with no DSP in it loses audio at every buffer size (1024 drops 17.8% of windows, 4096 drops 5.4%, 16384 drops 1.0%, never zero). That trades a control that does nothing for one that produces broken audio.So the secure context is made real instead of worked around:
<data>/certs/, with the machine's real LAN IPs as SANs. Nothing is shipped: a certificate in the release would publish its private key to everyone who downloaded it, which is worse than plain http because it looks secure. It regenerates when the addresses change or expiry nears, and lasts 397 days because Safari rejects anything over 398.lifespan="off"on it, or the first server to stop would reap the worker out from under the other. Plain http stays on loopback for the Tauri webview, which has no chrome to click a certificate interstitial through and loses nothing, because loopback is already a secure context.X-Forwarded-Protoand RFC 7239Forwardedare honoured, so the common self-hosted shape (SWAG, NPM, Traefik, Caddy in front, plain http upstream) is served normally. The host machine is always served, so this can never lock someone out of their own server.uv.lockis untouched, deliberately. uvicorn's TLS is stdlibssland the generator is Rust, soruntimeIddoes not move and the desktop in-app updater still offers this release to existing installs.The rest of making the phone work
i18n.jsalone is 328 KB. Measured on the LAN: 334 KB to 88 KB. TLS was ruled out first as the cause (200 MB/s against 171 MB/s bulk, 48 ms against 55 ms per range request). The middleware is 200-only and allowlisted by content type, because compressing a206range window of audio rewritesContent-LengthwhileContent-Rangestill describes the uncompressed bytes, which would have broken phone playback outright.GET /api/jobs. Deleting on the desktop left the job in the registry, so the phone kept listing tracks the user thought they had binned. Trashing is not deleting: stems stay on disk, so a mistaken tap on a phone never destroys audio.Riders
Verification
The 13 Windows failures are ffmpeg not being on PATH for a from-source run, Linux-only packaging cases, and process-timing tests. All pass on Linux. None are touched by this branch.
Also verified by hand on a real phone over the LAN: certificate accepted once, transpose working, and the compression measured on the wire.
Before merging
The Unraid template pin in
templates/stemdeck.xmlis deliberately untouched. Per the project's rule it tracks the latest published pre-release, so it should be bumped after a release is published with all its assets, not at merge time.