diff --git a/README.md b/README.md index 5534b3c0..7f70c2aa 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,20 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** fro | Category | Name | What they do | Link | |---|---|---|---| | Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | +| Artists & Creators | Killah Trakz | Producer | [@killahtrakz](https://www.instagram.com/killahtrakz/) | | Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) | +| Artists & Creators | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | +| Artists & Creators | Dead röses | Cork-based punk rock band | [@dead_rosesband](https://www.instagram.com/dead_rosesband) | | Instrument Builders & Repair | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) | | Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) | | Instrument Builders & Repair | Kris Luthier | Instrument repair and restoration | [@krisluthier](https://www.instagram.com/krisluthier) | -| Music Gear | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | | Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) | | Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) | | Music & Karaoke Technology | Beltr | Local, subscription-free karaoke software | [beltr.app](https://beltr.app/) | | Music & Karaoke Technology | Seratone | TV-based karaoke system | [seratone.audio](https://seratone.audio/) | | Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) | | Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) | +| Writers & Storytellers | Alexandre Borges | Portuguese writer, screenwriter, and cultural commentator | [Books & author profile](https://www.instagram.com/alexgram_b/) | --- @@ -336,9 +339,66 @@ The library is persistent by default (`STEMDECK_PERSIST_LIBRARY=1`), so tracks a | `STEMDECK_TIMEOUT_FFMPEG` | `300` | ffmpeg subprocess timeout (seconds). | | `STEMDECK_TIMEOUT_ANALYZE` | `120` | Audio analysis timeout (seconds). | | `STEMDECK_TIMEOUT_DEMUCS_STALL` | `1800` | Kill Demucs if no output for this many seconds. | +| `STEMDECK_SSL_CERT` | (none) | PEM certificate; set with the key below to serve https directly. | +| `STEMDECK_SSL_KEY` | (none) | PEM private key for the certificate above. | +| `STEMDECK_HTTPS_PORT` | (none) | Serve https on this port *in addition* to the main listener. Set by the desktop app; see below. | `run.sh` also reads: `HOST` (default `127.0.0.1`), `PORT` (default `8765`), `RELOAD=1` (enable uvicorn auto-reload for development), `FOREGROUND=1` (run in foreground instead of backgrounding). +### Serving other devices: why https is not optional + +Transpose is built on `AudioWorklet`, and browsers grant that only to a +**secure context**. `https://` and `localhost` qualify. A plain +`http://192.168.1.20:8000` does not, so a phone reaching StemDeck over plain +http gets working playback and a key control that cannot do anything. There is +no fallback worth shipping: driving the same DSP from a `ScriptProcessorNode` +measured around 5% of the audio missing, because that node type drops buffers +on its own at every size. + +So a server that other devices will use terminates TLS, one of three ways: + +1. **A reverse proxy** (SWAG, Nginx Proxy Manager, Traefik, Caddy). The usual + self-hosted shape, and the best one if you already run it. StemDeck reads + `X-Forwarded-Proto` and the RFC 7239 `Forwarded` header, so an https browser + over a plain-http upstream hop is recognised as secure and served normally. +2. **StemDeck itself**, by pointing `STEMDECK_SSL_CERT` and `STEMDECK_SSL_KEY` + at a certificate and key. uvicorn serves them directly; no extra package is + installed for this. +3. **A private overlay network** such as Tailscale, whose addresses are already + https. + +Reaching a plaintext non-local origin with none of those in place is refused +with a 403 that explains this, rather than served as an app that is quietly +half-broken. Loopback is always served, so turning this on can never lock the +host out of its own server. + +### The desktop app runs two listeners + +The desktop app does the same thing without being configured, because it has +two audiences that need opposite things. + +- **Plain http on `127.0.0.1`** for its own window. Loopback is already a + secure context, so nothing is lost, and it is the only scheme that works: a + self-signed certificate would raise a warning page the app window has no way + to click through. +- **https on the LAN**, port 8443 by default, for phones and other computers. + This is the address Settings shows and the QR code points at. + +Both listeners serve the same process, so there is one library, one queue and +one Demucs worker either way. + +The certificate is generated on your own machine the first time you enable +network access, and lives in `/certs/` beside `jobs/` and +`settings.json`. Nothing is shipped in the download: 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 is regenerated automatically +when your machine's addresses change or the certificate is close to expiring. + +Because it is signed by nobody, **your phone will show a "your connection is +not private" warning the first time**. Tap Advanced, then Continue. Once per +device, per computer. Settings says so, in red, next to the toggle. + + --- ## API diff --git a/app/api/jobs.py b/app/api/jobs.py index 7e40f3b8..7091a5e5 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -34,6 +34,7 @@ from app.core.registry import persist as registry_persist from app.core.registry import register_if_capacity as registry_register_if_capacity from app.core.registry import remove as registry_remove +from app.core.registry import set_trashed as registry_set_trashed from app.core.settings import get_auto_sections, get_max_duration_sec from app.core.stems_location import is_relocating from app.pipeline import jobqueue @@ -317,15 +318,47 @@ async def _create_local_job(request: Request) -> dict[str, str]: @router.get("") -def list_jobs() -> list[dict]: - """List all completed jobs in the library, sorted by creation time.""" +def list_jobs(trashed: Literal["exclude", "include", "only"] = "exclude") -> list[dict]: + """Completed jobs in the library, oldest first. + + Trashed jobs are left out by default, which is the whole point of the + parameter: this endpoint is the phone UI's entire library, and before the + Trash moved server-side it happily listed tracks the user had deleted on + their desktop hours earlier. + """ + jobs = sorted(registry_all_jobs().values(), key=lambda j: j.created_at) return [ _job_state(job) - for job in sorted(registry_all_jobs().values(), key=lambda j: j.created_at) + for job in jobs if job.status == "done" + and (trashed == "include" or (job.trashed_at is not None) == (trashed == "only")) ] +@router.post("/{job_id}/trash") +def trash_job(job_id: str) -> dict: + """Put a job in the Trash. Reversible, and nothing on disk is touched.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_set_trashed(job_id, True) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + registry_persist(JOBS_DIR) + return {"job_id": job.id, "trashed_at": job.trashed_at} + + +@router.post("/{job_id}/restore") +def restore_job(job_id: str) -> dict: + """Take a job back out of the Trash.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_set_trashed(job_id, False) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + registry_persist(JOBS_DIR) + return {"job_id": job.id, "trashed_at": job.trashed_at} + + @router.get("/{job_id}") def get_job(job_id: str) -> dict: """Get the current state of a job by ID.""" diff --git a/app/core/compression.py b/app/core/compression.py new file mode 100644 index 00000000..1068bcbd --- /dev/null +++ b/app/core/compression.py @@ -0,0 +1,95 @@ +"""Compress the text StemDeck sends, and nothing else. + +Opening the phone UI pulls about 456 KB of JavaScript, CSS and HTML, of which +`static/js/i18n.js` alone is 328 KB -- eleven language tables shipped to every +user so that one of them can be read. Over loopback in the desktop webview that +is invisible. Over Wi-Fi to a phone it is the load time, and it gets worse on +https: browsers generally refuse to keep a disk cache for an origin with a +certificate error, so the revalidation that would normally answer 304 fetches +the whole thing again on every visit. Gzip takes that 456 KB to 126 KB. + +Starlette ships `GZipMiddleware` and it is nearly right: it already declines +`text/event-stream`, so the job and queue streams keep flowing. What it does +not do is look at the status code or the content type of anything else, and +two of StemDeck's responses must not be touched. + +**Range responses.** The phone plays audio through +`static/js/chunkedAudioEngine.js`, which asks for five-second windows with a +`Range` header and gets `206 Partial Content` back. Compressing one rewrites +`Content-Length` while leaving `Content-Range` describing the uncompressed +bytes, and the two disagreeing is a specification corner browsers do not handle +alike. The gain would have been nothing anyway: the payload is PCM. + +**Audio and video generally.** WAV does not compress, and paying deflate on a +40 MB stem -- on the same machine that is running Demucs -- costs real time to +save nothing. + +So the rule here is an allowlist by content type plus a hard `200`-only gate, +rather than a list of paths, which would silently start compressing a stem the +first time a route moved. +""" + +from __future__ import annotations + +from starlette.datastructures import Headers +from starlette.middleware.gzip import GZipMiddleware, GZipResponder +from starlette.types import Message, Receive, Scope, Send + +# Everything StemDeck serves that is text under the hood. Matched as a prefix, +# so the charset parameter ("text/html; charset=utf-8") does not need listing. +COMPRESSIBLE_TYPES: tuple[str, ...] = ( + "text/", + "application/javascript", + "application/json", + "application/manifest+json", + "image/svg+xml", +) + +# Level 9 buys about 2% over level 6 on this content and costs several times the +# CPU. The server doing this may also be separating a track. +COMPRESS_LEVEL = 6 + +# Below this a gzip header and trailer are most of what gets sent, and the round +# trip dominates either way. +MINIMUM_SIZE = 1024 + + +class _TextOnlyGZipResponder(GZipResponder): + """Starlette's responder, with a look at the response before committing. + + The decision needs the status and content type, which only exist once the + application has answered, so it is made here on the way out rather than + from the request. + """ + + _pass_through = False + + async def send_with_compression(self, message: Message) -> None: + if message["type"] == "http.response.start": + content_type = Headers(raw=message["headers"]).get("content-type", "") + # 200 only. That rules out 206 (a range window of audio) and 304 + # (no body to compress), without naming either as a special case. + self._pass_through = message["status"] != 200 or not content_type.startswith( + COMPRESSIBLE_TYPES + ) + if self._pass_through: + await self.send(message) + return + await super().send_with_compression(message) + + +class TextGZipMiddleware(GZipMiddleware): + """`GZipMiddleware` restricted to text, and only when the client asked.""" + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or "gzip" not in Headers(scope=scope).get("Accept-Encoding", ""): + # Starlette would run its IdentityResponder here purely to add a + # Vary header. Nothing between us and the browser on a LAN caches + # on our behalf, and buffering every response to add one header is + # not worth it. + await self.app(scope, receive, send) + return + responder = _TextOnlyGZipResponder( + self.app, self.minimum_size, compresslevel=self.compresslevel + ) + await responder(scope, receive, send) diff --git a/app/core/config.py b/app/core/config.py index 11e2635f..fe9f86ea 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -19,6 +19,12 @@ def _env_path(name: str, default: Path) -> Path: return Path(raw).expanduser().resolve() if raw else default +def _env_path_opt(name: str) -> Path | None: + """A path setting with no default: absent means the feature is off.""" + raw = os.environ.get(name, "").strip() + return Path(raw).expanduser().resolve() if raw else None + + def available_torch_devices() -> list[str]: """Compute devices this machine can actually use, best-first. CPU is always present; cuda/mps depend on the hardware + installed torch build. The @@ -146,6 +152,32 @@ def _stored_jobs_dir() -> Path | None: MODELS_DIR = _env_path("STEMDECK_MODELS_DIR", DATA_DIR / "models") LOGS_DIR = _env_path("STEMDECK_LOGS_DIR", DATA_DIR / "logs") FFMPEG_DIR = _env_path("STEMDECK_FFMPEG_DIR", DATA_DIR / "ffmpeg") + +# ── TLS, for server deployments ─────────────────────────────────────────────── +# +# AudioWorklet -- and so transpose -- is a [SecureContext] API, which browsers +# grant to https:// and localhost and to nothing else. A phone on +# http:// therefore cannot have it, and there is no workaround worth +# shipping: driving the same DSP from a ScriptProcessorNode was measured +# dropping ~5% of the audio, audibly, because that node type is lossy on its +# own regardless of buffer size. +# +# Terminating TLS here costs nothing. uvicorn uses the stdlib `ssl` module, so +# no package is added, uv.lock does not change, and the desktop in-app updater +# is unaffected (see .claude/rules/desktop-update-gate.md). Generating a +# certificate would need a dependency and would hand every client a full-page +# browser warning, so StemDeck never does that: bring your own, from a reverse +# proxy, Tailscale Serve, or mkcert. +SSL_CERTFILE = _env_path_opt("STEMDECK_SSL_CERT") +SSL_KEYFILE = _env_path_opt("STEMDECK_SSL_KEY") +# The desktop app runs two listeners, not one: plain http on loopback for its +# own webview, and https on the LAN for phones. It needs both because the two +# have incompatible requirements -- the webview cannot be shown a certificate +# warning it has no way to click through, and a phone cannot have transpose +# without a secure origin. When this is set, app.main starts the second +# listener alongside the first; when it is not, there is only ever one server +# and TLS (if configured at all) belongs to whoever launched uvicorn. +HTTPS_PORT = _env_int("STEMDECK_HTTPS_PORT", 0) or None FFMPEG_BIN = _env_path( "STEMDECK_FFMPEG", FFMPEG_DIR / ("ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"), diff --git a/app/core/models.py b/app/core/models.py index e6a4d73e..0220e195 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -98,6 +98,17 @@ class Job: # app/pipeline/vocal_split.py) and is recorded in stems/vocal_split_error.txt, # not job.error_detail, since the job itself did not fail. vocal_split: Literal["none", "running", "done", "error"] = "none" + # When the user put this job in the Trash, or None if they have not. + # + # Server-side on purpose. The Trash used to live only in the browser's + # catalog store, which is per-device: a track deleted on the desktop was + # still returned by GET /api/jobs, so the phone -- which builds its library + # straight from that endpoint -- listed everything the user thought they + # had thrown away. Two UIs, two answers to "what is in my library". + # + # A timestamp rather than a bool so the Trash can say when, and so a future + # auto-purge has something to work from. + trashed_at: float | None = None # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. # Not surfaced via to_state() -- it's internal control state. cancel_requested: bool = False @@ -151,6 +162,7 @@ def to_state(self) -> dict[str, Any]: "gpu_fallback": self.gpu_fallback, "stage_timings": self.stage_timings, "vocal_split": self.vocal_split, + "trashed_at": self.trashed_at, "created_at": self.created_at, } diff --git a/app/core/registry.py b/app/core/registry.py index eaffc421..9cc07c87 100644 --- a/app/core/registry.py +++ b/app/core/registry.py @@ -5,6 +5,7 @@ import shutil import subprocess import threading +import time import uuid from pathlib import Path @@ -91,6 +92,21 @@ def remove(job_id: str) -> None: _procs.pop(job_id, None) +def set_trashed(job_id: str, trashed: bool) -> Job | None: + """Move a job to the Trash, or take it back out. + + Not a delete: the stems stay on disk and the job stays in the registry, so + restoring is free and the user's audio is never destroyed by a tap. Only + emptying the Trash calls DELETE, which is what actually removes files. + """ + with _lock: + job = _jobs.get(job_id) + if job is None: + return None + job.trashed_at = time.time() if trashed else None + return job + + def all_jobs() -> dict[str, Job]: """Return a snapshot of the registry for sweep / cleanup.""" with _lock: diff --git a/app/core/tls_listener.py b/app/core/tls_listener.py new file mode 100644 index 00000000..bba41af9 --- /dev/null +++ b/app/core/tls_listener.py @@ -0,0 +1,140 @@ +"""A second uvicorn listener, serving the same app over TLS on the LAN. + +The desktop app has two audiences with incompatible requirements, and one +listener cannot satisfy both. + +Its own webview needs plain http on loopback. `http://127.0.0.1` is already a +secure context by browser rule, so nothing is lost there, and it is the only +scheme the webview can use: a self-signed certificate would raise an interstitial +that a Tauri window has no UI to click through, and the app would simply fail to +load. + +A phone on the LAN needs https. Transpose is an AudioWorklet, which browsers +withhold from `http://192.168.x.x`, so over plain http the key control is dead +with nothing on screen to explain it. Only TLS makes that origin secure. + +So both run, against the same FastAPI app object in the same process -- one +registry, one queue, one demucs worker. Only the primary listener runs the +lifespan; see `lifespan="off"` below. + +None of this applies to server mode, where there is one listener and whoever +launched uvicorn decides its scheme. This module is started only when +STEMDECK_HTTPS_PORT is set, which is the desktop shell's doing. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +_log = logging.getLogger(__name__) + +# The port the companion is actually accepting connections on, or None. Read by +# /api/settings to build the address it hands out, so it must reflect a socket +# that exists rather than the port we were asked for -- a failed bind has to +# show up as "no https address", not as a QR code that cannot connect. +_active_port: int | None = None +_server: object | None = None +_task: asyncio.Task | None = None + + +def active_port() -> int | None: + """The live TLS port, or None if no companion listener is running.""" + return _active_port + + +async def _serve(server: object) -> None: + """Run a uvicorn server, containing the way it reports a failed bind. + + uvicorn answers an unusable port with `sys.exit(1)`, which is fine for the + process it normally owns and wrong here: SystemExit is a BaseException, so + asyncio re-raises it out of the task and into the event loop, taking the + primary listener down with it. Ours is a companion; it is allowed to fail. + """ + try: + await server.serve() # type: ignore[attr-defined] + except SystemExit: + _log.debug("https listener exited during startup", exc_info=True) + + +async def start(app: object, *, port: int, certfile: Path, keyfile: Path) -> bool: + """Serve `app` over TLS on 0.0.0.0:port. Returns whether it came up. + + Failure is deliberately not fatal. This runs inside the app's lifespan, and + an unreadable certificate or an occupied port must cost the user LAN + transpose, never the ability to open StemDeck at all. + """ + global _active_port, _server, _task + + if _active_port is not None: + return True + for label, path in (("certificate", certfile), ("private key", keyfile)): + if not path.is_file(): + _log.warning("https listener not started: %s missing at %s", label, path) + return False + + import uvicorn + + config = uvicorn.Config( + app, + # The LAN is the entire point of this listener: it exists so that a + # phone can reach StemDeck over a secure origin. The loopback half of + # the pair is the primary server, bound separately. Whether another + # device is actually served is decided per request by the network gate + # in app/main.py, which defaults to off. + host="0.0.0.0", # noqa: S104 # nosec B104 + port=port, + ssl_certfile=str(certfile), + ssl_keyfile=str(keyfile), + # The primary listener owns startup and shutdown. Running the lifespan + # twice would start a second queue worker against the same registry, + # and the first server to stop would reap the shared demucs worker out + # from under the other. + lifespan="off", + # Inherit the logging the app already configured rather than reapplying + # uvicorn's dictConfig, which would tear down our handlers. + log_config=None, + access_log=False, + timeout_graceful_shutdown=2, + ) + server = uvicorn.Server(config) + # Server.serve() installs SIGINT/SIGTERM handlers when it runs on the main + # thread, which is where the lifespan runs. Left alone, the companion would + # replace the primary server's handlers and Ctrl+C would stop only this one. + server.install_signal_handlers = lambda: None # type: ignore[method-assign] + + task = asyncio.create_task(_serve(server), name="stemdeck-https") + # Wait for the bind to resolve either way. uvicorn signals a failed bind by + # setting should_exit and returning, so a finished task here means the port + # was refused, not that the server is running. + while not server.started and not task.done(): + await asyncio.sleep(0.02) + if task.done(): + exc = task.exception() if not task.cancelled() else None + _log.warning("https listener could not bind port %d: %s", port, exc or "port in use") + return False + + _server, _task, _active_port = server, task, port + _log.info("https listener on 0.0.0.0:%d (%s)", port, certfile.name) + return True + + +async def stop() -> None: + """Shut the companion down with the app. Safe to call when never started.""" + global _active_port, _server, _task + + server, task = _server, _task + _server = _task = None + _active_port = None + if server is None or task is None: + return + server.should_exit = True # type: ignore[attr-defined] + try: + await asyncio.wait_for(task, timeout=3) + except (TimeoutError, asyncio.TimeoutError): + # Past the graceful window. The process is going away regardless, and + # blocking shutdown on a stuck connection is worse than a stray socket. + task.cancel() + except Exception: + _log.exception("https listener did not stop cleanly") diff --git a/app/main.py b/app/main.py index 54622795..7488221f 100644 --- a/app/main.py +++ b/app/main.py @@ -22,11 +22,16 @@ from fastapi.staticfiles import StaticFiles from app.api.router import router +from app.core import tls_listener +from app.core.compression import COMPRESS_LEVEL, MINIMUM_SIZE, TextGZipMiddleware from app.core.config import ( DEMUCS_MODEL, FFMPEG_BIN, + HTTPS_PORT, JOBS_DIR, LOGS_DIR, + SSL_CERTFILE, + SSL_KEYFILE, STATIC_DIR, available_torch_devices, configure_portable_environment, @@ -179,7 +184,7 @@ async def _desktop_parent_watchdog(parent_pid: int) -> None: @asynccontextmanager -async def lifespan(_: FastAPI) -> AsyncIterator[None]: +async def lifespan(app: FastAPI) -> AsyncIterator[None]: _background_tasks = set() t = asyncio.create_task(_sweep_loop()) _background_tasks.add(t) @@ -226,7 +231,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: wt = asyncio.create_task(_desktop_parent_watchdog(parent_pid_int)) _background_tasks.add(wt) wt.add_done_callback(_background_tasks.discard) + # The LAN half of the desktop's two listeners. Only the shell sets these, + # and only once it has a certificate for this machine; server mode has a + # single listener whose scheme is not ours to decide. See app/core/tls_listener. + if HTTPS_PORT and SSL_CERTFILE and SSL_KEYFILE: + await tls_listener.start(app, port=HTTPS_PORT, certfile=SSL_CERTFILE, keyfile=SSL_KEYFILE) yield + await tls_listener.stop() # Stop taking new work. Deliberately not cancelling the in-flight job: it is # blocked in asyncio.to_thread, which cannot be cancelled, so it dies with # the process exactly as it did before the queue existed. @@ -368,7 +379,23 @@ def get_settings(request: Request) -> dict[str, object]: # LAN addresses other devices can use — loopback excluded (only works on the # host). The port is whatever this request came in on. port = request.url.port or 8000 - addresses = sorted(f"http://{ip}:{port}" for ip in _local_ips() if _is_lan_ipv4(ip)) + # The address handed to another device is not necessarily the one this + # request arrived on. The desktop app answers its own webview over plain + # http on loopback while serving the LAN over TLS on a second port, so the + # companion listener -- when it is actually up -- is what the QR code has + # to point at. Reading its live port rather than the configured one means a + # failed bind shows as no address instead of an address that cannot connect. + tls_port = tls_listener.active_port() + if tls_port is not None: + scheme, port_out = "https", tls_port + else: + # Single-listener deployments: https only when we terminate TLS + # ourselves. Advertising https:// for a plain server, or the reverse, + # hands the user an address that will not connect -- and the scheme is + # also what decides whether transpose works once they open it. + scheme = "https" if SSL_CERTFILE and SSL_KEYFILE else "http" + port_out = port + addresses = sorted(f"{scheme}://{ip}:{port_out}" for ip in _local_ips() if _is_lan_ipv4(ip)) # Show the port the server is actually running on rather than the stored # preference (which only takes effect on the next restart) -- so the field # reflects reality. Editing it still saves the preference via POST. @@ -952,6 +979,87 @@ def _is_host_request(host: str | None) -> bool: return h in _local_ips() +def _is_desktop_shell() -> bool: + """True when the Tauri app spawned this backend (it sets STEMDECK_DESKTOP).""" + return os.environ.get("STEMDECK_DESKTOP") == "1" + + +def _client_scheme(request: Request) -> str: + """The scheme the BROWSER used, which is not always the one we were called on. + + Behind a reverse proxy -- SWAG, Nginx Proxy Manager, Traefik, Caddy, which + is how most self-hosted StemDeck installs are reached -- the proxy + terminates TLS and forwards plain HTTP upstream. The browser has a perfectly + good secure context; only this hop is plaintext. Judging by our own socket + would reject exactly the deployments that already did the right thing. + """ + forwarded = request.headers.get("x-forwarded-proto") + if forwarded: + # A chain of proxies appends, and the client-facing one is first. + return forwarded.split(",")[0].strip().lower() + # RFC 7239, which Caddy and some others prefer to the X- header. + rfc7239 = request.headers.get("forwarded") + if rfc7239: + for part in rfc7239.split(",")[0].split(";"): + key, _, value = part.strip().partition("=") + if key.lower() == "proto": + return value.strip().strip('"').lower() + return request.url.scheme + + +# Shown in the browser, so it has to read as an explanation rather than an +# error code. Someone hitting this did nothing wrong. +_INSECURE_ORIGIN_MESSAGE = """StemDeck needs an https:// address when reached from another device. + +Browsers only grant a secure context to https:// and localhost. Without one +the audio engine cannot build its pitch stage, so transpose silently stops +working. Rather than serve a half-working app over the network, server mode +asks for TLS. + +Any of these fixes it: + - Put a reverse proxy in front (SWAG, Nginx Proxy Manager, Traefik, + Caddy) and make sure it sends X-Forwarded-Proto. + - Run `tailscale serve` on the host for a real certificate, with no + browser warning and nothing to install on the phone. + - Point STEMDECK_SSL_CERT and STEMDECK_SSL_KEY at a certificate and + restart. + +The host machine itself is always served, over http, on localhost.""" + + +# Secure-origin gate. Server mode only: the desktop app spawns this backend on +# loopback for its own webview, and its "available on your network" toggle is a +# different feature with its own explanation in the UI. +# +# This is NOT about eavesdropping -- there is no auth here by design. It is +# about not handing someone a browser tab that looks like StemDeck and quietly +# is not, because half the audio engine needs a secure context and never says +# so at the point of failure. +# +# X-Forwarded-Proto is taken on trust. Any device on the LAN could send it and +# get through, which matters little in an app that has no authentication at all +# (see .claude/rules/security.md) and would otherwise mean rejecting every +# properly proxied install. +def _secure_origin_required() -> bool: + """Whether this deployment enforces a secure origin for other devices. + + Server mode does; the desktop app does not, because its "available on your + network" toggle is a different feature with its own explanation in the UI, + and hard-failing it would take playback away from a phone in order to fix + transpose on it. + """ + return not _is_desktop_shell() + + +@app.middleware("http") +async def secure_origin_gate(request: Request, call_next): + if _secure_origin_required(): + client_host = request.client.host if request.client else None + if not _is_host_request(client_host) and _client_scheme(request) != "https": + return PlainTextResponse(_INSECURE_ORIGIN_MESSAGE, status_code=403) + return await call_next(request) + + # Network availability gate (Settings → "Make StemDeck available on your # network"). Added after the headers middleware so it is the OUTERMOST layer and # short-circuits before anything else. It NEVER stops the server — it only @@ -970,6 +1078,16 @@ async def network_gate(request: Request, call_next): return await call_next(request) +# Compress text on the way out. Registered last, so it is the outermost layer +# and sees the finished response -- including the headers the middleware above +# add. Only text is touched: see app/core/compression.py for why a range window +# of audio must not be. +app.add_middleware( + TextGZipMiddleware, + minimum_size=MINIMUM_SIZE, + compresslevel=COMPRESS_LEVEL, +) + app.include_router(router, prefix="/api") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") diff --git a/build/Dockerfile b/build/Dockerfile index cf0c38db..1ca32f17 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -66,6 +66,15 @@ COPY static/ ./static/ ARG VERSION=0.0.0 RUN SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" pip install . --timeout 300 +# Drop the ~930 yt-dlp extractors StemDeck can never reach. The container gets +# the same treatment as the desktop bundles and for the same reason: several +# dozen of them are adult sites, named as such, and lazy_extractors.py lists +# every one of those domains. A self-hosted user browsing /opt/venv should not +# find that in a music tool. The script verifies itself and fails the build if +# the pruned tree stops matching YouTube or SoundCloud. +COPY scripts/prune_ytdlp_extractors.py ./scripts/ +RUN python scripts/prune_ytdlp_extractors.py "$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')" + # ─── Stage 2: runner ───────────────────────────────────────────── FROM python:3.12-slim AS runner diff --git a/build/docker-entrypoint.sh b/build/docker-entrypoint.sh index 99e6b924..4374a3aa 100644 --- a/build/docker-entrypoint.sh +++ b/build/docker-entrypoint.sh @@ -17,6 +17,23 @@ PGID="${PGID:-1001}" chown -R "${PUID}:${PGID}" /app/jobs /cache 2>/dev/null || true touch /app/settings.json 2>/dev/null && chown "${PUID}:${PGID}" /app/settings.json 2>/dev/null || true +# Serve TLS when a certificate is supplied. Appended to the CMD rather than +# baked into it so `docker run ... uvicorn ...` overrides still work, and so an +# install with no certificate is unchanged. +# +# StemDeck never generates a certificate: that needs a dependency, and a +# self-signed one hands every client a full-page browser warning. Bring one from +# a reverse proxy, `tailscale serve`, or mkcert. A proxy that terminates TLS in +# front of the container needs nothing here at all -- it forwards +# X-Forwarded-Proto and the app trusts that. +if [ -n "${STEMDECK_SSL_CERT:-}" ] && [ -n "${STEMDECK_SSL_KEY:-}" ]; then + if [ ! -r "${STEMDECK_SSL_CERT}" ] || [ ! -r "${STEMDECK_SSL_KEY}" ]; then + echo "STEMDECK_SSL_CERT/STEMDECK_SSL_KEY are set but not readable" >&2 + exit 1 + fi + set -- "$@" --ssl-certfile "${STEMDECK_SSL_CERT}" --ssl-keyfile "${STEMDECK_SSL_KEY}" +fi + # Drop to the target user and exec the CMD. gosu accepts a numeric UID:GID even # when no matching named user exists. exec gosu "${PUID}:${PGID}" "$@" diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index faa8524d..6842f018 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -491,14 +491,38 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -514,13 +538,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -557,6 +592,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -721,6 +787,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "embed-resource" version = "3.0.9" @@ -1095,6 +1167,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "gio" version = "0.18.4" @@ -1781,6 +1864,17 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-ip-address" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1900,6 +1994,35 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2199,6 +2322,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2506,7 +2639,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2577,6 +2710,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3060,7 +3206,7 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3208,6 +3354,8 @@ version = "0.0.0" dependencies = [ "flate2", "libc", + "local-ip-address", + "rcgen", "reqwest 0.12.28", "serde", "serde_json", @@ -3219,6 +3367,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-store", "tempfile", + "time", "zip", "zstd", ] @@ -5138,6 +5287,15 @@ dependencies = [ "rustix", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 002d64bb..8c5a493a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -13,6 +13,14 @@ tauri-build = { version = "2", features = [] } [dependencies] flate2 = "1" libc = "0.2" +# Enumerating this machine's own interface addresses, so the generated +# certificate can name the IPs a phone will actually dial. std has no API for +# this and the platform calls differ three ways. +local-ip-address = "0.6" +# Generating StemDeck's own LAN certificate on the user's machine. A shipped +# certificate would ship its private key to every download, which is strictly +# worse than plain http because it looks secure. See src/certs.rs. +rcgen = "0.13" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -25,6 +33,9 @@ tar = "0.4" tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-store = "2" +# Certificate validity dates. Already in the tree via rcgen; named here because +# certs.rs uses it directly. +time = "0.3" zip = { version = "2", default-features = false, features = ["deflate"] } zstd = "0.13" diff --git a/desktop/src-tauri/src/certs.rs b/desktop/src-tauri/src/certs.rs new file mode 100644 index 00000000..014947ba --- /dev/null +++ b/desktop/src-tauri/src/certs.rs @@ -0,0 +1,275 @@ +//! The TLS certificate StemDeck serves to phones on the LAN. +//! +//! Transpose is an AudioWorklet, and browsers hand that out only on a secure +//! origin. `http://192.168.x.x` is not one, so a phone reaching StemDeck over +//! plain http gets playback and a dead key control. TLS is the only thing that +//! changes that, which means the desktop app needs a certificate. +//! +//! It generates its own, here, on the user's machine. The alternative -- a +//! certificate committed to the repo and shipped in every download -- would +//! publish its private key along with it, so anyone could impersonate any +//! StemDeck install on any network. That is strictly worse than plain http, +//! because it looks secure while offering nothing. +//! +//! The consequence, which is unavoidable and not a defect: the certificate is +//! signed by nobody, so the phone shows its "connection is not private" screen +//! the first time. The user taps through once per device. Settings says so, in +//! red, before they ever see it. +//! +//! Everything lives under the data directory, beside `jobs/` and +//! `settings.json`, so a portable install keeps the whole app inside its own +//! folder and deleting that folder leaves nothing behind. + +use std::fs; +use std::net::{IpAddr, Ipv4Addr}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, SanType}; +use serde::{Deserialize, Serialize}; +use time::{Duration as TimeDuration, OffsetDateTime}; + +/// Safari rejects a server certificate whose lifetime exceeds 398 days, and +/// has since 2020. Self-signed is no exemption, so a ten-year certificate +/// would fail on exactly the device this feature exists for. 397 days keeps a +/// day in hand against clock skew. +const VALID_DAYS: i64 = 397; + +/// Regenerate this long before expiry, so a certificate never goes stale +/// between one launch and the next. +const RENEW_WITHIN_SECS: i64 = 14 * 24 * 3600; + +/// Paths to a certificate and its key, both PEM. +pub struct LanCertificate { + pub cert: PathBuf, + pub key: PathBuf, +} + +/// What the certificate on disk was made for. +/// +/// Recorded beside it rather than parsed back out of the DER, which would mean +/// an x509 parser in the tree to answer two questions we already knew the +/// answers to when we wrote the file. +#[derive(Serialize, Deserialize, Default)] +struct CertMeta { + /// The IPv4 addresses in the certificate's SANs, sorted. + ips: Vec, + /// Unix seconds. Compared against the clock, so no date parsing is needed. + not_after: i64, +} + +/// This machine's LAN IPv4 addresses: the ones another device could dial. +/// +/// Mirrors `_is_lan_ipv4` in app/main.py, which decides the addresses shown in +/// Settings. The two lists have to agree, or the app hands out an address the +/// certificate does not cover. +fn lan_ipv4s() -> Vec { + let mut out: Vec = local_ip_address::list_afinet_netifas() + .unwrap_or_default() + .into_iter() + .filter_map(|(_, ip)| match ip { + IpAddr::V4(v4) => Some(v4), + // Link-local IPv6 needs a zone index no browser will accept. + IpAddr::V6(_) => None, + }) + // 169.254.x is what an interface picks when DHCP failed; nothing is + // reachable there. + .filter(|v4| !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified()) + .collect(); + out.sort(); + out.dedup(); + out +} + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Read the record beside the certificate, if it is there and intact. +fn read_meta(path: &Path) -> Option { + serde_json::from_str(&fs::read_to_string(path).ok()?).ok() +} + +/// The certificate for this machine, generating or renewing it if needed. +/// +/// Regenerates when the machine's addresses have changed -- a new router, a +/// different Wi-Fi network, a DHCP lease that moved -- because a certificate +/// that does not name the IP being dialled produces a worse warning than the +/// ordinary self-signed one, and on some browsers no way through at all. +pub fn ensure(data_dir: &Path) -> Result { + let dir = data_dir.join("certs"); + fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?; + let cert = dir.join("lan.crt"); + let key = dir.join("lan.key"); + let meta_path = dir.join("lan.json"); + + let ips = lan_ipv4s(); + let want: Vec = ips.iter().map(|ip| ip.to_string()).collect(); + + if cert.is_file() && key.is_file() { + if let Some(meta) = read_meta(&meta_path) { + let covers = want.iter().all(|ip| meta.ips.contains(ip)); + let fresh = meta.not_after - now_secs() > RENEW_WITHIN_SECS; + if covers && fresh { + return Ok(LanCertificate { cert, key }); + } + } + } + + let not_before = OffsetDateTime::now_utc() - TimeDuration::days(1); + let not_after = not_before + TimeDuration::days(VALID_DAYS + 1); + + let mut params = CertificateParams::default(); + params.not_before = not_before; + params.not_after = not_after; + let mut dn = DistinguishedName::new(); + // What the phone shows when someone taps through to inspect it. Naming the + // app is the whole value: it tells the user the warning is the thing they + // were just told to expect. + dn.push(DnType::CommonName, "StemDeck"); + dn.push(DnType::OrganizationName, "StemDeck"); + params.distinguished_name = dn; + params.subject_alt_names = ips + .iter() + .map(|ip| SanType::IpAddress(IpAddr::V4(*ip))) + .chain([ + // The loopback listener is plain http, but a user may still reach + // the TLS port from the host machine, and an unnamed address there + // is a warning for no reason. + SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)), + SanType::DnsName("localhost".try_into().map_err(|e| format!("{e:?}"))?), + ]) + .collect(); + + let key_pair = KeyPair::generate().map_err(|e| format!("could not generate a key: {e}"))?; + let signed = params + .self_signed(&key_pair) + .map_err(|e| format!("could not sign the certificate: {e}"))?; + + // Key first: a certificate with no key beside it would be picked up as + // usable on the next launch and fail at bind instead of regenerating. + write_private(&key, &key_pair.serialize_pem())?; + fs::write(&cert, signed.pem()) + .map_err(|e| format!("could not write {}: {e}", cert.display()))?; + let meta = CertMeta { + ips: want, + not_after: not_after.unix_timestamp(), + }; + fs::write( + &meta_path, + serde_json::to_string_pretty(&meta).unwrap_or_default(), + ) + .map_err(|e| format!("could not write {}: {e}", meta_path.display()))?; + + Ok(LanCertificate { cert, key }) +} + +/// Write a private key readable only by its owner. +/// +/// It never leaves this machine, but it is still a private key, and the data +/// directory of a portable install can sit somewhere shared. +fn write_private(path: &Path, pem: &str) -> Result<(), String> { + fs::write(path, pem).map_err(|e| format!("could not write {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn crt(dir: &Path) -> String { + fs::read_to_string(dir.join("certs").join("lan.crt")).unwrap() + } + + #[test] + fn generates_a_certificate_and_a_key() { + let dir = tempfile::tempdir().unwrap(); + let got = ensure(dir.path()).unwrap(); + assert!(got.cert.is_file()); + assert!(got.key.is_file()); + assert!(crt(dir.path()).starts_with("-----BEGIN CERTIFICATE-----")); + } + + #[test] + fn it_lives_under_the_data_directory_and_nowhere_else() { + // The whole point of a portable install: deleting the folder leaves + // nothing behind. + let dir = tempfile::tempdir().unwrap(); + let got = ensure(dir.path()).unwrap(); + assert!(got.cert.starts_with(dir.path())); + assert!(got.key.starts_with(dir.path())); + } + + #[test] + fn a_second_call_reuses_the_same_certificate() { + // Regenerating per launch would re-prompt every phone every time. + let dir = tempfile::tempdir().unwrap(); + ensure(dir.path()).unwrap(); + let first = crt(dir.path()); + ensure(dir.path()).unwrap(); + assert_eq!(first, crt(dir.path())); + } + + #[test] + fn it_regenerates_when_the_machine_has_a_new_address() { + let dir = tempfile::tempdir().unwrap(); + ensure(dir.path()).unwrap(); + let before = crt(dir.path()); + // Stand in for a router change by claiming the certificate was made + // for an address this machine no longer has. + let meta_path = dir.path().join("certs").join("lan.json"); + let meta = CertMeta { + ips: vec!["203.0.113.1".into()], + not_after: now_secs() + 300 * 24 * 3600, + }; + fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap(); + ensure(dir.path()).unwrap(); + // Only meaningful on a machine that has a LAN address to miss. + if !lan_ipv4s().is_empty() { + assert_ne!(before, crt(dir.path())); + } + } + + #[test] + fn it_regenerates_before_it_expires() { + let dir = tempfile::tempdir().unwrap(); + ensure(dir.path()).unwrap(); + let before = crt(dir.path()); + let meta_path = dir.path().join("certs").join("lan.json"); + let mut meta = read_meta(&meta_path).unwrap(); + meta.not_after = now_secs() + 3600; // about to lapse + fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap(); + ensure(dir.path()).unwrap(); + assert_ne!(before, crt(dir.path())); + } + + #[test] + fn it_recovers_from_a_missing_key() { + // Half a pair on disk must not be served: uvicorn would fail to bind + // and the user would lose LAN access with nothing to explain it. + let dir = tempfile::tempdir().unwrap(); + let got = ensure(dir.path()).unwrap(); + fs::remove_file(&got.key).unwrap(); + assert!(ensure(dir.path()).unwrap().key.is_file()); + } + + #[test] + fn the_certificate_lasts_under_the_398_day_limit() { + // Safari refuses anything longer, which would break the one device + // this feature is for. + let dir = tempfile::tempdir().unwrap(); + ensure(dir.path()).unwrap(); + let meta = read_meta(&dir.path().join("certs").join("lan.json")).unwrap(); + let days = (meta.not_after - now_secs()) / 86400; + assert!(days < 398, "certificate valid for {days} days"); + assert!(days > 300, "certificate valid for only {days} days"); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 72b29486..3478d640 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,3 +1,5 @@ +mod certs; + use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -24,6 +26,12 @@ use zip::ZipArchive; const SETUP_VERSION: u64 = 1; +/// Preferred port for the LAN https listener. 8443 is the conventional +/// alternative https port, so it reads as intended rather than arbitrary in a +/// firewall prompt. Taken ports fall back to any free one, same as the http +/// listener, so this is a preference and never a requirement. +const HTTPS_PORT: u16 = 8443; + // ── In-app updater platform support (#421) ────────────────────────────────── // // Windows and Linux ship the same shape: a flat directory with the executable, @@ -324,6 +332,27 @@ struct GpuSetup { } fn main() { + // `StemDeck --emit-lan-cert ` writes a certificate into /certs and + // exits without opening a window. Two callers: the Python test suite, which + // needs a certificate made by the code that actually ships rather than a + // hand-rolled stand-in that could drift from it, and anyone diagnosing a + // phone that will not accept the one on disk. + let mut args = env::args().skip(1); + if args.next().as_deref() == Some("--emit-lan-cert") { + let dir = args.next().unwrap_or_else(|| ".".to_string()); + match certs::ensure(Path::new(&dir)) { + Ok(c) => { + println!("{}", c.cert.display()); + println!("{}", c.key.display()); + } + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } + return; + } + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::default().build()) @@ -1534,6 +1563,26 @@ fn start_backend( })?; patch_pyvenv_cfg(&python); let (port, port_guard) = reserve_port(bind_host, configured_port())?; + // The LAN half of the pair. The webview talks plain http to the + // loopback listener above, because a self-signed certificate would + // raise an interstitial a Tauri window has no way to click through; + // phones get this one, because without TLS their origin is insecure + // and the browser withholds the AudioWorklet transpose runs on. + // Best-effort throughout: no certificate, no free port, or a backend + // too old to read the variables all mean LAN transpose is unavailable, + // never that StemDeck fails to start. + let https = certs::ensure(&data_dir) + .map_err(|e| { + eprintln!("stemdeck: no LAN certificate, https disabled: {e}"); + e + }) + .ok() + .and_then(|cert| { + reserve_port(bind_host, HTTPS_PORT) + .map_err(|e| eprintln!("stemdeck: no port for https: {e}")) + .ok() + .map(|(https_port, guard)| (cert, https_port, guard)) + }); let url = format!("http://127.0.0.1:{port}"); let log_path = data_dir.join("logs").join("backend.log"); let (stdout, stderr) = prepare_backend_stdio(&log_path).unwrap_or_else(|_| { @@ -1607,6 +1656,14 @@ fn start_backend( .stdout(stdout) .stderr(stderr); + // app/core/tls_listener reads these three together; any one missing + // means it stays off and the app is exactly what it was before. + if let Some((ref cert, https_port, _)) = https { + cmd.env("STEMDECK_SSL_CERT", &cert.cert) + .env("STEMDECK_SSL_KEY", &cert.key) + .env("STEMDECK_HTTPS_PORT", https_port.to_string()); + } + apply_ffmpeg_path(&mut cmd, &data_dir)?; #[cfg(windows)] @@ -1619,8 +1676,10 @@ fn start_backend( let mut child = cmd .spawn() .map_err(|e| format!("failed to start backend: {e}"))?; - // Release the reserved port immediately after spawn so uvicorn can bind it. + // Release the reserved ports immediately after spawn so uvicorn can + // bind them. Both, or the companion listener finds its own port held. drop(port_guard); + drop(https); if let Err(err) = wait_for_health( &mut child, diff --git a/scripts/linux/make-portable.sh b/scripts/linux/make-portable.sh index 742e07ec..c3c014fe 100755 --- a/scripts/linux/make-portable.sh +++ b/scripts/linux/make-portable.sh @@ -226,6 +226,17 @@ done # package, and the NVIDIA variant pip-installs CUDA torch into this very tree on # the user's first run (install_cuda_torch). +# yt-dlp ships ~940 site extractors. StemDeck rejects every host but YouTube +# and SoundCloud before yt-dlp is called, so the rest are unreachable -- and +# several dozen of them are adult sites, named as such, plus a 15,000-line +# lazy_extractors.py listing every one of those domains. None of that belongs +# on a user's disk. The script verifies itself: it re-imports the pruned tree +# and asserts YouTube and SoundCloud still match, because the import check +# below would not catch a broken registry -- extractors resolve lazily through +# __getattr__, so `import yt_dlp` succeeds even when none of them load. +"$BUNDLED_PYTHON" "${REPO_ROOT}/scripts/prune_ytdlp_extractors.py" \ + "${PYTHON_DIR}/lib/python${PYTHON_VERSION}/site-packages" + # Re-verify after the widened strip: the check above ran before it and would not # catch a strip that removed something load-bearing (#407, #421). "$BUNDLED_PYTHON" -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile, audio_separator, onnxruntime; print('Post-strip import check OK')" diff --git a/scripts/macos/make-runtime-pack.sh b/scripts/macos/make-runtime-pack.sh index 72562708..9f3c1125 100755 --- a/scripts/macos/make-runtime-pack.sh +++ b/scripts/macos/make-runtime-pack.sh @@ -198,6 +198,17 @@ echo "${QJS_SHA256} ${QJS_DIR}/qjs" | shasum -a 256 -c - >/dev/null || { } chmod +x "${QJS_DIR}/qjs" +echo "==> Pruning unreachable yt-dlp extractors" +# yt-dlp ships ~940 site extractors. StemDeck rejects every host but YouTube +# and SoundCloud before yt-dlp is called, so the rest are unreachable -- and +# several dozen of them are adult sites, named as such, plus a 15,000-line +# lazy_extractors.py listing every one of those domains. None of that belongs +# on a user's disk. Runs before the import check below, so that check doubles +# The script verifies itself: it re-imports the pruned tree and asserts +# YouTube and SoundCloud still match. +"$PYTHON_DIR/bin/python" "${REPO_ROOT}/scripts/prune_ytdlp_extractors.py" \ + "$PYTHON_DIR/lib/python${PYTHON_VERSION}/site-packages" + echo "==> Capturing dependency inventory" mkdir -p "$RUNTIME_DIR/licenses" uv pip list --system --python "$PYTHON_DIR/bin/python" --format=json > "$RUNTIME_DIR/licenses/pip-list.json" diff --git a/scripts/prune_ytdlp_extractors.py b/scripts/prune_ytdlp_extractors.py new file mode 100644 index 00000000..1d8ca421 --- /dev/null +++ b/scripts/prune_ytdlp_extractors.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Ship yt-dlp with only the extractors StemDeck can actually reach. + +yt-dlp bundles ~940 site extractors. StemDeck accepts YouTube and SoundCloud +and nothing else -- `validate_youtube_url` in app/pipeline/download.py rejects +every other host before yt-dlp is ever called -- so the rest are unreachable +code that we nonetheless copy onto every user's disk. + +That is mostly a nuisance, except for the part that is not: several dozen of +them are adult sites, named as such. `pornhub.py`, `xhamster.py`, +`spankbang.py`, `chaturbate.py` and friends sit in the install directory, and +`lazy_extractors.py` is a single 15,000-line file listing every one of those +domains as a URL regex. A music tool has no business putting that on someone's +computer, and "it is dormant" is not an answer to a user who found it, or to a +corporate scanner that indexed it. + +So the packaging scripts run this after installing dependencies and before the +post-strip import check, which then doubles as the proof the prune was safe. + +WHAT IS KEPT, AND WHY IT IS NOT THE OBVIOUS LIST + + youtube, soundcloud what StemDeck downloads + generic the loader imports GenericIE *by name* as the final + fallback, so it is not optional + common, commonprotocols, unsupported + base classes and the "this site is not supported" path + modules that the REST of yt_dlp imports from outside + extractor/. Today that is openload (YoutubeDL.py), + adobepass (yt_dlp/__init__.py) and afreecatv, whose + helper `downloader/soop.py` imports. A hand-written + list would have missed afreecatv, and `import yt_dlp` + would fail outright. + +Everything reachable from those, transitively, comes along. + +HOW THE REGISTRY IS REBUILT + +`extractor/extractors.py` prefers `lazy_extractors.py` and falls back to +`_extractors.py` on ImportError. lazy_extractors.py has to go regardless: it is +where all the domain strings live, which is the whole point. Removing it puts +the loader on its own documented fallback path, so `_extractors.py` is rewritten +to import just what remains. No patching of yt-dlp's logic. + +Idempotent: running it twice is a no-op. + +Usage: + python scripts/prune_ytdlp_extractors.py [--no-verify] +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import shutil +import subprocess +import sys + +# Extractors StemDeck itself reaches, plus the ones the loader hard-requires. +# Anything else in the keep set is discovered, never assumed. +SEEDS = frozenset( + { + "youtube", + "soundcloud", + "generic", + "common", + "commonprotocols", + "unsupported", + } +) + +# Files that are the registry itself rather than an extractor. +REGISTRY = frozenset({"__init__.py", "extractors.py", "_extractors.py"}) + +# The classes _extractors.py must expose. Kept explicit so that a yt-dlp release +# renaming one fails here, loudly, instead of at a user's first download. +EXPORTS = { + "soundcloud": ( + "SoundcloudIE", + "SoundcloudPlaylistIE", + "SoundcloudSetIE", + "SoundcloudUserIE", + ), + "youtube": ("YoutubeIE", "YoutubePlaylistIE", "YoutubeTabIE"), + "generic": ("GenericIE",), +} + +_SIBLING_IMPORT = re.compile(r"^\s*from \.(\w+)", re.M) +_CROSS_IMPORT = re.compile(r"^\s*from \.{1,2}extractor\.(\w+) import", re.M) + + +def discover_external_seeds(pkg: pathlib.Path) -> set[str]: + """Extractor modules that the rest of yt_dlp imports by name. + + These are invisible from inside extractor/ and are exactly the ones a + hand-maintained list gets wrong, so they are read out of the source every + time rather than written down once. + """ + found: set[str] = set() + extractor_dir = pkg / "extractor" + for path in pkg.rglob("*.py"): + if extractor_dir in path.parents or path.parent == extractor_dir: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + found.update(_CROSS_IMPORT.findall(text)) + return found + + +def _files_for(root: pathlib.Path, name: str) -> list[pathlib.Path]: + """Every source file of one extractor, module or package.""" + module = root / f"{name}.py" + if module.is_file(): + return [module] + package = root / name + return sorted(package.rglob("*.py")) if package.is_dir() else [] + + +def resolve_keep_set(root: pathlib.Path, seeds: set[str]) -> set[str]: + """Close the seed set over sibling imports.""" + keep: set[str] = set() + queue = list(seeds) + while queue: + name = queue.pop() + if name in keep: + continue + files = _files_for(root, name) + if not files: + # `from .utils import ...` and friends resolve outside extractor/. + continue + keep.add(name) + for path in files: + text = path.read_text(encoding="utf-8", errors="ignore") + queue.extend(_SIBLING_IMPORT.findall(text)) + return keep + + +def render_registry(keep: set[str]) -> str: + lines = [ + "# flake8: noqa: F401", + "# Generated by scripts/prune_ytdlp_extractors.py at package time.", + "#", + "# StemDeck rejects every host but YouTube and SoundCloud before yt-dlp is", + "# called, so the ~930 other extractors were unreachable. They are not", + "# shipped, which also keeps several dozen adult-site modules and their", + "# URL regexes off the user's disk.", + "", + ] + for module, names in EXPORTS.items(): + if module not in keep: + continue + if len(names) == 1: + lines.append(f"from .{module} import {names[0]}") + else: + lines.append(f"from .{module} import (") + lines.extend(f" {n}," for n in names) + lines.append(")") + return "\n".join(lines) + "\n" + + +def verify(site_packages: pathlib.Path) -> None: + """Prove the pruned tree still loads and still matches the two hosts. + + Run in a subprocess so it exercises a cold import of what will ship, rather + than whatever this process already has in sys.modules. + """ + code = ( + "import pathlib, sys\n" + "import yt_dlp\n" + # Prove we are checking the tree that was just pruned. Without this the + # check happily passes against some other yt-dlp on sys.path and reports + # a green result for a bundle nobody looked at. + "want = pathlib.Path(sys.argv[1]).resolve()\n" + "got = pathlib.Path(yt_dlp.__file__).resolve()\n" + "assert want in got.parents, f'verified the wrong yt_dlp: {got}'\n" + "from yt_dlp.extractor import get_info_extractor, gen_extractor_classes\n" + "names = sorted(c.IE_NAME for c in gen_extractor_classes())\n" + "assert get_info_extractor('Youtube').suitable(" + "'https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'YouTube URL no longer matches'\n" + "assert get_info_extractor('Youtube').suitable(" + "'https://youtu.be/dQw4w9WgXcQ'), 'youtu.be URL no longer matches'\n" + "assert get_info_extractor('Soundcloud').suitable(" + "'https://soundcloud.com/artist/track'), 'SoundCloud URL no longer matches'\n" + "print(' verified:', ', '.join(names))\n" + ) + result = subprocess.run( + [sys.executable, "-c", code, str(site_packages)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.stderr.write(result.stdout + result.stderr) + raise SystemExit("pruned yt-dlp failed verification; refusing to ship it") + sys.stdout.write(result.stdout) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site_packages", type=pathlib.Path) + parser.add_argument( + "--no-verify", + action="store_true", + help="skip the post-prune import check (the caller runs its own)", + ) + args = parser.parse_args() + + pkg = args.site_packages / "yt_dlp" + root = pkg / "extractor" + if not root.is_dir(): + raise SystemExit(f"no yt_dlp extractor directory under {args.site_packages}") + + seeds = set(SEEDS) | discover_external_seeds(pkg) + keep = resolve_keep_set(root, seeds) + + missing = sorted(set(EXPORTS) - keep) + if missing: + raise SystemExit(f"required extractors missing from yt-dlp: {missing}") + + removed = 0 + for path in sorted(root.iterdir()): + if path.name in REGISTRY: + continue + if path.name == "__pycache__": + shutil.rmtree(path) + continue + stem = path.stem if path.suffix == ".py" else path.name + if stem in keep: + continue + shutil.rmtree(path) if path.is_dir() else path.unlink() + removed += 1 + + lazy = root / "lazy_extractors.py" + if lazy.exists(): + lazy.unlink() + + (root / "_extractors.py").write_text(render_registry(keep), encoding="utf-8") + shutil.rmtree(root / "__pycache__", ignore_errors=True) + + print(f"==> Pruned yt-dlp extractors: removed {removed}, kept {len(keep)}") + print(f" kept: {', '.join(sorted(keep))}") + if not args.no_verify: + verify(args.site_packages) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index ada93657..df1b4423 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -317,6 +317,18 @@ Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages") -Directory -Force # Installation may result in an incomplete environment" -- a broken torch on the # machines that most need a working one, to save a few hundred KB. +# yt-dlp ships ~940 site extractors. StemDeck rejects every host but YouTube +# and SoundCloud before yt-dlp is called, so the rest are unreachable -- and +# several dozen of them are adult sites, named as such, plus a 15,000-line +# lazy_extractors.py listing every one of those domains. None of that belongs +# on a user's disk. The script verifies itself: it re-imports the pruned tree +# and asserts YouTube and SoundCloud still match, because the import check +# below would not catch a broken registry -- extractors resolve lazily through +# __getattr__, so `import yt_dlp` succeeds even when none of them load. +& $PythonExe (Join-Path $Root "scripts\prune_ytdlp_extractors.py") ` + (Join-Path $PythonDir "Lib\site-packages") +Assert-LastExitCode "pruning yt-dlp extractors" + # The strip above widened what ships (#421), so re-verify the packaged # interpreter can still import everything the pipeline needs -- the earlier # import check ran pre-strip and pre-bundle, and would not catch a strip that diff --git a/static/css/daw.css b/static/css/daw.css index 3ec1eef4..5b093ec3 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -979,11 +979,16 @@ input, textarea { font-family: inherit; } .settings-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 5px 12px 9px; cursor: pointer; margin-bottom: -1px; } .settings-tab:hover { color: var(--fg-2); } .settings-tab.active { color: var(--fg); border-bottom-color: var(--accent); } -/* Every pane fills the fixed dialog body so all tabs are the same size; the - General pane (the tall one, with the tracks table) scrolls within it. */ -.settings-pane { display: flex; flex-direction: column; min-height: 0; flex: 1; } -.settings-pane[data-pane="general"] { - flex: 1; overflow-y: auto; +/* Every pane fills the fixed dialog body so all tabs are the same size, and + every pane scrolls inside it. + Only General used to scroll, which was a bet that no other tab would ever + grow past the dialog's 540px. Network did, once it gained the secure-origin + warning and one QR card per network interface: the content simply ran on + underneath the footer, with the Done button sitting on top of the Port + setting. A pane that cannot scroll does not clip, it overlaps. */ +.settings-pane { + display: flex; flex-direction: column; min-height: 0; flex: 1; + overflow-y: auto; /* Reserve a gutter for the scrollbar so it never overlaps the right-aligned controls (Compute device, Out of sync tracks). padding-right insets the content; the equal negative margin lets that gutter sit in the card's own @@ -997,7 +1002,11 @@ input, textarea { font-family: inherit; } .settings-pane.hidden { display: none; } .settings-pane[data-pane="general"] .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; } -.settings-foot { display: flex; justify-content: flex-end; margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border); } +/* flex-shrink: 0 so a tall pane squeezes itself, never the footer. */ +.settings-foot { + display: flex; justify-content: flex-end; flex-shrink: 0; + margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border); +} .settings-done { min-height: 32px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.35); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 0 22px; cursor: pointer; } .settings-done:hover { background: rgba(244,183,64,0.24); } /* Right-aligned form controls share a fixed width so they line up down the column. */ @@ -1036,6 +1045,21 @@ input, textarea { font-family: inherit; } .settings-net { margin-top: 10px; font-size: 11px; color: var(--muted); } .settings-net.hidden { display: none; } .settings-net-empty { color: var(--muted); } +/* Transpose-over-the-network warning. Red because the consequence is a control + that does nothing, or a browser page that says the connection is not + private: both look like the app is broken, and both are worth reading before + the user hands the address to their phone. */ +.settings-net-warn { + margin: 8px 0 10px; + padding: 8px 10px; + border-left: 3px solid var(--danger); + border-radius: 4px; + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + font-size: 11px; + line-height: 1.5; +} +.settings-net-warn.hidden { display: none; } .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: 28px; } @@ -3409,7 +3433,9 @@ input, textarea { font-family: inherit; } /* Wide enough for two columns of three cards. The single column it replaced was 420px and always scrolled: five sections stacked is taller than any normal window, so half the people on the list were never seen. */ -.friends-card { width: min(780px, calc(100vw - 32px)); } +/* Wide enough that a four-person category is one row rather than two, which is + what was pushing the list past the dialog and putting a scrollbar on it. */ +.friends-card { width: min(1020px, calc(100vw - 32px)); } .friends-card .lib-friends-grid { width: 100%; margin-top: 4px; } .lib-friends-grid { display: grid; @@ -3418,11 +3444,21 @@ input, textarea { font-family: inherit; } align-items: start; text-align: left; padding: 2px 2px 0 0; - /* Kept as a floor, not as the normal case: a very short window still has to - reach the bottom of the list somehow. */ - max-height: min(74vh, 660px); + /* Nothing here is meant to be selected or dragged: these are link tiles, and + a stray drag across two of them selects half the dialog and leaves it + highlighted. */ + user-select: none; + -webkit-user-select: none; + /* Kept as a floor, not as the normal case: the list is meant to fit, but a + very short window still has to reach the bottom of it somehow. */ + max-height: min(88vh, 900px); overflow-y: auto; } +.lib-friends-grid img { + -webkit-user-drag: none; + user-select: none; + pointer-events: none; +} .lib-friends-col { min-width: 0; } .lib-friends-split { align-self: stretch; background: var(--border); } /* One column again once two would squeeze the cards below three per row. */ diff --git a/static/img/friends/alexandre-borges.jpg b/static/img/friends/alexandre-borges.jpg new file mode 100644 index 00000000..5f54fabe Binary files /dev/null and b/static/img/friends/alexandre-borges.jpg differ diff --git a/static/img/friends/dead-roses.jpg b/static/img/friends/dead-roses.jpg new file mode 100644 index 00000000..3b27a5f0 Binary files /dev/null and b/static/img/friends/dead-roses.jpg differ diff --git a/static/img/friends/killah-trakz.jpg b/static/img/friends/killah-trakz.jpg new file mode 100644 index 00000000..aef4c0db Binary files /dev/null and b/static/img/friends/killah-trakz.jpg differ diff --git a/static/index.html b/static/index.html index f3d29ad5..02255da6 100644 --- a/static/index.html +++ b/static/index.html @@ -45,7 +45,7 @@ StemDeck

We Recommend

-

Wonderful people doing beautiful work. Go meet them ❤️

+

StemDeck is free and does not accept any money, sponsorship, or funding from anyone listed below. I share these makers and artists and communities purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️

diff --git a/static/js/catalog.js b/static/js/catalog.js index e4ffd69f..eaca8afc 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -59,6 +59,27 @@ const FRIEND_GROUPS = [ roleKey: "friends.role.moreNotesLessTalk", url: "https://www.youtube.com/@morenoteslesstalk", }, + { + name: "Analog4Lyfe", + roleKey: "friends.role.analog4lyfe", + url: "https://www.instagram.com/analog4lyfe", + logo: "/img/friends/analog4lyfe.jpg", + avatar: true, + }, + { + name: "Dead röses", + roleKey: "friends.role.deadRoses", + url: "https://www.instagram.com/dead_rosesband", + logo: "/img/friends/dead-roses.jpg", + avatar: true, + }, + { + name: "Killah Trakz", + roleKey: "friends.role.killahTrakz", + url: "https://www.instagram.com/killahtrakz/", + logo: "/img/friends/killah-trakz.jpg", + avatar: true, + }, ], }, { @@ -89,13 +110,6 @@ const FRIEND_GROUPS = [ { labelKey: "friends.cat.gear", members: [ - { - name: "Analog4Lyfe", - roleKey: "friends.role.analog4lyfe", - url: "https://www.instagram.com/analog4lyfe", - logo: "/img/friends/analog4lyfe.jpg", - avatar: true, - }, { name: "Empress Effects", roleKey: "friends.role.empressEffects", @@ -146,6 +160,18 @@ const FRIEND_GROUPS = [ }, ], }, + { + labelKey: "friends.cat.writers", + members: [ + { + name: "Alexandre Borges", + roleKey: "friends.role.alexandreBorges", + url: "https://www.instagram.com/alexgram_b/", + logo: "/img/friends/alexandre-borges.jpg", + avatar: true, + }, + ], + }, ]; // Instagram glyph (Simple Icons), shown under tiles that link to Instagram. @@ -607,6 +633,25 @@ function applyTrackInfoToPanel(track) { } } +/** + * Tell the server a track was binned or brought back. + * + * The Trash used to be purely local, which meant it was per-device: a track + * deleted here stayed in GET /api/jobs, and the phone UI builds its whole + * library from that endpoint, so it listed everything the user thought they + * had thrown away. The local folders are still the desktop's own view; this is + * what makes the server agree with it. + * + * Deliberately not awaited by the callers. Binning a track must feel instant + * and must not fail because the backend blinked; the reconcile on next load + * catches anything that did not land. + */ +function syncTrashToServer(trackId, trashed) { + const action = trashed ? "trash" : "restore"; + fetch(`/api/jobs/${encodeURIComponent(trackId)}/${action}`, { method: "POST" }) + .catch((e) => console.warn(`[catalog] could not ${action} ${trackId} on the server`, e)); +} + function moveTrackToTrash(trackId) { if (!tracks[trackId]) return; removeTrackFromFolders(trackId); @@ -617,6 +662,7 @@ function moveTrackToTrash(trackId) { // purgeTrash() does the same on permanent delete, for a job that errors // after being trashed but before it's purged. dismissFailuresByJobId(trackId); + syncTrashToServer(trackId, true); saveState(); render(); } @@ -1072,6 +1118,7 @@ function restoreTrackFromTrash(trackId) { folders.unshift(target); } if (!target.items.includes(trackId)) target.items.push(trackId); + syncTrashToServer(trackId, false); saveState(); render(); } @@ -2800,10 +2847,9 @@ function wireSupportersDialog() { if (grid && grid.dataset.ready !== "1") { grid.dataset.ready = "1"; - // Two columns with a rule between them, rather than one long list. Five - // sections stacked was taller than the dialog on any normal window, so the - // whole thing scrolled and half the people on it were never seen. Split at - // three, which is where the two sides come out closest in height. + // Two columns with a rule between them, rather than one long list. Six + // sections stacked is taller than the dialog on any normal window, so the + // whole thing scrolled and half the people on it were never seen. const columns = [document.createElement("div"), document.createElement("div")]; const split = document.createElement("div"); split.className = "lib-friends-split"; @@ -2811,12 +2857,47 @@ function wireSupportersDialog() { for (const col of columns) col.className = "lib-friends-col"; grid.append(columns[0], split, columns[1]); - FRIEND_GROUPS.forEach((group, i) => { - const col = columns[i < 3 ? 0 : 1]; + // Alphabetical, and sorted here rather than in FRIEND_GROUPS so that + // adding someone never means finding the right line to put them on. + // + // Categories sort on the *translated* label, which is the only order that + // reads as alphabetical to the person looking at it -- a fixed order taken + // from the English names is arbitrary in the other ten languages. Members + // sort on `name`, which is a proper noun and identical everywhere. + // localeCompare so accents and case land where a reader expects them + // (Dead roses next to Dlima, not after Z). + const collator = new Intl.Collator(getLanguage(), { sensitivity: "base", numeric: true }); + const groups = FRIEND_GROUPS.map((group) => ({ + ...group, + label: i18nT(group.labelKey), + members: [...group.members].sort((a, b) => collator.compare(a.name, b.name)), + })).sort((a, b) => collator.compare(a.label, b.label)); + + // Where to break between the two columns. Counting sections was a fixed + // split at three, which only balanced while every category had the same + // number of people in it; the moment one grew to two rows of cards, one + // side ran past the bottom of the dialog and the other stopped halfway. + // + // So measure in rows instead: a heading plus however many rows of cards + // the category needs, and break wherever the two sides come out closest. + const PER_ROW = 4; + const HEADING = 0.35; // a heading is roughly a third of a card row + const weights = groups.map((g) => HEADING + Math.ceil(g.members.length / PER_ROW)); + const total = weights.reduce((a, b) => a + b, 0); + let best = { at: 1, gap: Infinity }; + let run = 0; + for (let k = 1; k < groups.length; k++) { + run += weights[k - 1]; + const gap = Math.abs(run - (total - run)); + if (gap < best.gap) best = { at: k, gap }; + } + + groups.forEach((group, i) => { + const col = columns[i < best.at ? 0 : 1]; const label = document.createElement("h3"); label.className = "lib-friends-cat"; label.setAttribute("data-i18n", group.labelKey); - label.textContent = i18nT(group.labelKey); + label.textContent = group.label; col.appendChild(label); const row = document.createElement("div"); row.className = "lib-friends-row"; @@ -2852,13 +2933,39 @@ function reconcileAvailability(jobs) { render(); } +/** + * Push this device's Trash up to the server. + * + * Every install that predates server-side Trash has a local bin the backend + * knows nothing about, and those tracks are exactly the ones showing up on the + * user's phone. One pass on load fixes them, and it doubles as the retry for + * any syncTrashToServer call that failed while offline. + * + * One-way on purpose. Letting the server's answer win here would mean a failed + * restore silently re-binning the track on the next load, and the desktop is + * the only client with a Trash to be authoritative about. + */ +function reconcileTrashWithServer(jobs, trashIds) { + for (const state of jobs) { + const shouldBeTrashed = trashIds.has(state.job_id); + if (shouldBeTrashed === (state.trashed_at != null)) continue; + syncTrashToServer(state.job_id, shouldBeTrashed); + } +} + async function syncWithServer() { try { - const res = await fetch("/api/jobs", { cache: "no-store" }); + // trashed=include, because this side keeps its own view of the library and + // needs the whole registry to reconcile against. The default list leaves + // trashed jobs out -- right for the phone, which has no Trash of its own, + // and wrong here: reconcileAvailability would read every one of them as + // "gone from the registry" and mark the user's binned tracks unavailable. + const res = await fetch("/api/jobs?trashed=include", { cache: "no-store" }); if (!res.ok) return; const jobs = await res.json(); const trashIds = new Set(getTrashFolder()?.items || []); const deletedIds = getDeletedJobIds(); + reconcileTrashWithServer(jobs, trashIds); for (const state of jobs) { if (tracks[state.job_id]) continue; if (trashIds.has(state.job_id)) continue; // soft-deleted, skip @@ -3004,6 +3111,7 @@ function networkSettingsHtml() { @@ -3410,6 +3518,21 @@ async function wireNetworkSetting(overlay) { // 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. + // Transpose is an AudioWorklet, which browsers hand out only on a secure + // origin, so what this warning has to say depends on whether the addresses + // below are https. Getting it wrong in either direction is worse than saying + // nothing: over http the key control is simply dead with no reason given, + // and over https the phone throws a "not private" page that looks like the + // app is broken or unsafe when it is neither. + const warnEl = overlay.querySelector(".settings-net-warn"); + if (warnEl) { + const secure = addresses.length > 0 && addresses.every((a) => a.startsWith("https://")); + warnEl.textContent = i18nT( + secure ? "settings.network.transposeCertPrompt" : "settings.network.transposeNeedsHttps", + ); + warnEl.classList.toggle("hidden", addresses.length === 0); + } + if (qrWrap) { qrWrap.textContent = ""; if (addresses.length) { diff --git a/static/js/i18n.js b/static/js/i18n.js index 0f7fd48d..a9b5dca1 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -236,7 +236,7 @@ const en = { "structure.experimental": "Experimental", "doc.title": "StemDeck — split any track into stems", - "topbar.urlPlaceholder": "Search, or paste a YouTube or SoundCloud link, or drop an audio file…", + "topbar.urlPlaceholder": "Search or drop an audio file…", "topbar.removeFile": "Remove file", "topbar.uploadFile": "Upload audio file", "extract.label": "Extract", @@ -470,19 +470,23 @@ const en = { "about.x": "X", "friends.title": "We Recommend", - "friends.tagline": "Wonderful people doing beautiful work. Go meet them ❤️", + "friends.tagline": "StemDeck is free and does not accept any money, sponsorship, or funding from anyone listed below. I share these makers and artists and communities purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️", "friends.closeAria": "Close friends dialog", "friends.cat.artists": "Artists & Creators", "friends.cat.builders": "Instrument Builders & Repair", "friends.cat.gear": "Music Gear", "friends.cat.karaoke": "Music & Karaoke Technology", "friends.cat.media": "Media & Community", + "friends.cat.writers": "Writers & Storytellers", + "friends.role.alexandreBorges": "Portuguese writer, screenwriter, and cultural commentator", "friends.role.joaoGaspar": "Producer, film scorer, touring/session musician", "friends.role.moreNotesLessTalk": "Gear-focused creative project with a raw, tape-recorded identity", "friends.role.dlimaGuitars": "Custom guitars and basses", "friends.role.lisbonGuitarWorks": "Handmade guitars in Lisbon", "friends.role.krisLuthier": "Instrument repair and restoration", "friends.role.analog4lyfe": "Analog gear specialist", + "friends.role.deadRoses": "Cork-based punk rock band", + "friends.role.killahTrakz": "Producer", "friends.role.empressEffects": "Boutique effects pedals", "friends.role.thomann": "Large music-equipment retailer", "friends.role.beltr": "Local, subscription-free karaoke software", @@ -630,6 +634,8 @@ const en = { "settings.network.allowTitle": "Make StemDeck available on your network", "settings.network.allowDesc": "Let other devices (like your phone) open StemDeck at the address below.", "settings.network.lockNote": "Read-only when StemDeck is started in server mode — network access is then set by your server configuration.", + "settings.network.transposeNeedsHttps": "Changing the key will not work on your phone at this address. Phones only allow that over a secure connection (https).", + "settings.network.transposeCertPrompt": "If your phone warns that this connection is not private, it just means the certificate is not one your phone recognises. That is normal on a home network. Tap Advanced, then Continue, once on each device. Changing the key will not work until you do.", "settings.network.port.title": "Port", "settings.network.port.desc": "Port StemDeck runs on. Restart to apply.", "settings.network.noConnection": "No local network connection detected.", @@ -825,7 +831,7 @@ const pl = { "structure.experimental": "Eksperymentalne", "doc.title": "StemDeck — rozdziel dowolny utwór na ścieżki", - "topbar.urlPlaceholder": "Szukaj albo wklej link YouTube lub SoundCloud, albo upuść plik audio…", + "topbar.urlPlaceholder": "Szukaj albo upuść plik audio…", "topbar.removeFile": "Usuń plik", "topbar.uploadFile": "Wgraj plik audio", "extract.label": "Wyodrębnij", @@ -1057,19 +1063,23 @@ const pl = { "about.x": "X", "friends.title": "Polecamy", - "friends.tagline": "Wspaniali ludzie tworzący piękne rzeczy. Poznaj ich ❤️", + "friends.tagline": "StemDeck jest darmowy i nie przyjmuje żadnych pieniędzy, sponsoringu ani finansowania od nikogo z wymienionych poniżej. Dzielę się tymi twórcami, artystami i społecznościami wyłącznie dla radości wskazania Ci wspaniałych ludzi tworzących piękne rzeczy. Poznaj ich ❤️", "friends.closeAria": "Zamknij okno poleceń", "friends.cat.artists": "Artyści i twórcy", "friends.cat.builders": "Lutnicy i naprawa instrumentów", "friends.cat.gear": "Sprzęt muzyczny", "friends.cat.karaoke": "Technologie muzyczne i karaoke", "friends.cat.media": "Media i społeczność", + "friends.cat.writers": "Pisarze i gawędziarze", + "friends.role.alexandreBorges": "Portugalski pisarz, scenarzysta i komentator kultury", "friends.role.joaoGaspar": "Producent, kompozytor muzyki filmowej, muzyk koncertowy i sesyjny", "friends.role.moreNotesLessTalk": "Projekt twórczy skupiony na sprzęcie, o surowym brzmieniu nagranym na taśmę", "friends.role.dlimaGuitars": "Gitary i basy na zamówienie", "friends.role.lisbonGuitarWorks": "Gitary robione ręcznie w Lizbonie", "friends.role.krisLuthier": "Naprawa i renowacja instrumentów", "friends.role.analog4lyfe": "Specjalista od sprzętu analogowego", + "friends.role.deadRoses": "Zespół punkrockowy z Corku", + "friends.role.killahTrakz": "Producent", "friends.role.empressEffects": "Butikowe efekty gitarowe", "friends.role.thomann": "Duży sklep ze sprzętem muzycznym", "friends.role.beltr": "Lokalne karaoke bez abonamentu", @@ -1220,6 +1230,8 @@ const pl = { "settings.network.allowTitle": "Udostępnij StemDeck w swojej sieci", "settings.network.allowDesc": "Pozwala innym urządzeniom (np. telefonowi) otworzyć StemDeck pod adresem poniżej.", "settings.network.lockNote": "Tylko do odczytu, gdy StemDeck jest uruchomiony w trybie serwera — dostęp sieciowy ustala wtedy konfiguracja serwera.", + "settings.network.transposeNeedsHttps": "Zmiana tonacji nie zadziała na telefonie pod tym adresem. Telefony pozwalają na to tylko przez bezpieczne połączenie (https).", + "settings.network.transposeCertPrompt": "Jeśli telefon ostrzeże, że to połączenie nie jest prywatne, oznacza to tylko, że nie rozpoznaje certyfikatu. W sieci domowej jest to normalne. Dotknij Zaawansowane, a potem Kontynuuj, raz na każdym urządzeniu. Do tego czasu zmiana tonacji nie zadziała.", "settings.network.port.title": "Port", "settings.network.port.desc": "Port, na którym działa StemDeck. Uruchom ponownie, aby zastosować.", "settings.network.noConnection": "Nie wykryto lokalnego połączenia sieciowego.", @@ -1404,7 +1416,7 @@ const ja = { "structure.experimental": "試験的", "doc.title": "StemDeck — トラックをパートごとに分離", - "topbar.urlPlaceholder": "検索するか、YouTube・SoundCloud のリンクを貼り付けるか、音声ファイルをドロップ…", + "topbar.urlPlaceholder": "検索するか、音声ファイルをドロップ…", "topbar.removeFile": "ファイルを削除", "topbar.uploadFile": "音声ファイルをアップロード", "extract.label": "抽出", @@ -1635,19 +1647,23 @@ const ja = { "about.x": "X", "friends.title": "おすすめ", - "friends.tagline": "美しい仕事をする素晴らしい人たち。ぜひ会いに行ってみてください ❤️", + "friends.tagline": "StemDeck は無料で、以下に挙げた方々から金銭・スポンサー・資金提供を一切受け取っていません。素晴らしい仕事をする方々を紹介する喜びだけのために、これらの作り手やアーティスト、コミュニティを共有しています。ぜひ会いに行ってみてください ❤️", "friends.closeAria": "おすすめダイアログを閉じる", "friends.cat.artists": "アーティストとクリエイター", "friends.cat.builders": "楽器製作とリペア", "friends.cat.gear": "音楽機材", "friends.cat.karaoke": "音楽とカラオケの技術", "friends.cat.media": "メディアとコミュニティ", + "friends.cat.writers": "作家とストーリーテラー", + "friends.role.alexandreBorges": "ポルトガルの作家、脚本家、文化評論家", "friends.role.joaoGaspar": "プロデューサー、映画音楽家、ツアー/セッションミュージシャン", "friends.role.moreNotesLessTalk": "機材を軸に、テープ録音の生々しさを大切にするクリエイティブプロジェクト", "friends.role.dlimaGuitars": "オーダーメイドのギターとベース", "friends.role.lisbonGuitarWorks": "リスボンの手作りギター", "friends.role.krisLuthier": "楽器のリペアとレストア", "friends.role.analog4lyfe": "アナログ機材のスペシャリスト", + "friends.role.deadRoses": "コーク拠点のパンクロックバンド", + "friends.role.killahTrakz": "プロデューサー", "friends.role.empressEffects": "ブティック系エフェクターペダル", "friends.role.thomann": "大手の楽器機材販売店", "friends.role.beltr": "サブスク不要、ローカルで動くカラオケソフト", @@ -1790,6 +1806,8 @@ const ja = { "settings.network.allowTitle": "ネットワーク上でStemDeckを利用可能にする", "settings.network.allowDesc": "他の端末(スマートフォンなど)が下記のアドレスでStemDeckを開けるようにします。", "settings.network.lockNote": "サーバーモードで起動している場合は読み取り専用です — ネットワークアクセスはサーバー設定で決まります。", + "settings.network.transposeNeedsHttps": "このアドレスではスマートフォンでキーを変更できません。スマートフォンは安全な接続 (https) でのみキー変更を許可します。", + "settings.network.transposeCertPrompt": "スマートフォンに「この接続はプライベートではありません」と表示された場合は、証明書がスマートフォンの知らないものであるというだけです。家庭内のネットワークでは普通のことです。端末ごとに一度だけ「詳細設定」から「続行」を選んでください。それまではキーを変更できません。", "settings.network.port.title": "ポート", "settings.network.port.desc": "StemDeckが動作するポート。適用するには再起動してください。", "settings.network.noConnection": "ローカルネットワーク接続が検出されませんでした。", @@ -1958,7 +1976,7 @@ const zhHans = { "structure.experimental": "实验性", "doc.title": "StemDeck — 将任意曲目分离为音轨", - "topbar.urlPlaceholder": "搜索,或粘贴 YouTube 或 SoundCloud 链接,或拖放音频文件…", + "topbar.urlPlaceholder": "搜索,或拖放音频文件…", "topbar.removeFile": "移除文件", "topbar.uploadFile": "上传音频文件", "extract.label": "提取", @@ -2189,19 +2207,23 @@ const zhHans = { "about.x": "X", "friends.title": "推荐", - "friends.tagline": "一群做着美好事情的了不起的人。去认识他们吧 ❤️", + "friends.tagline": "StemDeck 是免费的,也不接受下列任何人的金钱、赞助或资助。分享这些创作者、艺术家和社区,纯粹是为了把你引向那些做着美好事情的了不起的人。去认识他们吧 ❤️", "friends.closeAria": "关闭推荐对话框", "friends.cat.artists": "艺术家与创作者", "friends.cat.builders": "乐器制作与维修", "friends.cat.gear": "音乐器材", "friends.cat.karaoke": "音乐与卡拉OK技术", "friends.cat.media": "媒体与社区", + "friends.cat.writers": "作家与故事讲述者", + "friends.role.alexandreBorges": "葡萄牙作家、编剧和文化评论人", "friends.role.joaoGaspar": "制作人、电影配乐师、巡演/录音乐手", "friends.role.moreNotesLessTalk": "以器材为主的创作项目,保留磁带录音的粗粝质感", "friends.role.dlimaGuitars": "定制吉他与贝斯", "friends.role.lisbonGuitarWorks": "里斯本手工吉他", "friends.role.krisLuthier": "乐器维修与修复", "friends.role.analog4lyfe": "模拟器材专家", + "friends.role.deadRoses": "来自科克的朋克摇滚乐队", + "friends.role.killahTrakz": "制作人", "friends.role.empressEffects": "精品效果器", "friends.role.thomann": "大型乐器器材零售商", "friends.role.beltr": "本地运行、无需订阅的卡拉OK软件", @@ -2344,6 +2366,8 @@ const zhHans = { "settings.network.allowTitle": "允许在你的网络中使用 StemDeck", "settings.network.allowDesc": "允许其他设备(例如你的手机)通过下方地址打开 StemDeck。", "settings.network.lockNote": "在服务器模式下启动时为只读 — 此时网络访问权限由服务器配置决定。", + "settings.network.transposeNeedsHttps": "在此地址下,手机无法更改调性。手机只允许通过安全连接 (https) 更改调性。", + "settings.network.transposeCertPrompt": "如果手机提示此连接不是私密连接,只是因为手机不认识这个证书。在家庭网络中这很正常。请在每台设备上点击「高级」,然后点击「继续」。在此之前无法更改调性。", "settings.network.port.title": "端口", "settings.network.port.desc": "StemDeck 运行所使用的端口。重启后生效。", "settings.network.noConnection": "未检测到本地网络连接。", @@ -2512,7 +2536,7 @@ const de = { "structure.experimental": "Experimentell", "doc.title": "StemDeck — jeden Track in Stems zerlegen", - "topbar.urlPlaceholder": "Suchen, einen YouTube- oder SoundCloud-Link einfügen oder eine Audiodatei ablegen…", + "topbar.urlPlaceholder": "Suchen oder eine Audiodatei ablegen…", "topbar.removeFile": "Datei entfernen", "topbar.uploadFile": "Audiodatei hochladen", "extract.label": "Extrahieren", @@ -2744,19 +2768,23 @@ const de = { "about.x": "X", "friends.title": "Wir empfehlen", - "friends.tagline": "Wunderbare Menschen, die schöne Arbeit leisten. Lern sie kennen ❤️", + "friends.tagline": "StemDeck ist kostenlos und nimmt von niemandem auf dieser Liste Geld, Sponsoring oder Förderung an. Ich teile diese Macher, Künstler und Communities allein aus Freude daran, dich auf wunderbare Menschen hinzuweisen, die schöne Arbeit leisten. Lern sie kennen ❤️", "friends.closeAria": "Empfehlungsdialog schließen", "friends.cat.artists": "Künstler und Kreative", "friends.cat.builders": "Instrumentenbau und Reparatur", "friends.cat.gear": "Musik-Equipment", "friends.cat.karaoke": "Musik- und Karaoke-Technik", "friends.cat.media": "Medien und Community", + "friends.cat.writers": "Autoren und Erzähler", + "friends.role.alexandreBorges": "Portugiesischer Autor, Drehbuchautor und Kulturkommentator", "friends.role.joaoGaspar": "Produzent, Filmkomponist, Tour- und Sessionmusiker", "friends.role.moreNotesLessTalk": "Kreativprojekt rund um Equipment, roh auf Band aufgenommen", "friends.role.dlimaGuitars": "Gitarren und Bässe nach Maß", "friends.role.lisbonGuitarWorks": "Handgebaute Gitarren aus Lissabon", "friends.role.krisLuthier": "Reparatur und Restaurierung von Instrumenten", "friends.role.analog4lyfe": "Spezialist für analoges Equipment", + "friends.role.deadRoses": "Punkrock-Band aus Cork", + "friends.role.killahTrakz": "Produzent", "friends.role.empressEffects": "Boutique-Effektpedale", "friends.role.thomann": "Großer Händler für Musik-Equipment", "friends.role.beltr": "Lokale Karaoke-Software ohne Abo", @@ -2903,6 +2931,8 @@ const de = { "settings.network.allowTitle": "StemDeck in deinem Netzwerk verfügbar machen", "settings.network.allowDesc": "Ermöglicht anderen Geräten (z. B. deinem Handy), StemDeck unter der unten stehenden Adresse zu öffnen.", "settings.network.lockNote": "Schreibgeschützt, wenn StemDeck im Servermodus gestartet wird — der Netzwerkzugriff wird dann durch deine Serverkonfiguration festgelegt.", + "settings.network.transposeNeedsHttps": "Die Tonart lässt sich unter dieser Adresse auf dem Handy nicht ändern. Handys erlauben das nur über eine sichere Verbindung (https).", + "settings.network.transposeCertPrompt": "Wenn dein Handy warnt, dass diese Verbindung nicht privat ist, kennt es das Zertifikat einfach nicht. Im Heimnetz ist das normal. Tippe einmal pro Gerät auf Erweitert und dann auf Fortfahren. Bis dahin lässt sich die Tonart nicht ändern.", "settings.network.port.title": "Port", "settings.network.port.desc": "Port, auf dem StemDeck läuft. Zum Anwenden neu starten.", "settings.network.noConnection": "Keine lokale Netzwerkverbindung erkannt.", @@ -3077,7 +3107,7 @@ const pt = { "structure.experimental": "Experimental", "doc.title": "StemDeck — separe qualquer faixa em stems", - "topbar.urlPlaceholder": "Pesquise, ou cole um link do YouTube ou SoundCloud, ou solte um arquivo de áudio…", + "topbar.urlPlaceholder": "Pesquise ou solte um arquivo de áudio…", "topbar.removeFile": "Remover arquivo", "topbar.uploadFile": "Enviar arquivo de áudio", "extract.label": "Extrair", @@ -3309,19 +3339,23 @@ const pt = { "about.x": "X", "friends.title": "Recomendamos", - "friends.tagline": "Pessoas maravilhosas fazendo um trabalho lindo. Conheça-as ❤️", + "friends.tagline": "O StemDeck é gratuito e não aceita dinheiro, patrocínio ou financiamento de ninguém listado abaixo. Compartilho estes criadores, artistas e comunidades apenas pela alegria de apontar você para pessoas maravilhosas fazendo um trabalho lindo. Conheça-as ❤️", "friends.closeAria": "Fechar diálogo de recomendações", "friends.cat.artists": "Artistas e criadores", "friends.cat.builders": "Luteria e reparos", "friends.cat.gear": "Equipamentos musicais", "friends.cat.karaoke": "Tecnologia musical e karaokê", "friends.cat.media": "Mídia e comunidade", + "friends.cat.writers": "Escritores e contadores de histórias", + "friends.role.alexandreBorges": "Escritor, roteirista e comentarista cultural português", "friends.role.joaoGaspar": "Produtor, compositor de trilhas, músico de turnê e de estúdio", "friends.role.moreNotesLessTalk": "Projeto criativo focado em equipamentos, com identidade crua gravada em fita", "friends.role.dlimaGuitars": "Guitarras e baixos sob medida", "friends.role.lisbonGuitarWorks": "Guitarras feitas à mão em Lisboa", "friends.role.krisLuthier": "Reparo e restauração de instrumentos", "friends.role.analog4lyfe": "Especialista em equipamentos analógicos", + "friends.role.deadRoses": "Banda de punk rock de Cork", + "friends.role.killahTrakz": "Produtor", "friends.role.empressEffects": "Pedais de efeito boutique", "friends.role.thomann": "Grande varejista de equipamentos musicais", "friends.role.beltr": "Software de karaokê local, sem assinatura", @@ -3468,6 +3502,8 @@ const pt = { "settings.network.allowTitle": "Disponibilizar o StemDeck na sua rede", "settings.network.allowDesc": "Permite que outros dispositivos (como seu celular) abram o StemDeck no endereço abaixo.", "settings.network.lockNote": "Somente leitura quando o StemDeck é iniciado em modo servidor — o acesso à rede é então definido pela configuração do seu servidor.", + "settings.network.transposeNeedsHttps": "Mudar o tom não vai funcionar no celular neste endereço. Celulares só permitem isso em uma conexão segura (https).", + "settings.network.transposeCertPrompt": "Se o celular avisar que a conexão não é privada, é só porque ele não reconhece o certificado. Em uma rede doméstica isso é normal. Toque em Avançado e depois em Continuar, uma vez em cada aparelho. Até lá, mudar o tom não vai funcionar.", "settings.network.port.title": "Porta", "settings.network.port.desc": "Porta em que o StemDeck roda. Reinicie para aplicar.", "settings.network.noConnection": "Nenhuma conexão de rede local detectada.", @@ -3644,7 +3680,7 @@ const id = { "structure.experimental": "Eksperimental", "doc.title": "StemDeck — pisahkan trek apa pun menjadi stem", - "topbar.urlPlaceholder": "Cari, atau tempel tautan YouTube atau SoundCloud, atau seret file audio…", + "topbar.urlPlaceholder": "Cari atau seret file audio…", "topbar.removeFile": "Hapus file", "topbar.uploadFile": "Unggah file audio", "extract.label": "Ekstrak", @@ -3875,19 +3911,23 @@ const id = { "about.x": "X", "friends.title": "Rekomendasi", - "friends.tagline": "Orang-orang hebat yang melakukan pekerjaan indah. Temui mereka ❤️", + "friends.tagline": "StemDeck gratis dan tidak menerima uang, sponsor, atau pendanaan dari siapa pun yang tercantum di bawah. Saya membagikan para pembuat, seniman, dan komunitas ini semata-mata demi kesenangan menunjukkan orang-orang hebat yang melakukan pekerjaan indah. Temui mereka ❤️", "friends.closeAria": "Tutup dialog rekomendasi", "friends.cat.artists": "Artis dan Kreator", "friends.cat.builders": "Pembuat dan Reparasi Instrumen", "friends.cat.gear": "Perangkat Musik", "friends.cat.karaoke": "Teknologi Musik dan Karaoke", "friends.cat.media": "Media dan Komunitas", + "friends.cat.writers": "Penulis dan Pencerita", + "friends.role.alexandreBorges": "Penulis, penulis skenario, dan komentator budaya asal Portugal", "friends.role.joaoGaspar": "Produser, penata musik film, musisi tur dan sesi", "friends.role.moreNotesLessTalk": "Proyek kreatif seputar perangkat, dengan karakter mentah rekaman pita", "friends.role.dlimaGuitars": "Gitar dan bas custom", "friends.role.lisbonGuitarWorks": "Gitar buatan tangan di Lisbon", "friends.role.krisLuthier": "Reparasi dan restorasi instrumen", "friends.role.analog4lyfe": "Spesialis perangkat analog", + "friends.role.deadRoses": "Band punk rock asal Cork", + "friends.role.killahTrakz": "Produser", "friends.role.empressEffects": "Pedal efek butik", "friends.role.thomann": "Peritel besar perangkat musik", "friends.role.beltr": "Perangkat lunak karaoke lokal tanpa langganan", @@ -4030,6 +4070,8 @@ const id = { "settings.network.allowTitle": "Jadikan StemDeck tersedia di jaringan Anda", "settings.network.allowDesc": "Memungkinkan perangkat lain (seperti ponsel Anda) membuka StemDeck di alamat berikut.", "settings.network.lockNote": "Hanya-baca saat StemDeck dijalankan dalam mode server — akses jaringan kemudian ditentukan oleh konfigurasi server Anda.", + "settings.network.transposeNeedsHttps": "Mengubah nada tidak akan berfungsi di ponsel pada alamat ini. Ponsel hanya mengizinkannya lewat koneksi aman (https).", + "settings.network.transposeCertPrompt": "Jika ponsel Anda memperingatkan bahwa koneksi ini tidak privat, itu hanya berarti ponsel tidak mengenali sertifikatnya. Di jaringan rumah hal itu wajar. Ketuk Lanjutan, lalu Lanjutkan, sekali di setiap perangkat. Sampai itu dilakukan, nada tidak bisa diubah.", "settings.network.port.title": "Port", "settings.network.port.desc": "Port tempat StemDeck berjalan. Mulai ulang untuk menerapkan.", "settings.network.noConnection": "Tidak ada koneksi jaringan lokal yang terdeteksi.", @@ -4198,7 +4240,7 @@ const fr = { "structure.experimental": "Expérimental", "doc.title": "StemDeck — séparez n'importe quel morceau en pistes", - "topbar.urlPlaceholder": "Recherchez, ou collez un lien YouTube ou SoundCloud, ou déposez un fichier audio…", + "topbar.urlPlaceholder": "Recherchez ou déposez un fichier audio…", "topbar.removeFile": "Retirer le fichier", "topbar.uploadFile": "Importer un fichier audio", "extract.label": "Extraire", @@ -4430,19 +4472,23 @@ const fr = { "about.x": "X", "friends.title": "Nos recommandations", - "friends.tagline": "Des gens formidables qui font de belles choses. Allez les rencontrer ❤️", + "friends.tagline": "StemDeck est gratuit et n'accepte aucun argent, parrainage ni financement de la part des personnes citées ci-dessous. Je partage ces créateurs, artistes et communautés pour le seul plaisir de vous orienter vers des gens formidables qui font de belles choses. Allez les rencontrer ❤️", "friends.closeAria": "Fermer la fenêtre des recommandations", "friends.cat.artists": "Artistes et créateurs", "friends.cat.builders": "Lutherie et réparation", "friends.cat.gear": "Matériel de musique", "friends.cat.karaoke": "Technologies musicales et karaoké", "friends.cat.media": "Médias et communauté", + "friends.cat.writers": "Écrivains et conteurs", + "friends.role.alexandreBorges": "Écrivain, scénariste et commentateur culturel portugais", "friends.role.joaoGaspar": "Producteur, compositeur de musique de film, musicien de tournée et de studio", "friends.role.moreNotesLessTalk": "Projet créatif axé sur le matériel, à l'identité brute enregistrée sur bande", "friends.role.dlimaGuitars": "Guitares et basses sur mesure", "friends.role.lisbonGuitarWorks": "Guitares faites main à Lisbonne", "friends.role.krisLuthier": "Réparation et restauration d'instruments", "friends.role.analog4lyfe": "Spécialiste du matériel analogique", + "friends.role.deadRoses": "Groupe de punk rock basé à Cork", + "friends.role.killahTrakz": "Producteur", "friends.role.empressEffects": "Pédales d'effets boutique", "friends.role.thomann": "Grand détaillant de matériel de musique", "friends.role.beltr": "Logiciel de karaoké local, sans abonnement", @@ -4589,6 +4635,8 @@ const fr = { "settings.network.allowTitle": "Rendre StemDeck accessible sur votre réseau", "settings.network.allowDesc": "Permet à d'autres appareils (comme votre téléphone) d'ouvrir StemDeck à l'adresse ci-dessous.", "settings.network.lockNote": "En lecture seule lorsque StemDeck est lancé en mode serveur — l'accès réseau est alors défini par la configuration de votre serveur.", + "settings.network.transposeNeedsHttps": "Changer la tonalité ne fonctionnera pas sur votre téléphone à cette adresse. Les téléphones ne l'autorisent que sur une connexion sécurisée (https).", + "settings.network.transposeCertPrompt": "Si votre téléphone prévient que cette connexion n'est pas privée, c'est simplement qu'il ne reconnaît pas le certificat. Sur un réseau domestique, c'est normal. Touchez Paramètres avancés, puis Continuer, une fois sur chaque appareil. D'ici là, la tonalité ne pourra pas être changée.", "settings.network.port.title": "Port", "settings.network.port.desc": "Port utilisé par StemDeck. Redémarrez pour appliquer.", "settings.network.noConnection": "Aucune connexion au réseau local détectée.", @@ -4802,7 +4850,7 @@ const ptPT = { "playlist.confirmBody.one": "Coloca em fila {count} faixa, uma de cada vez, numa pasta com o mesmo nome.{skipped}", "playlist.confirmBody.other": "Coloca em fila {count} faixas, uma de cada vez, numa pasta com o mesmo nome.{skipped}", "favorites.empty": "Ainda sem favoritos, clique no ♥ de uma faixa para a guardar", - "topbar.urlPlaceholder": "Pesquise, ou cole um link do YouTube ou SoundCloud, ou largue um ficheiro de áudio…", + "topbar.urlPlaceholder": "Pesquise ou largue um ficheiro de áudio…", "topbar.removeFile": "Remover ficheiro", "topbar.uploadFile": "Carregar ficheiro de áudio", "library.importedFile": "Ficheiro importado", @@ -4841,6 +4889,8 @@ const ptPT = { "export.includeClickTitle": "Misturar o clique de referência no ficheiro exportado", "settings.network.allowDesc": "Permite que outros dispositivos (como o seu telemóvel) abram o StemDeck no endereço abaixo.", "settings.network.lockNote": "Só de leitura quando o StemDeck é iniciado em modo servidor, o acesso à rede é então definido pela configuração do seu servidor.", + "settings.network.transposeNeedsHttps": "Mudar o tom não vai funcionar no telemóvel neste endereço. Os telemóveis só permitem isso numa ligação segura (https).", + "settings.network.transposeCertPrompt": "Se o telemóvel avisar que a ligação não é privada, é só porque não reconhece o certificado. Numa rede doméstica isso é normal. Toque em Avançadas e depois em Continuar, uma vez em cada dispositivo. Até lá, mudar o tom não vai funcionar.", "settings.logs.setupAria": "Log de configuração (só de leitura)", "settings.logs.setup.desc": "A última hora de setup.log, configuração inicial e instalação do runtime de GPU. Apenas app desktop. Só de leitura.", "settings.stemsLocation.movedPersistFailed.one": "{count} item movido, mas o StemDeck não conseguiu guardar isto como o novo local (verifique se a pasta é gravável). Reiniciar agora reverteria para o local antigo. Tente definir novamente.", @@ -4849,6 +4899,8 @@ const ptPT = { "friends.cat.builders": "Luteria e reparações", "friends.cat.karaoke": "Tecnologia musical e karaoke", "friends.cat.media": "Media e comunidade", + "friends.tagline": "O StemDeck é gratuito e não aceita dinheiro, patrocínio ou financiamento de ninguém listado abaixo. Partilho estes criadores, artistas e comunidades apenas pela alegria de o apontar para pessoas maravilhosas a fazer um trabalho lindo. Conheça-as ❤️", + "friends.role.alexandreBorges": "Escritor, argumentista e comentador cultural português", "friends.role.joaoGaspar": "Produtor, compositor de bandas sonoras, músico de digressão e de estúdio", "friends.role.krisLuthier": "Reparação e restauro de instrumentos", "friends.role.thomann": "Grande retalhista de equipamento musical", @@ -4876,7 +4928,7 @@ const es = { "structure.experimental": "Experimental", "doc.title": "StemDeck — separa cualquier pista en stems", - "topbar.urlPlaceholder": "Busca, pega un enlace de YouTube o SoundCloud, o suelta un archivo de audio…", + "topbar.urlPlaceholder": "Busca o suelta un archivo de audio…", "topbar.removeFile": "Quitar archivo", "topbar.uploadFile": "Subir archivo de audio", "extract.label": "Extraer", @@ -5108,19 +5160,23 @@ const es = { "about.x": "X", "friends.title": "Recomendamos", - "friends.tagline": "Gente maravillosa haciendo un trabajo precioso. Ve a saludarlos ❤️", + "friends.tagline": "StemDeck es gratuito y no acepta dinero, patrocinio ni financiación de nadie de los que aparecen abajo. Comparto a estos creadores, artistas y comunidades solo por el gusto de señalarte gente maravillosa haciendo un trabajo precioso. Ve a saludarlos ❤️", "friends.closeAria": "Cerrar el diálogo de recomendaciones", "friends.cat.artists": "Artistas y creadores", "friends.cat.builders": "Luthería y reparación", "friends.cat.gear": "Equipo musical", "friends.cat.karaoke": "Tecnología musical y karaoke", "friends.cat.media": "Medios y comunidad", + "friends.cat.writers": "Escritores y narradores", + "friends.role.alexandreBorges": "Escritor, guionista y comentarista cultural portugués", "friends.role.joaoGaspar": "Productor, compositor de bandas sonoras, músico de gira y de sesión", "friends.role.moreNotesLessTalk": "Proyecto creativo centrado en el equipo, con una identidad cruda grabada en cinta", "friends.role.dlimaGuitars": "Guitarras y bajos a medida", "friends.role.lisbonGuitarWorks": "Guitarras hechas a mano en Lisboa", "friends.role.krisLuthier": "Reparación y restauración de instrumentos", "friends.role.analog4lyfe": "Especialista en equipo analógico", + "friends.role.deadRoses": "Banda de punk rock de Cork", + "friends.role.killahTrakz": "Productor", "friends.role.empressEffects": "Pedales de efectos boutique", "friends.role.thomann": "Gran tienda de equipo musical", "friends.role.beltr": "Software de karaoke local y sin suscripción", @@ -5268,6 +5324,8 @@ const es = { "settings.network.allowTitle": "Hacer que StemDeck esté disponible en tu red", "settings.network.allowDesc": "Permite que otros dispositivos (como tu teléfono) abran StemDeck en la dirección de abajo.", "settings.network.lockNote": "De solo lectura cuando StemDeck se inicia en modo servidor: en ese caso el acceso a la red lo define la configuración de tu servidor.", + "settings.network.transposeNeedsHttps": "Cambiar el tono no funcionará en tu teléfono en esta dirección. Los teléfonos solo lo permiten con una conexión segura (https).", + "settings.network.transposeCertPrompt": "Si tu teléfono avisa de que la conexión no es privada, solo significa que no reconoce el certificado. En una red doméstica es normal. Toca Configuración avanzada y luego Continuar, una vez en cada dispositivo. Hasta entonces no se podrá cambiar el tono.", "settings.network.port.title": "Puerto", "settings.network.port.desc": "Puerto en el que funciona StemDeck. Reinicia para aplicarlo.", "settings.network.noConnection": "No se detectó ninguna conexión de red local.", @@ -5464,7 +5522,7 @@ const ko = { "structure.experimental": "실험적", "doc.title": "StemDeck, 어떤 곡이든 스템으로 분리", - "topbar.urlPlaceholder": "검색하거나 YouTube 또는 SoundCloud 링크를 붙여넣거나 오디오 파일을 끌어다 놓으세요…", + "topbar.urlPlaceholder": "검색하거나 오디오 파일을 끌어다 놓으세요…", "topbar.removeFile": "파일 제거", "topbar.uploadFile": "오디오 파일 업로드", "extract.label": "추출", @@ -5695,19 +5753,23 @@ const ko = { "about.x": "X", "friends.title": "추천", - "friends.tagline": "좋은 일을 하는 멋진 사람들이에요. 한번 만나 보세요 ❤️", + "friends.tagline": "StemDeck은 무료이며, 아래에 있는 누구에게서도 돈이나 후원, 자금을 받지 않습니다. 좋은 일을 하는 멋진 사람들을 소개하는 즐거움만으로 이 창작자와 아티스트, 커뮤니티를 공유합니다. 한번 만나 보세요 ❤️", "friends.closeAria": "추천 창 닫기", "friends.cat.artists": "아티스트와 크리에이터", "friends.cat.builders": "악기 제작과 수리", "friends.cat.gear": "음악 장비", "friends.cat.karaoke": "음악과 노래방 기술", "friends.cat.media": "미디어와 커뮤니티", + "friends.cat.writers": "작가와 이야기꾼", + "friends.role.alexandreBorges": "포르투갈 작가이자 각본가, 문화 평론가", "friends.role.joaoGaspar": "프로듀서, 영화 음악 작곡가, 투어 및 세션 뮤지션", "friends.role.moreNotesLessTalk": "장비를 중심으로 테이프 녹음의 거친 질감을 살린 창작 프로젝트", "friends.role.dlimaGuitars": "주문 제작 기타와 베이스", "friends.role.lisbonGuitarWorks": "리스본에서 손으로 만드는 기타", "friends.role.krisLuthier": "악기 수리와 복원", "friends.role.analog4lyfe": "아날로그 장비 전문", + "friends.role.deadRoses": "코크를 기반으로 활동하는 펑크 록 밴드", + "friends.role.killahTrakz": "프로듀서", "friends.role.empressEffects": "부티크 이펙터 페달", "friends.role.thomann": "대형 음악 장비 판매점", "friends.role.beltr": "구독이 필요 없는 로컬 노래방 소프트웨어", @@ -5850,6 +5912,8 @@ const ko = { "settings.network.allowTitle": "네트워크에서 StemDeck에 접속 허용", "settings.network.allowDesc": "휴대폰 같은 다른 기기에서 아래 주소로 StemDeck을 열 수 있게 해요.", "settings.network.lockNote": "StemDeck을 서버 모드로 켜면 바꿀 수 없어요. 이때 네트워크 접속은 서버 설정에서 정해져요.", + "settings.network.transposeNeedsHttps": "이 주소에서는 휴대폰에서 키를 바꿀 수 없어요. 휴대폰은 보안 연결(https)에서만 키 변경을 허용해요.", + "settings.network.transposeCertPrompt": "휴대폰에서 이 연결이 비공개가 아니라는 경고가 뜨면, 인증서를 휴대폰이 모른다는 뜻일 뿐이에요. 집 네트워크에서는 흔한 일이에요. 기기마다 한 번씩 고급을 누른 다음 계속을 선택하세요. 그전까지는 키를 바꿀 수 없어요.", "settings.network.port.title": "포트", "settings.network.port.desc": "StemDeck이 쓰는 포트예요. 다시 시작하면 적용돼요.", "settings.network.noConnection": "로컬 네트워크 연결을 찾지 못했어요.", diff --git a/static/mobile/app.js b/static/mobile/app.js index 5ea7cddd..e67401f3 100644 --- a/static/mobile/app.js +++ b/static/mobile/app.js @@ -5,6 +5,7 @@ // Extract is still mock pending the SSE/upload wiring (next step). import { fetchJobs, jobToCard } from "../js/shared/jobs.js"; import { createChunkedAudioEngine } from "../js/chunkedAudioEngine.js"; +import { PITCH_MAX, PITCH_MIN, clampPitch } from "../js/pitchBus.js"; // Per-stem label + color, keyed by the backend stem name. Unknown names fall // back to a rotating palette so non-standard models still render sensibly. const STEM_META = { @@ -80,6 +81,7 @@ const state = { muted: {}, solo: {}, speed: 1.0, + pitch: 0, // global transpose in semitones, -6..+6 selected: { vocals: true, drums: true, bass: true, guitar: true, piano: true, other: true }, quality: "High", filter: "All", @@ -220,6 +222,7 @@ async function openTrack(card, { autoplay = false } = {}) { state.playing = false; state.progress = 0; state.speed = 1.0; + state.pitch = 0; render(); if (engine) { engine.destroy(); engine = null; engineTrackId = null; } @@ -449,6 +452,33 @@ function analysisBody() { return `
${statsHtml}${presenceHtml}${exportBtns}
`; } +/** + * The global transpose row. + * + * Phones get this UI rather than the desktop one, which is where transpose + * lives everywhere else, so without a control here the feature simply does not + * exist on a phone however well the engine supports it. There are no per-lane + * keys on this screen, so this is the whole of transpose on mobile: every lane + * moves together. + * + * Steppers rather than a slider. A semitone is one twelfth of the range and a + * slider that wide is not something a thumb can land on. + */ +function keyRow() { + const n = state.pitch; + const label = n === 0 ? "0" : (n > 0 ? `+${n}` : `${n}`); + const off = !engineReady || engine?.supportsPitchShift?.() !== true; + const why = off ? ' title="Transpose is not available on this connection"' : ""; + return `
+ Key +
+ + +
+ ${label} +
`; +} + function mixerScreen() { const c = state.current || { title: "No track selected", sub: "Pick one from your Library", initial: "♪", gradient: DEFAULT_GRADIENT, stemCount: 0 }; const sourceTag = c.sub || "—"; @@ -489,6 +519,7 @@ function mixerScreen() { ${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x + ${keyRow()} ${preparing ? '
Preparing audio…
' : ""}
@@ -813,9 +844,14 @@ function closeSwipe() { async function deleteTrack(id) { state.swipedTrackId = null; try { - await fetch(`/api/jobs/${id}`, { method: "DELETE" }); + // Trash, not DELETE. This is a swipe on a touch screen with no undo, and + // DELETE removes the stems from disk for good. The desktop has always + // moved tracks to a Trash folder instead; now that the Trash lives on the + // server, this screen can do the same thing rather than being the one + // place in StemDeck where a stray thumb destroys a separation. + await fetch(`/api/jobs/${encodeURIComponent(id)}/trash`, { method: "POST" }); } catch (e) { - console.warn("[mobile] delete failed:", e); + console.warn("[mobile] could not move track to trash:", e); } state.tracks = state.tracks.filter((t) => t.id !== id); if (!state.tracks.length) state.libState = "empty"; @@ -824,7 +860,7 @@ async function deleteTrack(id) { state.current = null; state.playing = false; } - toast("Track deleted"); + toast("Moved to Trash"); render(); } @@ -870,6 +906,18 @@ function wireFaders() { }); } + app.querySelectorAll("[data-key-step]").forEach((btn) => { + btn.addEventListener("click", () => { + if (!engine?.setStemPitch) return; + const next = clampPitch(state.pitch + Number(btn.dataset.keyStep)); + if (next === state.pitch) return; + state.pitch = next; + // Every lane together: this screen has no per-lane keys to preserve. + for (const lane of lanes()) engine.setStemPitch(lane.name, next); + render(); + }); + }); + const bars = app.querySelector("[data-seek]"); if (bars) { bars.addEventListener("pointerdown", (e) => { diff --git a/static/mobile/styles.css b/static/mobile/styles.css index 95445015..7cae237f 100644 --- a/static/mobile/styles.css +++ b/static/mobile/styles.css @@ -296,6 +296,21 @@ button { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; color: #e8c96a; width: 38px; text-align: right; white-space: nowrap; } +/* ── key / transpose ── */ +.key-row { margin-top: 8px; } +.key-steps { + display: flex; flex: 1; gap: 8px; +} +.key-step { + flex: 1; height: 30px; border-radius: 8px; cursor: pointer; + border: 1px solid rgba(148,163,184,0.22); + background: rgba(148,163,184,0.1); + color: #e6e7ee; font-size: 16px; font-weight: 600; line-height: 1; +} +.key-step:active:not(:disabled) { background: rgba(232,201,106,0.18); } +.key-step:disabled { opacity: 0.35; cursor: default; } +.speed-row-val.off { color: #6b6c78; } + .speed-slider { -webkit-appearance: none; appearance: none; flex: 1; height: 4px; border-radius: 999px; outline: none; cursor: pointer; diff --git a/tests/conftest.py b/tests/conftest.py index 9e489612..dcefa139 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,6 +54,18 @@ def _isolate_job_queue(): _registry._deleted.clear() +@pytest.fixture(autouse=True) +def _relax_secure_origin_gate(monkeypatch): + """Server mode refuses a plaintext request from a non-local client, and + TestClient's peer is "testclient" -- not loopback -- so the gate would 403 + the entire suite for a reason none of those tests are about. The gate has + its own file, tests/test_secure_origin_gate.py, which puts it back.""" + import app.main as _main + + monkeypatch.setattr(_main, "_secure_origin_required", lambda: False) + yield + + @pytest.fixture(autouse=True) def _isolate_network_settings(tmp_path, monkeypatch): """Isolate the runtime network gate for every test. Without this, a stray diff --git a/tests/e2e/mobile-transpose.spec.mjs b/tests/e2e/mobile-transpose.spec.mjs new file mode 100644 index 00000000..1eecd6f4 --- /dev/null +++ b/tests/e2e/mobile-transpose.spec.mjs @@ -0,0 +1,121 @@ +// Transpose on the phone UI. +// +// A phone does not get the studio. main.py routes it to static/mobile by user +// agent, so whatever the engine can do is irrelevant there unless this screen +// has a control for it, and it had none. That makes this the only place the +// mobile half of transpose is checked at all. +// +// Transpose needs AudioWorklet, which browsers only expose on a secure origin, +// so a phone reaching StemDeck over plain http:// cannot have it. The +// control has to be honest about that rather than absent or silently dead, +// which is the second half of what these tests cover. + +import { test, expect } from "@playwright/test"; +import { JOB_ID } from "./helpers.mjs"; + +const keyValue = (page) => page.locator(".key-row .speed-row-val"); +const keyUp = (page) => page.locator('.key-row [data-key-step="1"]'); +const keyDown = (page) => page.locator('.key-row [data-key-step="-1"]'); + +/** + * Land on the Library tab, which the app does not open on. + * + * `?ui=mobile` rather than a phone user agent: the routing is the backend's + * business and is not what this file is about, and pinning a UA string here + * would make these tests fail the next time that list is edited. + */ +async function gotoLibrary(page) { + await page.goto("/?ui=mobile", { waitUntil: "domcontentloaded" }); + await page.locator('[data-action="tab"][data-tab="library"]').first().click(); + await page.locator(`.track[data-id="${JOB_ID}"]`).first().waitFor({ timeout: 20000 }); +} + +/** Strip the secure-context APIs, the way a plain http origin does. */ +const asInsecureOrigin = (page) => + page.addInitScript(() => { + Object.defineProperty(window, "isSecureContext", { value: false, configurable: true }); + for (const Ctor of [window.AudioContext, window.webkitAudioContext]) { + if (Ctor) { + Object.defineProperty(Ctor.prototype, "audioWorklet", { + get: () => undefined, + configurable: true, + }); + } + } + window.AudioWorkletNode = undefined; + }); + +/** Open the fixture track on the phone UI and wait for its engine. */ +async function openOnPhone(page) { + await gotoLibrary(page); + await page.locator(`.track[data-id="${JOB_ID}"]`).first().click(); + // The key steppers stay disabled until the engine reports a pitch stage, so + // an enabled stepper is exactly "the audio is ready and transpose is real". + await expect(keyUp(page)).toBeEnabled({ timeout: 20000 }); +} + +test("the phone UI has a transpose control at all", async ({ page }) => { + await openOnPhone(page); + await expect(keyValue(page)).toHaveText("0"); +}); + +test("stepping it moves the key", async ({ page }) => { + await openOnPhone(page); + await keyUp(page).click(); + await expect(keyValue(page)).toHaveText("+1"); + await keyDown(page).click(); + await keyDown(page).click(); + await expect(keyValue(page)).toHaveText("-1"); +}); + +test("the control reaches the audio graph, not just the label", async ({ page }) => { + await page.addInitScript(() => { + window.__connects = 0; + const orig = AudioNode.prototype.connect; + // A lane's transpose is a connection, not a parameter, so the only honest + // evidence the control did anything is the graph being rewired. + AudioNode.prototype.connect = function connect(...args) { + window.__connects++; + return orig.apply(this, args); + }; + }); + await openOnPhone(page); + const before = await page.evaluate(() => window.__connects); + await keyUp(page).click(); + await expect(keyValue(page)).toHaveText("+1"); + expect(await page.evaluate(() => window.__connects)).toBeGreaterThan(before); +}); + +test("the range stops at the ends rather than wrapping", async ({ page }) => { + await openOnPhone(page); + // Six clicks is the whole range. The seventh is not a no-op that still + // fires: the button is gone, which is what "stops at the end" has to mean on + // a touch screen where a held thumb repeats. + for (let i = 0; i < 6; i++) await keyUp(page).click(); + await expect(keyValue(page)).toHaveText("+6"); + await expect(keyUp(page)).toBeDisabled(); + for (let i = 0; i < 12; i++) await keyDown(page).click(); + await expect(keyValue(page)).toHaveText("-6"); + await expect(keyDown(page)).toBeDisabled(); +}); + +test("a new track starts back at its own key", async ({ page }) => { + await openOnPhone(page); + await keyUp(page).click(); + await expect(keyValue(page)).toHaveText("+1"); + await page.locator('[data-action="tab"][data-tab="library"]').first().click(); + await page.locator(".track[data-id]").nth(1).click(); + await expect(keyValue(page)).toHaveText("0"); +}); + +test("over a plain http origin it is disabled and says why", async ({ page }) => { + // The common case on a phone today, and the one that must not look like a + // bug in the app: a dead stepper with no explanation is indistinguishable + // from a broken build. + await asInsecureOrigin(page); + await gotoLibrary(page); + await page.locator(`.track[data-id="${JOB_ID}"]`).first().click(); + await expect(page.locator(".key-row")).toHaveAttribute("title", /not available/i); + await expect(keyUp(page)).toBeDisabled(); + await expect(keyDown(page)).toBeDisabled(); +}); diff --git a/tests/e2e/network-transpose-warning.spec.mjs b/tests/e2e/network-transpose-warning.spec.mjs new file mode 100644 index 00000000..25caab47 --- /dev/null +++ b/tests/e2e/network-transpose-warning.spec.mjs @@ -0,0 +1,80 @@ +// The warning attached to "Make StemDeck available on your network". +// +// Handing someone a LAN address for their phone has a consequence the address +// itself does not show. Transpose is an AudioWorklet, and browsers hand that +// out only on a secure origin, so over plain http the key control on the phone +// is simply dead with nothing on screen to say why. Over https with StemDeck's +// own certificate the phone instead throws a full-page "your connection is not +// private" warning, which looks like the app is unsafe when it is not. +// +// Both are the moment to say something, and they need opposite text. These +// tests pin that the right one appears, because the failure mode is silent: +// the setting looks perfectly fine either way. + +import { test, expect } from "@playwright/test"; +import { seedLibrary } from "./helpers.mjs"; + +const warning = (page) => page.locator(".settings-net-warn"); + +async function openSettings(page) { + await seedLibrary(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#settingsBtn").click(); + // The overlay opens on General; this setting lives under Network. + await page.locator('.settings-tab[data-tab="network"]').click(); + await expect(page.locator(".net-access-input")).toBeVisible(); +} + +/** Answer /api/settings with a chosen set of LAN addresses. */ +async function withAddresses(page, addresses) { + await page.route("**/api/settings", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const resp = await route.fetch(); + const body = await resp.json(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...body, allow_network: true, lan_addresses: addresses }), + }); + }); +} + +test("a plain http address warns that the key control will not work", async ({ page }) => { + await withAddresses(page, ["http://192.168.1.50:8000"]); + await openSettings(page); + await expect(warning(page)).toBeVisible(); + await expect(warning(page)).toContainText(/secure connection/i); + await expect(warning(page)).toContainText(/https/i); +}); + +test("an https address explains the certificate prompt instead", async ({ page }) => { + await withAddresses(page, ["https://192.168.1.50:8000"]); + await openSettings(page); + await expect(warning(page)).toBeVisible(); + // The phone's own words, so the user can match what they are seeing. + await expect(warning(page)).toContainText(/not private/i); + await expect(warning(page)).toContainText(/continue/i); +}); + +test("the two messages are not both shown", async ({ page }) => { + await withAddresses(page, ["https://192.168.1.50:8000"]); + await openSettings(page); + await expect(warning(page)).not.toContainText(/secure connection \(https\)/i); +}); + +test("it is red, not another quiet grey note", async ({ page }) => { + // The setting already carries two grey explanatory lines. A third would be + // read as more of the same and skipped, which defeats the point of writing it. + await withAddresses(page, ["http://192.168.1.50:8000"]); + await openSettings(page); + const colour = await warning(page).evaluate((el) => getComputedStyle(el).color); + const [r, g, b] = colour.match(/\d+/g).map(Number); + expect(r).toBeGreaterThan(g + 40); + expect(r).toBeGreaterThan(b + 40); +}); + +test("nothing is said when there is no address to hand out", async ({ page }) => { + await withAddresses(page, []); + await openSettings(page); + await expect(warning(page)).toBeHidden(); +}); diff --git a/tests/e2e/settings-scroll.spec.mjs b/tests/e2e/settings-scroll.spec.mjs new file mode 100644 index 00000000..6a14755c --- /dev/null +++ b/tests/e2e/settings-scroll.spec.mjs @@ -0,0 +1,107 @@ +// Every Settings tab scrolls inside the dialog. +// +// The dialog is a fixed height so that switching tabs never resizes it, and +// each pane is flex:1 to fill it. For a long time only the General pane had +// overflow-y, which was a bet that no other tab would outgrow 540px. Network +// did, once it gained the secure-origin warning and one QR card per network +// interface, and a pane with no overflow does not clip: it runs on underneath, +// leaving the Done button sitting on top of the Port setting. +// +// So these tests assert the containment rather than any particular height. A +// pane is allowed to be as tall as it likes; what it may not do is escape the +// dialog or collide with the footer. + +import { test, expect } from "@playwright/test"; +import { seedLibrary } from "./helpers.mjs"; + +const TABS = ["general", "network", "export", "logs", "registry"]; + +async function openSettings(page) { + await seedLibrary(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#settingsBtn").click(); + await expect(page.locator(".library-editor")).toBeVisible(); +} + +/** Make the Network tab as tall as it gets: warning plus several QR cards. */ +async function withManyAddresses(page) { + await page.route("**/api/settings", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const resp = await route.fetch(); + const body = await resp.json(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...body, + allow_network: true, + lan_addresses: [ + "http://192.168.1.50:8000", + "http://10.0.0.7:8000", + "http://172.28.224.1:8000", + ], + }), + }); + }); +} + +async function showTab(page, name) { + await page.locator(`.settings-tab[data-tab="${name}"]`).click(); + return page.locator(`.settings-pane[data-pane="${name}"]`); +} + +// The invariant, stated the only way that actually distinguishes the two +// cases. Measuring boxes cannot: a pane that overflows keeps its own +// constrained box and merely paints its children outside it, while a pane that +// scrolls correctly has children below the fold whose rects also sit past the +// bottom. Both look identical to getBoundingClientRect. What separates them is +// whether the pane clips. +for (const name of TABS) { + test(`the ${name} tab clips and scrolls rather than painting outside`, async ({ page }) => { + await withManyAddresses(page); + await openSettings(page); + const pane = await showTab(page, name); + const overflow = await pane.evaluate((el) => getComputedStyle(el).overflowY); + expect(overflow, `${name} pane would overlap the footer once it grows`).not.toBe("visible"); + }); +} + +test("the dialog is one size on every tab", async ({ page }) => { + // The reason panes are flex:1 in the first place. If a tab could stretch it, + // the fix for the overlap would just be a resizing dialog instead. + await withManyAddresses(page); + await openSettings(page); + const heights = []; + for (const name of TABS) { + await showTab(page, name); + heights.push((await page.locator(".library-editor").boundingBox()).height); + } + expect(Math.max(...heights) - Math.min(...heights)).toBeLessThanOrEqual(2); +}); + +test("the network tab scrolls rather than overflowing", async ({ page }) => { + await withManyAddresses(page); + await openSettings(page); + const pane = await showTab(page, "network"); + const { scrollable, canScroll } = await pane.evaluate((el) => ({ + scrollable: getComputedStyle(el).overflowY, + canScroll: el.scrollHeight > el.clientHeight, + })); + expect(scrollable).toBe("auto"); + // The fixture is deliberately tall enough that this is a real scroll, not a + // property that happens to be set on content which never needed it. + expect(canScroll).toBe(true); + await pane.evaluate((el) => el.scrollTo(0, el.scrollHeight)); + expect(await pane.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); +}); + +test("the Done button stays reachable at the bottom of a long tab", async ({ page }) => { + await withManyAddresses(page); + await openSettings(page); + await showTab(page, "network"); + const done = page.locator(".settings-done"); + await expect(done).toBeVisible(); + // Not merely present: actually clickable where it is drawn. + await done.click(); + await expect(page.locator(".library-editor")).toHaveCount(0); +}); diff --git a/tests/test_compression.py b/tests/test_compression.py new file mode 100644 index 00000000..1d8fc854 --- /dev/null +++ b/tests/test_compression.py @@ -0,0 +1,197 @@ +"""Text is compressed on the way out; audio and event streams are not. + +Opening the phone UI costs about 456 KB of JavaScript, CSS and HTML, and gzip +takes that to roughly 126 KB. The saving is worth having, but the middleware +that provides it sits in front of every response StemDeck makes, so most of +what is pinned here is what it must leave alone. + +Two responses in particular. A five-second window of a stem comes back as +`206 Partial Content`; compressing one rewrites `Content-Length` while +`Content-Range` still describes the uncompressed bytes, which browsers do not +agree on how to read. And the job and queue progress streams are +`text/event-stream`, where a compressor's buffer is a stall. + +Both failures are quiet -- a stalled stream and a mangled range window both look +like the audio engine misbehaving -- so they are pinned here rather than left to +be noticed. +""" + +from __future__ import annotations + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse, Response, StreamingResponse +from starlette.routing import Route + +from app.core.compression import TextGZipMiddleware + +# Comfortably over MINIMUM_SIZE, and repetitive enough that a failure to +# compress is unmistakable rather than marginal. +BIG_TEXT = "export const table = {};\n" * 2000 +PCM = bytes(range(256)) * 400 # ~100 KB of audio-shaped bytes + + +def _app() -> Starlette: + async def script(_request): + return Response(BIG_TEXT, media_type="application/javascript") + + async def page(_request): + return Response(f"{BIG_TEXT}", media_type="text/html") + + async def small(_request): + return PlainTextResponse("tiny") + + async def audio_range(_request): + # What FileResponse produces for a Range request on a stem. + return Response( + PCM, + status_code=206, + media_type="audio/wav", + headers={ + "Content-Range": f"bytes 0-{len(PCM) - 1}/99999999", + "Accept-Ranges": "bytes", + }, + ) + + async def audio_full(_request): + return Response(PCM, media_type="audio/wav") + + async def events(_request): + async def stream(): + for i in range(3): + yield f"data: {i}\n\n".encode() + + return StreamingResponse(stream(), media_type="text/event-stream") + + app = Starlette( + routes=[ + Route("/script.js", script), + Route("/page", page), + Route("/small", small), + Route("/range", audio_range), + Route("/audio", audio_full), + Route("/events", events), + ] + ) + app.add_middleware(TextGZipMiddleware, minimum_size=1024, compresslevel=6) + return app + + +@pytest.fixture +def client() -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app()), + base_url="http://testserver", + # httpx decodes transparently, which would hide the header under test. + headers={"Accept-Encoding": "gzip"}, + ) + + +async def test_javascript_is_compressed(client) -> None: + async with client as c: + resp = await c.get("/script.js") + assert resp.headers["content-encoding"] == "gzip" + # The decoded body is still the file that was asked for. + assert resp.text == BIG_TEXT + assert int(resp.headers["content-length"]) < len(BIG_TEXT) / 3 + + +async def test_html_is_compressed(client) -> None: + async with client as c: + resp = await c.get("/page") + assert resp.headers["content-encoding"] == "gzip" + + +async def test_a_range_window_of_audio_is_left_alone(client) -> None: + """The one that would break playback rather than merely slow it. + + Content-Range describes the uncompressed representation. Rewriting the body + underneath it leaves the two disagreeing, and the chunked engine reads the + bytes raw. + """ + async with client as c: + resp = await c.get("/range") + assert resp.status_code == 206 + assert "content-encoding" not in resp.headers + assert resp.content == PCM + assert resp.headers["content-range"] == f"bytes 0-{len(PCM) - 1}/99999999" + assert int(resp.headers["content-length"]) == len(PCM) + + +async def test_whole_audio_files_are_left_alone(client) -> None: + """PCM does not compress, and the host may be running Demucs.""" + async with client as c: + resp = await c.get("/audio") + assert "content-encoding" not in resp.headers + assert resp.content == PCM + + +async def test_event_streams_are_left_alone(client) -> None: + """A compressor's buffer on an SSE stream is a stall with no error.""" + async with client as c: + resp = await c.get("/events") + assert "content-encoding" not in resp.headers + assert resp.text == "data: 0\n\ndata: 1\n\ndata: 2\n\n" + + +async def test_small_responses_are_not_worth_it(client) -> None: + async with client as c: + resp = await c.get("/small") + assert "content-encoding" not in resp.headers + assert resp.text == "tiny" + + +async def test_a_client_that_did_not_ask_gets_plain_bytes() -> None: + """Curl without a header, and anything older than gzip itself.""" + transport = httpx.ASGITransport(app=_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + resp = await c.get("/script.js", headers={"Accept-Encoding": "identity"}) + assert "content-encoding" not in resp.headers + assert resp.text == BIG_TEXT + + +async def test_the_real_app_compresses_its_javascript() -> None: + """The middleware is actually registered, not merely importable. + + i18n.js is the single largest asset the phone loads and the reason this + exists. StaticFiles streams it, so there is no Content-Length to read here + -- the size win is pinned above, on a response that has one. + """ + from app.core.config import STATIC_DIR + from app.main import app + + transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 5000)) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + resp = await c.get("/js/i18n.js", headers={"Accept-Encoding": "gzip"}) + assert resp.status_code == 200 + assert resp.headers["content-encoding"] == "gzip" + # Decoded, it is still byte-for-byte the file on disk. + assert resp.content == (STATIC_DIR / "js" / "i18n.js").read_bytes() + + +async def test_the_real_app_leaves_a_stem_range_alone(tmp_path, monkeypatch) -> None: + """End to end on the route the phone actually streams audio through.""" + from app.core.models import Job + from app.core.registry import _jobs, register + from app.main import app + + job_id = "abcdefabcdef" + stems = tmp_path / job_id / "stems" + stems.mkdir(parents=True) + (stems / "drums.wav").write_bytes(PCM) + monkeypatch.setattr("app.api.stems.JOBS_DIR", tmp_path) + _jobs.clear() + register(Job(id=job_id, status="done", title="Range")) + try: + transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 5000)) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + resp = await c.get( + f"/api/jobs/{job_id}/stems/drums.wav", + headers={"Accept-Encoding": "gzip", "Range": "bytes=0-999"}, + ) + finally: + _jobs.clear() + assert resp.status_code == 206 + assert "content-encoding" not in resp.headers + assert resp.content == PCM[:1000] diff --git a/tests/test_jobs_trash.py b/tests/test_jobs_trash.py new file mode 100644 index 00000000..5b87793b --- /dev/null +++ b/tests/test_jobs_trash.py @@ -0,0 +1,149 @@ +"""Trash lives on the server, so both UIs agree on what the library contains. + +It used to live only in the browser's catalog store. That store is per-device, +so a track the user deleted on their desktop was still returned by +GET /api/jobs, and the phone UI -- which builds its entire library from that +endpoint -- listed everything they thought they had thrown away. Two clients, +two answers to "what is in my library", and the phone's answer was the wrong +one in the direction that matters. + +Trashing is deliberately not deleting: stems stay on disk and the job stays in +the registry, so restore costs nothing and a mistaken tap never destroys audio. +Only emptying the Trash calls DELETE. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.models import Job +from app.core.registry import _jobs, register +from app.main import app + + +@pytest.fixture(autouse=True) +def _isolate_registry(): + """Each test gets a fresh in-memory registry, as everywhere else here. + + The registry is module-global and is restored from the real jobs directory + at import, so without this a developer's own library leaks into the + assertions. + """ + _jobs.clear() + yield + _jobs.clear() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def _done(job_id: str, title: str) -> Job: + job = Job(id=job_id, status="done", title=title) + register(job) + return job + + +def _ids(resp) -> list[str]: + return [j["job_id"] for j in resp.json()] + + +def test_a_trashed_job_leaves_the_library(client: TestClient) -> None: + _done("aaaaaaaaaaaa", "Keep") + _done("bbbbbbbbbbbb", "Bin") + assert client.post("/api/jobs/bbbbbbbbbbbb/trash").status_code == 200 + assert _ids(client.get("/api/jobs")) == ["aaaaaaaaaaaa"] + + +def test_the_phone_and_the_desktop_now_see_the_same_list(client: TestClient) -> None: + """The actual bug: the mobile UI reads this endpoint and nothing else.""" + _done("cccccccccccc", "Kept") + _done("dddddddddddd", "Deleted on the desktop") + client.post("/api/jobs/dddddddddddd/trash") + # Whatever any browser has in local storage, this is the one answer. + assert _ids(client.get("/api/jobs")) == ["cccccccccccc"] + + +def test_the_trash_itself_can_be_listed(client: TestClient) -> None: + _done("eeeeeeeeeeee", "Kept") + _done("ffffffffffff", "Binned") + client.post("/api/jobs/ffffffffffff/trash") + assert _ids(client.get("/api/jobs?trashed=only")) == ["ffffffffffff"] + assert set(_ids(client.get("/api/jobs?trashed=include"))) == { + "eeeeeeeeeeee", + "ffffffffffff", + } + + +def test_restore_puts_it_back(client: TestClient) -> None: + _done("111111111111", "Oops") + client.post("/api/jobs/111111111111/trash") + assert _ids(client.get("/api/jobs")) == [] + assert client.post("/api/jobs/111111111111/restore").status_code == 200 + assert _ids(client.get("/api/jobs")) == ["111111111111"] + + +def test_trashing_records_when(client: TestClient) -> None: + """A timestamp, not a flag, so the Trash can say how old something is.""" + _done("222222222222", "Timed") + body = client.post("/api/jobs/222222222222/trash").json() + assert isinstance(body["trashed_at"], float) + assert body["trashed_at"] > 0 + assert client.post("/api/jobs/222222222222/restore").json()["trashed_at"] is None + + +def test_trashing_does_not_delete_the_job(client: TestClient) -> None: + """The reversibility this whole design depends on.""" + _done("333333333333", "Still here") + client.post("/api/jobs/333333333333/trash") + # Gone from the library, still fully addressable. + assert client.get("/api/jobs/333333333333").status_code == 200 + assert client.get("/api/jobs/333333333333").json()["trashed_at"] is not None + + +def test_trashing_is_idempotent(client: TestClient) -> None: + """Two devices can both decide to bin the same track.""" + _done("444444444444", "Twice") + first = client.post("/api/jobs/444444444444/trash").json()["trashed_at"] + second = client.post("/api/jobs/444444444444/trash").json()["trashed_at"] + assert first is not None and second is not None + assert _ids(client.get("/api/jobs")) == [] + + +def test_an_unknown_job_is_a_404_not_a_500(client: TestClient) -> None: + assert client.post("/api/jobs/999999999999/trash").status_code == 404 + assert client.post("/api/jobs/999999999999/restore").status_code == 404 + + +def test_a_malformed_id_never_reaches_the_registry(client: TestClient) -> None: + """Rejected, and never a 500 or a silent success. + + Not pinned to 404: a traversal attempt normalises away before routing and + comes back 405, which is the router refusing to dispatch it at all. What + matters is that nothing is trashed and nothing blows up. + """ + _done("777777777777", "Untouched") + for bad in ("../../etc/passwd", "not a job id", "%2e%2e%2f"): + resp = client.post(f"/api/jobs/{bad}/trash") + assert 400 <= resp.status_code < 500, (bad, resp.status_code) + assert _ids(client.get("/api/jobs")) == ["777777777777"] + + +def test_unfinished_jobs_are_not_listed_either_way(client: TestClient) -> None: + """The library is finished tracks; the filter must not change that.""" + register(Job(id="555555555555", status="queued", title="Waiting")) + assert _ids(client.get("/api/jobs")) == [] + assert _ids(client.get("/api/jobs?trashed=include")) == [] + + +def test_an_unknown_filter_value_is_rejected(client: TestClient) -> None: + assert client.get("/api/jobs?trashed=maybe").status_code == 422 + + +def test_a_registry_written_before_this_existed_still_loads() -> None: + """Upgrades must not trip over a record with no trashed_at key.""" + record = Job(id="666666666666", status="done", title="Old").to_record() + del record["trashed_at"] + assert Job.from_record(record).trashed_at is None diff --git a/tests/test_packaging_linux.py b/tests/test_packaging_linux.py index cd4185cc..0544327c 100644 --- a/tests/test_packaging_linux.py +++ b/tests/test_packaging_linux.py @@ -9,6 +9,7 @@ from __future__ import annotations import shlex +import subprocess from pathlib import Path import pytest @@ -19,6 +20,27 @@ MAKE_PORTABLE = ROOT / "scripts" / "linux" / "make-portable.sh" +def _git_mode(relative: str) -> str | None: + """The file mode git has recorded for `relative`, or None if it cannot say. + + None covers both "git is not installed" and "this is not a working tree", + which are the same thing as far as the caller is concerned: fall back. + """ + try: + out = subprocess.run( # noqa: S603 + ["git", "ls-files", "-s", "--", relative], + cwd=ROOT, + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return None + if out.returncode != 0 or not out.stdout.strip(): + return None + return out.stdout.split(maxsplit=1)[0] + + def _entries() -> dict[str, str]: lines = TEMPLATE.read_text(encoding="utf-8").splitlines() assert lines[0] == "[Desktop Entry]", "the group header must come first" @@ -80,9 +102,29 @@ def test_make_portable_stages_the_installer(): def test_the_installer_exists_and_is_executable(): + """The bit that reaches the user is the one git records, not the one on disk. + + Asking the filesystem is wrong on Windows, where NTFS carries no execute + bit at all and `core.filemode` is false, so a perfectly good `100755` file + reads back as `0o666` and this failed for everyone developing there. It is + also the wrong question: the tarball is built from what git has, so a file + committed without the bit would ship unrunnable even if the author had + chmod-ed their own copy. + + So read git's index. The filesystem is kept only as a fallback for a + checkout that is not a git working tree at all, such as an unpacked sdist. + """ installer = ROOT / "packaging" / "linux" / "install.sh" assert installer.is_file() - assert installer.stat().st_mode & 0o111, "install.sh must be executable in the repo" + + mode = _git_mode("packaging/linux/install.sh") + if mode is None: + assert installer.stat().st_mode & 0o111, "install.sh must be executable" + return + assert mode == "100755", ( + f"install.sh is recorded as {mode}; it ships unrunnable. " + "Fix with: git update-index --chmod=+x packaging/linux/install.sh" + ) def test_readme_documents_the_installer(): diff --git a/tests/test_prune_ytdlp_extractors.py b/tests/test_prune_ytdlp_extractors.py new file mode 100644 index 00000000..7eadb4f0 --- /dev/null +++ b/tests/test_prune_ytdlp_extractors.py @@ -0,0 +1,165 @@ +"""The packaging step that keeps unreachable yt-dlp extractors off users' disks. + +This runs against the real installed yt-dlp, copied into a temp tree, because +the thing most likely to break it is a yt-dlp upgrade rather than a change here. +The cross-package imports it has to discover have already moved once between +releases, and when they move again the failure is `import yt_dlp` raising +ModuleNotFoundError inside a shipped bundle -- after packaging, on a user's +machine, at the first download. These tests are the earlier warning. +""" + +from __future__ import annotations + +import os +import pathlib +import shutil +import subprocess +import sys + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "prune_ytdlp_extractors.py" + +yt_dlp = pytest.importorskip("yt_dlp", reason="yt-dlp is what this prunes") +YTDLP_DIR = pathlib.Path(yt_dlp.__file__).parent + + +@pytest.fixture(scope="module") +def pruned(tmp_path_factory) -> pathlib.Path: + """A site-packages tree holding a pruned copy of the installed yt-dlp.""" + site = tmp_path_factory.mktemp("site-packages") + shutil.copytree(YTDLP_DIR, site / "yt_dlp") + result = subprocess.run( + [sys.executable, str(SCRIPT), str(site), "--no-verify"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + return site + + +def _run_in(site: pathlib.Path, code: str) -> subprocess.CompletedProcess: + """Run code with the pruned tree ahead of the installed one on sys.path. + + Inheriting the environment rather than replacing it: a bare env breaks + asyncio on Windows, which yt_dlp imports on the way in, and the failure + looks exactly like a broken prune. + """ + env = dict(os.environ) + env["PYTHONPATH"] = str(site) + return subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + env=env, + ) + + +def test_the_pruned_tree_still_imports(pruned: pathlib.Path) -> None: + # The whole risk of this step: something outside extractor/ imports an + # extractor by name, and deleting it breaks the package at import time + # rather than at use time. `downloader/soop.py` does exactly that today. + proc = _run_in( + pruned, + "import pathlib, yt_dlp\n" + "assert pathlib.Path(yt_dlp.__file__).parent.parent.name.startswith(" + "'site-packages'), yt_dlp.__file__\n" + "print('ok')\n", + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + +def test_youtube_and_soundcloud_still_match(pruned: pathlib.Path) -> None: + proc = _run_in( + pruned, + "from yt_dlp.extractor import get_info_extractor\n" + "assert get_info_extractor('Youtube').suitable(" + "'https://www.youtube.com/watch?v=dQw4w9WgXcQ')\n" + "assert get_info_extractor('Youtube').suitable('https://youtu.be/dQw4w9WgXcQ')\n" + "assert get_info_extractor('Soundcloud').suitable(" + "'https://soundcloud.com/artist/track')\n" + "print('ok')\n", + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + +def test_the_registry_holds_only_what_ships(pruned: pathlib.Path) -> None: + proc = _run_in( + pruned, + "from yt_dlp.extractor import gen_extractor_classes\n" + "print(','.join(sorted(c.IE_NAME for c in gen_extractor_classes())))\n", + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + names = proc.stdout.strip().split(",") + assert "youtube" in names + assert "soundcloud" in names + # GenericIE is the loader's hard-wired final fallback, not an oversight. + assert "generic" in names + assert len(names) < 20, f"far more survived the prune than expected: {names}" + + +def test_the_adult_extractors_are_gone(pruned: pathlib.Path) -> None: + """The reason this step exists. + + Not a size optimisation: these are files a user can find in the install + directory, and a scanner can index, in a tool for splitting music. + """ + root = pruned / "yt_dlp" / "extractor" + remaining = {p.stem for p in root.iterdir()} | {p.name for p in root.iterdir()} + for name in ("pornhub", "xhamster", "xnxx", "spankbang", "chaturbate", "redtube"): + assert name not in remaining, f"{name} survived the prune" + + # And the file that lists every one of those domains as a URL regex, which + # is the bulk of the exposure and is easy to forget because it is generated. + assert not (root / "lazy_extractors.py").exists() + + # Nothing anywhere in the shipped tree should still name them. + haystack = " ".join(p.name for p in root.rglob("*")) + assert "porn" not in haystack.lower() + + +def test_it_removes_essentially_everything(pruned: pathlib.Path) -> None: + before = len([p for p in (YTDLP_DIR / "extractor").iterdir() if p.name != "__pycache__"]) + after = len([p for p in (pruned / "yt_dlp" / "extractor").iterdir() if p.name != "__pycache__"]) + assert before > 500, "yt-dlp got much smaller; re-check what this step assumes" + assert after < 20, f"prune kept {after} entries, expected a handful" + + +def test_running_it_twice_changes_nothing(pruned: pathlib.Path, tmp_path) -> None: + """Packaging scripts get re-run, and a rebuild must not be a special case.""" + site = tmp_path / "again" + site.mkdir() + shutil.copytree(pruned / "yt_dlp", site / "yt_dlp") + result = subprocess.run( + [sys.executable, str(SCRIPT), str(site), "--no-verify"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + # __pycache__ is ignored: importing the tree in the tests above recreates + # it, and it is not part of what the prune decides. + def entries(root): + return sorted(p.name for p in root.iterdir() if p.name != "__pycache__") + + assert entries(pruned / "yt_dlp" / "extractor") == entries(site / "yt_dlp" / "extractor") + + +def test_it_refuses_rather_than_ships_a_broken_tree(tmp_path) -> None: + """A yt-dlp that no longer has these extractors must stop the build. + + Silently shipping a bundle whose only download path is missing would look + like a successful release and fail at the user's first URL. + """ + site = tmp_path / "broken" + (site / "yt_dlp" / "extractor").mkdir(parents=True) + (site / "yt_dlp" / "__init__.py").write_text("", encoding="utf-8") + (site / "yt_dlp" / "extractor" / "__init__.py").write_text("", encoding="utf-8") + result = subprocess.run( + [sys.executable, str(SCRIPT), str(site), "--no-verify"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "required extractors missing" in (result.stdout + result.stderr) diff --git a/tests/test_secure_origin_gate.py b/tests/test_secure_origin_gate.py new file mode 100644 index 00000000..d18bc3cd --- /dev/null +++ b/tests/test_secure_origin_gate.py @@ -0,0 +1,132 @@ +"""Server mode refuses to serve a browser a plaintext non-local origin. + +Transpose is an AudioWorklet, which browsers grant only to a secure context. +On http:// the API is simply absent, so the feature disappears with no +error anywhere -- and there is no fallback worth shipping: driving the same DSP +from a ScriptProcessorNode measured ~5% of the audio missing, because that node +type is lossy on its own at every buffer size. So server mode asks for TLS +instead of serving an app that is quietly half-broken. + +The whole difficulty is in *not* over-applying that. Most self-hosted installs +sit behind a reverse proxy that terminates TLS and forwards plain HTTP: the +browser already has its secure context and only the last hop is plaintext. +Judging by our own socket would reject exactly those deployments, so the tests +below pin the distinction rather than the mechanism. +""" + +from __future__ import annotations + +import httpx +import pytest + +import app.main as main + +# Captured at import, before conftest's autouse fixture relaxes it for the rest +# of the suite. Restoring the real function rather than restating its rule is +# what keeps these tests about the shipped behaviour. +_REQUIRED = main._secure_origin_required + +LAN = ("192.168.1.50", 51234) +LOOPBACK = ("127.0.0.1", 51234) + + +def _client(peer: tuple[str, int], base: str = "http://testserver") -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=main.app, client=peer) + return httpx.AsyncClient(transport=transport, base_url=base) + + +@pytest.fixture(autouse=True) +def _server_mode(monkeypatch): + """Run as a server deployment, not as the desktop app's backend.""" + monkeypatch.setattr(main, "_secure_origin_required", _REQUIRED) + monkeypatch.delenv("STEMDECK_DESKTOP", raising=False) + # The network gate is a separate switch; keep it out of the way so these + # tests fail for their own reason and not that one. + monkeypatch.setattr("app.main.get_allow_network", lambda: True) + + +async def test_a_lan_client_on_plain_http_is_refused() -> None: + async with _client(LAN) as c: + resp = await c.get("/api/health") + assert resp.status_code == 403 + assert "https://" in resp.text + + +async def test_the_refusal_explains_itself_rather_than_erroring() -> None: + """Someone hitting this did nothing wrong, and a bare 403 reads as a bug.""" + async with _client(LAN) as c: + resp = await c.get("/api/health") + body = resp.text.lower() + assert "secure context" in body + # It has to name a way out, or it is just a wall. + assert "reverse proxy" in body + assert "tailscale" in body + assert "stemdeck_ssl_cert" in body + + +async def test_the_host_machine_is_always_served() -> None: + """Turning this on must never lock the host out of its own server.""" + async with _client(LOOPBACK) as c: + resp = await c.get("/api/health") + assert resp.status_code == 200 + + +async def test_a_lan_client_over_https_is_served() -> None: + async with _client(LAN, base="https://testserver") as c: + resp = await c.get("/api/health") + assert resp.status_code == 200 + + +async def test_a_proxy_that_terminated_tls_is_served() -> None: + """The common self-hosted shape: SWAG/NPM/Traefik in front, http upstream. + + The browser has a secure context; only this hop is plaintext. Rejecting it + would break the installs that already did the right thing. + """ + async with _client(LAN) as c: + resp = await c.get("/api/health", headers={"X-Forwarded-Proto": "https"}) + assert resp.status_code == 200 + + +async def test_a_chain_of_proxies_is_read_from_the_client_end() -> None: + """Each hop appends, so the browser-facing scheme is the first entry.""" + async with _client(LAN) as c: + resp = await c.get("/api/health", headers={"X-Forwarded-Proto": "https, http"}) + assert resp.status_code == 200 + + +async def test_the_rfc7239_header_works_too() -> None: + """Caddy and others prefer `Forwarded` to the X- header.""" + async with _client(LAN) as c: + resp = await c.get("/api/health", headers={"Forwarded": "for=203.0.113.9;proto=https"}) + assert resp.status_code == 200 + + +async def test_a_proxy_forwarding_plain_http_is_still_refused() -> None: + """A proxy is not a free pass: if the browser used http, this still applies.""" + async with _client(LAN) as c: + resp = await c.get("/api/health", headers={"X-Forwarded-Proto": "http"}) + assert resp.status_code == 403 + + +async def test_the_desktop_app_is_not_subject_to_this(monkeypatch) -> None: + """The desktop has its own network toggle and its own explanation in the UI. + + Its LAN sharing is a different feature from server mode, and turning it into + a hard failure would take away playback from a phone to fix transpose on it. + """ + monkeypatch.setenv("STEMDECK_DESKTOP", "1") + async with _client(LAN) as c: + resp = await c.get("/api/health") + assert resp.status_code == 200 + + +async def test_static_pages_are_gated_too_not_just_the_api() -> None: + """The point is the browser tab, so the page itself has to be refused. + + Serving index.html and failing only its fetches is the confusing outcome + this exists to prevent. + """ + async with _client(LAN) as c: + resp = await c.get("/") + assert resp.status_code == 403 diff --git a/tests/test_tls_listener.py b/tests/test_tls_listener.py new file mode 100644 index 00000000..f31e138e --- /dev/null +++ b/tests/test_tls_listener.py @@ -0,0 +1,183 @@ +"""The desktop app's second listener: https on the LAN, http on loopback. + +One listener cannot serve both audiences. The Tauri webview has to be talked to +over plain http on 127.0.0.1, because a self-signed certificate raises an +interstitial that window has no chrome to click through -- and it loses nothing, +since loopback is already a secure context by browser rule. A phone on the LAN +has to be talked to over https, because `http://192.168.x.x` is not a secure +context and browsers withhold the AudioWorklet that transpose is built on. + +So both run against the same app object in the same process. The tests here pin +the two things that decide whether that is safe and whether it is any use: that +only one of them runs the lifespan, and that Settings hands out the address of +the listener that is actually up. +""" + +from __future__ import annotations + +import socket +import ssl +import subprocess +import sys +from pathlib import Path + +import httpx +import pytest + +from app.core import tls_listener + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture(scope="module") +def certificate(tmp_path_factory) -> tuple[Path, Path]: + """A throwaway certificate, made the way the desktop shell makes the real one. + + Emitted by the Rust crate rather than restated here in Python: that is the + code that ships, and a hand-rolled substitute would let its output drift + (wrong SANs, a lifetime Safari rejects) without a single test noticing. + + Uses an already-built binary and skips when there is none, rather than + building one. `cargo build` on this crate is minutes of Tauri, which does + not belong in a Python test run, and CI does not build the desktop app at + all -- the Rust half is covered by `cargo test certs::`. + """ + root = Path(__file__).resolve().parents[1] / "desktop" / "src-tauri" / "target" + exe = "stemdeck.exe" if sys.platform == "win32" else "stemdeck" + # Newest wins. A checkout can hold both profiles, and the stale one predates + # this flag -- which it answers by opening the app window and never exiting. + built = max( + (p for p in (root / "release" / exe, root / "debug" / exe) if p.is_file()), + key=lambda p: p.stat().st_mtime, + default=None, + ) + if built is None: + pytest.skip("no desktop binary built; run `cargo build` in desktop/src-tauri") + out = tmp_path_factory.mktemp("certs") + try: + probe = subprocess.run( # noqa: S603 + [str(built), "--emit-lan-cert", str(out)], + capture_output=True, + timeout=30, + ) + except subprocess.TimeoutExpired: + pytest.skip(f"{built.name} predates --emit-lan-cert; rebuild the desktop crate") + cert, key = out / "certs" / "lan.crt", out / "certs" / "lan.key" + if not (cert.is_file() and key.is_file()): + pytest.skip(f"could not emit a certificate: {probe.stderr[-400:]!r}") + return cert, key + + +async def test_it_stays_off_unless_it_is_asked_for() -> None: + """Server mode has one listener whose scheme is not ours to choose.""" + assert tls_listener.active_port() is None + + +async def test_a_missing_certificate_does_not_take_the_app_down(tmp_path) -> None: + """Startup runs inside the lifespan. A bad certificate must cost LAN + transpose, never the ability to open StemDeck at all.""" + import app.main as main + + started = await tls_listener.start( + main.app, + port=_free_port(), + certfile=tmp_path / "absent.crt", + keyfile=tmp_path / "absent.key", + ) + assert started is False + assert tls_listener.active_port() is None + + +async def test_stopping_one_that_never_started_is_harmless() -> None: + await tls_listener.stop() + assert tls_listener.active_port() is None + + +@pytest.mark.skipif(sys.platform not in ("win32", "linux", "darwin"), reason="needs sockets") +async def test_it_serves_the_same_app_over_tls(certificate) -> None: + """The point of sharing the app object: one registry, one queue, one worker. + + A separate process per scheme would give a phone its own library and its own + idea of what is separating. + """ + import app.main as main + + cert, key = certificate + port = _free_port() + assert await tls_listener.start(main.app, port=port, certfile=cert, keyfile=key) + assert tls_listener.active_port() == port + try: + # Our own certificate signed it, so verification is the thing under + # test only in the sense that the phone will be asked to skip it too. + ctx = ssl.create_default_context(cafile=str(cert)) + ctx.check_hostname = False + async with httpx.AsyncClient(verify=ctx) as c: + resp = await c.get(f"https://127.0.0.1:{port}/api/health", timeout=10) + assert resp.status_code == 200 + finally: + await tls_listener.stop() + assert tls_listener.active_port() is None + + +@pytest.mark.skipif(sys.platform not in ("win32", "linux", "darwin"), reason="needs sockets") +async def test_the_companion_does_not_run_the_lifespan(certificate) -> None: + """Running it twice would start a second queue worker against the same + registry, and the first server to stop would reap the demucs worker out + from under the other. `lifespan="off"` is the whole safeguard.""" + import app.main as main + + cert, key = certificate + port = _free_port() + assert await tls_listener.start(main.app, port=port, certfile=cert, keyfile=key) + try: + assert tls_listener._server.config.lifespan == "off" + finally: + await tls_listener.stop() + + +@pytest.mark.skipif(sys.platform not in ("win32", "linux", "darwin"), reason="needs sockets") +async def test_a_taken_port_is_reported_rather_than_raised(certificate) -> None: + import app.main as main + + cert, key = certificate + with socket.socket() as held: + held.bind(("0.0.0.0", 0)) # noqa: S104 + held.listen(1) + port = held.getsockname()[1] + assert await tls_listener.start(main.app, port=port, certfile=cert, keyfile=key) is False + assert tls_listener.active_port() is None + + +async def test_settings_advertises_the_listener_that_is_actually_up(monkeypatch) -> None: + """A QR code is only worth anything if it points at a live socket. + + Reading the configured port instead of the live one would turn a failed + bind into an address that looks right and cannot connect, which is the one + outcome worse than saying nothing. + """ + import app.main as main + + monkeypatch.setattr(main, "_local_ips", lambda: frozenset({"192.168.1.50"})) + monkeypatch.setattr(tls_listener, "_active_port", 8443) + transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 5000)) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + body = (await c.get("/api/settings")).json() + assert body["lan_addresses"] == ["https://192.168.1.50:8443"] + + +async def test_without_it_the_address_is_the_scheme_this_server_speaks(monkeypatch) -> None: + """Server mode is unchanged: one listener, and http unless it terminates TLS.""" + import app.main as main + + monkeypatch.setattr(main, "_local_ips", lambda: frozenset({"192.168.1.50"})) + monkeypatch.setattr(tls_listener, "_active_port", None) + monkeypatch.setattr(main, "SSL_CERTFILE", None) + monkeypatch.setattr(main, "SSL_KEYFILE", None) + transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 5000)) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + body = (await c.get("/api/settings")).json() + assert body["lan_addresses"] == ["http://192.168.1.50:8000"]