Skip to content

Make StemDeck usable from another device - #568

Merged
thcp merged 10 commits into
mainfrom
feat/remote-transpose
Sep 3, 2026
Merged

Make StemDeck usable from another device#568
thcp merged 10 commits into
mainfrom
feat/remote-transpose

Conversation

@thcp

@thcp thcp commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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 on http://192.168.x.x got working playback and a key control that silently did nothing.

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:

  • The desktop app generates its own certificate on the user's 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.
  • A second uvicorn listener on 8443 serves the LAN over TLS, against the same app object, so there is one registry, one queue and one Demucs worker. 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.
  • 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 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.lock is untouched, deliberately. uvicorn's TLS is stdlib ssl and the generator is Rust, so runtimeId does not move and the desktop in-app updater still offers this release to existing installs.

The rest of making the phone work

  • Compression. Opening the phone UI pulled 456 KB of uncompressed text, of which i18n.js alone 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 a 206 range window of audio rewrites Content-Length while Content-Range still describes the uncompressed bytes, which would have broken phone playback outright.
  • Trash moved to the server. There were two answers to "what is in my library": the desktop's per-device storage and the phone's 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.
  • A key control on the phone at all. It has three states, not two, and the third is the common one: available, not yet loaded, and impossible on this origin. A dead stepper is indistinguishable from a broken build, so it is visibly disabled and says why.
  • Settings tabs scroll. Only General did, which was a bet that no other tab would outgrow 540px. Network did, and a pane with no overflow does not clip, it runs on underneath: the Done button ended up drawn on top of the Port setting.

Riders

  • yt-dlp extractors. 939 of them shipped to every user's disk, a large number for pornography sites, none reachable because StemDeck rejects every host but YouTube and SoundCloud before yt-dlp is called. 930 removed. The keep set is discovered rather than hand-written, because extractors import each other across the package, and every packaging path fails the build if the pruned tree does not import or if YouTube and SoundCloud stop matching.
  • We Recommend. Three people added, one recategorised, a new Writers category, the full no-money disclaimer moved into the dialog, alphabetical order resolved per language, and a wider dialog with a measured column split so the list stops outgrowing it.
  • The Linux installer exec-bit test failed on every Windows checkout, because NTFS has no execute bit. It was also asking the wrong question: the tarball is built from what git records, not from the developer's working copy.

Verification

pytest (Linux, via WSL) 992 passed, 0 failed
pytest (Windows) 944 passed, 13 pre-existing environment failures
Playwright 109 passed
ruff check / format clean
cargo clippy / fmt clean
cargo test certs 7 passed

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.xml is 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.

Thales added 8 commits September 3, 2026 18:33
`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
Comment thread app/core/tls_listener.py
Comment thread app/core/tls_listener.py
Comment thread tests/test_secure_origin_gate.py Fixed
Comment thread tests/test_tls_listener.py Fixed
Comment thread tests/test_tls_listener.py Fixed
Thales and others added 2 commits September 3, 2026 19:24
`# 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.
@thcp

thcp commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Went through the five bot comments. Three applied, two declined.

Applied: mixed import styles (3 comments)

eb82fce. Both test files reached for app.main two ways -- import app.main as main for monkeypatching, from app.main import app for the ASGI instance.

Worth doing not just to quiet 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 it was a latent trap rather than a live bug. main.app is the same object; behaviour is unchanged.

Declined: "unused global variable" on app/core/tls_listener.py:128 (2 comments)

These are false positives, and acting on them would be a regression.

_server and _task are written in start() and read in stop():

# start(), line 118
_server, _task, _active_port = server, task, port

# stop(), lines 127-128
server, task = _server, _task     # read here
_server = _task = None            # the line flagged as unused

The flagged assignment is what makes stop() idempotent. On a second call, server, task = _server, _task reads those Nones and takes the early return two lines later. test_stopping_one_that_never_started_is_harmless covers exactly that path.

The analysis appears to be statement-local: at line 128 the assigned value is not read before being reassigned within that function, which is true and irrelevant -- the read happens on the next invocation. Deleting the line, or renaming to _unused, would leave a stopped listener holding a dead server object and make a second stop() try to shut it down again.

Leaving both as they are.

Verification

ruff check     All checks passed
ruff format    already formatted
pytest         990 passed, 2 failed, 3 skipped

The 2 failures are test_stems_api.py::test_all_stems_zip_ogg and ::test_ogg_is_still_streamed, which fail identically on main -- a local ffmpeg without libvorbis, most likely. Unrelated to this branch.

@thcp
thcp merged commit a17177a into main Sep 3, 2026
13 checks passed
@thcp
thcp deleted the feat/remote-transpose branch September 3, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment