Repository navigation
fix(desktop): keep the portable WebView2 profile in data, end setup children with the app - #897
Merged
Merged
Conversation
…hildren with the app A portable Windows package kept every file beside StemDeck.exe except the WebView2 profile, which Tauri put in %LOCALAPPDATA% (#399). With portable.txt next to the exe the main window now uses data/webview. Installer builds, macOS and Linux keep the default location. On Windows only the backend ran in a kill-on-close job, so closing the window during the CUDA torch install or the model warmup left pip or Python running, still writing into python/ or the model cache. Both now start through the same job helper, renamed spawn_in_job, in a shared run_setup_child that also keeps setup_child_pid for the Unix close path.
thcp
added a commit
that referenced
this pull request
Nov 3, 2026
…ngs copy in its folder (#904) The portable Windows build now keeps what it writes inside its own folder. Before this change a portable run still wrote a copy of settings.json to `%LOCALAPPDATA%\StemDeck`, matplotlib's font cache to `%LOCALAPPDATA%\matplotlib`, its temporary files (WebView2, Python, uploads over 1 MB) to the Windows temp folder, and on the NVIDIA build pip's cache of the CUDA torch wheels to `%LOCALAPPDATA%\pip`. Follows #897, which moved the WebView2 profile to `data\webview`. Part of #399. ## What changes - `desktop/src-tauri/src/main.rs` - `portable_env` and `apply_portable_env`: one place, first thing in `main`, before Tauri starts any thread. For a portable package only, the process sets `TEMP`, `TMP`, `TMPDIR` to `data\tmp`, `PIP_CACHE_DIR` to `data\cache\pip` and `MPLCONFIGDIR` to `data\cache\matplotlib`. The shell, WebView2 and every child (backend, model warmup, pip) inherit them, so no spawn site needs its own patch. - If `data\tmp` cannot be created, nothing is redirected and `data\logs\setup.log` says so. - `with_user_env`: `open_url` and Reveal start `explorer.exe` with the user's own `TEMP`, `TMP`, `TMPDIR`, `PIP_CACHE_DIR` and `MPLCONFIGDIR`, so a browser started cold from it does not keep the portable folder as its temp folder. - `settings_mirror`: the backend is no longer given `STEMDECK_SETTINGS_MIRROR` in a portable package. An installed app gets exactly the value it got before. - `app/core/settings.py`: docstring of `_mirror_settings` only, to match. - `packaging/windows/README-WINDOWS.txt`: says what stays in the folder, what is outside only because the user chose it, and what Windows writes that StemDeck cannot move. Nothing changes for installed builds, macOS or Linux: `portable.txt` only ships in the Windows zip, and without it `portable_env` is empty and `settings_mirror` is unchanged. ## Every location a portable run writes Measured by running the real CPU package, built with `scripts/windows/make-portable.ps1 -CpuOnly`, with `LOCALAPPDATA`, `APPDATA`, `TEMP`, `TMP`, `USERPROFILE` and `HOME` pointed at empty throwaway folders, through first-run setup, one upload separated end to end (a 12 s, 2.1 MB WAV), and quit. Two runs of the old build and three of the fixed build (the last one on the final commit); listings after each phase. | Location | Before | After | Evidence | Fix | |---|---|---|---|---| | `data\` settings, config, logs, ffmpeg, models, jobs, certs, downloads, webview | in folder | in folder | package `data\` listing | none needed | | `data\cache\huggingface` (model downloads) | in folder | in folder | `data\cache\` listing | none needed (shell sets `XDG_CACHE_HOME`, `TORCH_HOME`) | | `%LOCALAPPDATA%\StemDeck\settings.json` | **outside** | gone | old runs: `LocalAppData\StemDeck\settings.json (14 B)` written when the backend started; fixed runs: absent | `settings_mirror` returns None for portable | | `%LOCALAPPDATA%\matplotlib\fontlist-v3.11.0.json` | **outside** | `data\cache\matplotlib` | old runs: 83 634 B file during setup; fixed runs: absent, `data\cache\matplotlib` present | `MPLCONFIGDIR` | | `%TEMP%` | **outside** | `data\tmp` | old runs: `Temp\<guid>.tmp` while running; fixed runs: `Temp` empty in every phase, `data\tmp` holds `msedgeedge_BITS_*` (WebView2) and the `<guid>.tmp` | `TEMP`, `TMP`, `TMPDIR` | | Upload spool (Starlette rolls a multipart body over 1 MB to `tempfile`) | **outside** (transient) | `data\tmp` | not visible in a listing because it is deleted when the request ends; `tempfile.gettempdir()` under the same environment printed the sandbox `Temp` folder | same as above | | `%LOCALAPPDATA%\pip\Cache` (NVIDIA build, CUDA torch install) | **outside** | `data\cache\pip` | not exercised by the CPU build; the packaged `python -m pip cache dir` printed the profile path without the variable and `data\cache\pip` with it | `PIP_CACHE_DIR` | | `%USERPROFILE%\AppData\Local\Microsoft\{IdentityCache, Windows\INetCache, INetCookies, History, Feeds Cache, Internet Explorer}` | **outside** | not seen | written at quit in both old runs, absent in both fixed runs | not identified which component wrote them; the fixed runs simply do not show them | | `%LOCALAPPDATA%\Microsoft\Windows\Caches\cversions.3.db` | outside | outside | both builds, when the main page loads | **cannot be moved**: Windows shell cache, written by Windows components | | `%USERPROFILE%\AppData\Local\NVIDIA\DXCache`, `LocalLow\NVIDIA\DXCache` | outside | outside | both builds, when WebView2's GPU process starts | **cannot be moved**: the graphics driver's shader cache | | Prefetch, file dialog folder history (registry), Windows Firewall rule | outside | outside | not measured, Windows behaviour for any program | **cannot be moved** | | WebView2 runtime | system | system | installed by Windows | **cannot be moved** | | Library moved in Settings, export and save folders the user picks | outside by choice | outside by choice | by design | allowed by the rule | | `Documents\StemDeck\jobs`, only when it already holds a library from an older install | outside | outside | `documents_dir_for_jobs` | kept on purpose: switching it off would hide an existing library | | `%LOCALAPPDATA%\StemDeck\settings.json` read on first run of a fresh folder | read only | read only | `ensure_workspace` | reading is not writing; kept | The real profile was never used: during every run the real `%LOCALAPPDATA%\app.stemdeck.desktop\EBWebView` and `%LOCALAPPDATA%\StemDeck` had no new or modified entries. ## Tests - New Rust tests in `main.rs`: `a_portable_package_sends_temp_and_tool_caches_into_data`, `an_installed_app_keeps_the_system_temp_and_caches`, `only_an_installed_app_mirrors_its_settings_to_the_profile`, `a_program_started_for_the_user_gets_the_users_temp_back`. Against the old code they do not build (`error[E0425]: cannot find function portable_env` and `settings_mirror`), and the old code passed the mirror to every backend; with the change all three pass. - `cargo fmt --check`, `cargo clippy --tests -- -D warnings`, `cargo test` (216 passed, 0 failed) in `desktop/src-tauri`. - No Python change, so no new Python test: the backend already resolves its cache, logs, models and downloads inside `STEMDECK_DATA_DIR` (`tests/test_config.py::test_data_dir_moves_default_runtime_dirs`); the stray writes came from what the shell handed it and from third-party libraries, which the shell now points into the folder. ## How installed behaviour was kept - `portable_env` returns nothing without `portable.txt` in the app root, so `apply_portable_env` sets no variable (`an_installed_app_keeps_the_system_temp_and_caches`). - `settings_mirror` returns the same `shared_settings_dir()\settings.json` the old inline code passed (`only_an_installed_app_mirrors_its_settings_to_the_profile`). - Not run as an installed build: on Windows Tauri resolves an installed app's WebView2 profile through the known-folder API, so a sandboxed run would have used the real `%LOCALAPPDATA%\app.stemdeck.desktop`. ## Not verified - The NVIDIA build's CUDA install end to end (no NVIDIA package was run); `PIP_CACHE_DIR` is proven by `pip cache dir` only. - Clicking a link or Reveal in the running app, which now passes the user's own environment to `explorer.exe` (unit-tested through `restore_env`, not clicked by hand). - `data\tmp` is not swept at startup. After quit it was empty in two of the three fixed runs; the third left one WebView2 `msedgeedge_BITS_*` folder left by WebView2. ## Review Rust and security reviewers read the diff. Taken: log a failed `data\tmp` to setup.log instead of stderr; give Explorer the user's environment back; look `TEMP` up by name; update the Python docstring. Not taken: both suggested matplotlib uses `%USERPROFILE%\.matplotlib` on Windows, but matplotlib 3.11 in this package was measured writing `%LOCALAPPDATA%\matplotlib\fontlist-v3.11.0.json`, so the comment stays. The security reviewer also noted that `C:\StemDeck`, the folder the not-writable message suggests, inherits Modify for Authenticated Users from `C:\`; that predates this change and applies equally to `python\` and `StemDeck.exe`, so it is a separate item. Does not touch `uv.lock`. Changes the shell binary, so it reaches users through the app update.
thcp
added a commit
that referenced
this pull request
Nov 3, 2026
Refs #405 ## What changes for the person running setup - The model step says which model it is on: **"Getting AI model 2 of 5: beat detection... Elapsed: 12s."** It used to show three timed messages. - A **Skip: download each model when first needed** button appears once the first model starts. Skipping stops the download at once, and the wizard goes on to start StemDeck. - Before the wizard leaves the page, it says what did not arrive and waits for **Continue**: - Skipped: "AI model download skipped, so these were not downloaded: beat detection, song sections, ... StemDeck works without them: each one downloads the first time a feature needs it." If the separation model is among them, it adds: "so the first separation downloads the separation model before it starts." - Offline: "StemDeck could not reach the model download servers (no internet connection, or the connection stopped answering), so these AI models were not downloaded: ..." A model that failed for another reason is listed separately, with a pointer to `logs/setup.log`. - Disk too full: "Not enough free disk space for the AI models: about 1.8 GB needed, 0.4 GB free, so none was downloaded. ..." - Before this, a missing model only reached the browser console. The first sign was a feature that quietly did less. - Closing the window during the model download now closes it. On Windows it used to hang until the download ended or hit the 30-minute limit. - A dead connection now ends each model on its own: - The separation and beat models end in about 1.5 minutes. - The other models end at their own library's limit. - The next model still gets its turn. - A slow, healthy download is no longer killed at 30 minutes. ## Why, and how it works - **The hang on close.** - `warmup_models` was a blocking Tauri command. Tauri runs those on the main thread: see tauri-macros 2.6.0, `body_blocking`, and the Tauri docs. `reveal_saved` already avoids this for the same reason. - So for the whole download the window could not handle a close, and no other command could reach the shell, Skip included. - It is now `async` and does its work in `spawn_blocking`. - **The child left running on Windows.** - `stop_backend` only signalled the setup child on unix. - The state now holds the setup child's label and, on Windows, its job (from #897). Stopping it calls `TerminateJobObject`, which ends the warmup and anything it started. - `skip_model_warmup` only stops the setup child when that child is the model warmup. A stray call can never cut a pip install short. - Only one setup child runs at a time: `run_setup_child` refuses a second one. Without that, two children would share the one slot that stops them. - **The deadline.** - The warmup is now waited on with `child_output_streaming`: 6 minutes of silence, with a 2-hour cap. It used to be a fixed 30 minutes. - Output on stderr now also counts as activity in that reader. The downloads report progress there (tqdm), and the warmup's stdout stays quiet for as long as one model takes. - For pip, this can only make the stall budget more lenient. Its cap is unchanged. - Why 6 minutes: I ran the real warmup behind a proxy that never answers. Both streams went quiet for 300 s at most (audio-separator's own read timeout) and for 77 s at most anywhere else. - **`app/pipeline/warmup.py`.** - Every model runs inside `bounded_downloads` from #905: - torch.hub downloads get the read timeout and the retries. - Sockets that set no timeout of their own get the read timeout. That covers Whisper's. - New stdout lines: - `WARMUP_START <model> <n> <count>` before each model. The shell passes it on as a `model-warmup-progress` event. - `WARMUP_OFFLINE <model>` after a failure whose cause chain holds a DNS failure, a timeout, or a refused or dropped connection. - `WARMUP_NO_SPACE <needed> <free>` when the disk cannot hold the models. Nothing downloads after it. - The space check: - It adds up each model's size, measured on a finished install, and subtracts what the models folder already holds. So a second setup run on a nearly full disk is not refused. - A check that cannot run is logged and does not stop the downloads. - **A half-downloaded file after Skip.** Checked per library: - torch.hub (separation and beat models) writes to a `.partial` name and moves the file into place only once it is whole. Tested. - The Hugging Face hub (sections) writes to an `.incomplete` file and renames it. Checked by reading its source. - Whisper checks the file's SHA-256 on every load. - The vocal-split model loads through `load_or_heal`. It now also heals audio-separator's `download_checks.json`, which is written in place and was not covered. - `load_or_heal` now clears the failed load's frames before it deletes anything. audio-separator reads its indexes with `json.load(open(...))` and leaves the file open, and Windows will not delete an open file. That also makes the existing index healing (#502) work on Windows. ## How it was verified Red then green: I ran each new test against the old code first. - `cargo test` (Windows), 223 passed: - `closing_the_window_mid_setup_ends_the_setup_child` failed on the old code ("the setup child ran on for 20.2s") and now passes. - `output_on_stderr_alone_keeps_a_streamed_child_alive` failed on the old code ("timed out after 3 seconds with no output") and now passes. - Also new: - `skip_stops_the_model_warmup_and_nothing_else` - `a_second_setup_child_is_refused_while_one_runs` - the status and progress parsers - the stall budget bound - `tests/test_pipeline_warmup.py`: - 7 of the new or changed tests failed on the old warmup. - One of them is `test_a_stalled_model_host_ends_the_model_instead_of_the_step`. Old code: a host that never answers held the step for 20 s, until the test server gave up. New code: under 10 s, then the next model runs. - `test_a_download_stopped_midway_leaves_no_file_that_looks_complete` hard-kills a real warmup process in the middle of a download from a throttled local server. It checks that only a `.partial` file is left, and that the next try downloads the whole file. - `tests/test_model_cache.py::test_a_half_written_vocal_split_index_is_fetched_again` drives audio-separator's own index reader against a half-written `download_checks.json`. - Old code: `JSONDecodeError`, every time. - With the file listed but the frames not cleared: `PermissionError` on Windows. - Now: the file is fetched again and read. - The Python tests use local servers on throwaway ports and a fake free-space function. Proxies are switched off for them. There is no real network and no real model. - End to end with the real libraries and no real network: - I ran `python -m app.pipeline.warmup` behind a proxy on a closed local port. All four models that need a download reported `WARMUP_FAILED` then `WARMUP_OFFLINE`. The run took 86 s and left no file behind. - Behind a proxy that accepts and never answers, every model failed on its own and was reported offline, and the run still reached the last model. It took 758 s, with the stream silences above. - `tests/e2e/setup-wizard.spec.mjs` (new): - It serves `desktop/ui` with a stand-in Tauri bridge. - It covers the progress line, Skip and its notice, offline versus other failures, the no-space notice, and going straight on when every model arrived. - 3 of the first 4 tests failed on the old wizard. - Also run: `cargo fmt --check`, `cargo clippy --tests -- -D warnings` on Windows, ruff check and format, and the full pytest suite. - Not compiled here: the Linux and macOS `cfg(unix)` lines. The PR's Rust check jobs cover them. ## Guide and languages No Guide entry and no i18n change. The setup wizard (`desktop/ui`) runs before the backend exists, is English-only, and is not part of the in-app Guide. No control in `static/js` changed. ## Update path - Changes the desktop shell (Rust), so it needs a new binary, not a payload-only update. - Does not touch `uv.lock`. ## Not changed here - `ensure_torch_device` and `repair_torchvision` are blocking commands too, so the GPU step holds the main thread in the same way. - Their pip progress events therefore probably reach the screen only when they end. Not verified on a running app. - Fixing them is the same change as here: make them `async` and use `spawn_blocking`. It is left for its own PR, so this one stays about the model step. ## What needs a human Run one clean first-run setup on Windows with a throttled network, in a throwaway data folder. No automated test here drives a real WebView2, so these need a person: 1. Unzip the portable build into a new folder, with no `data` folder next to it. 2. Throttle the network, for example with NetLimiter at 100 KB/s for StemDeck's python.exe, or use a slow hotspot. 3. Start StemDeck. At the model step, check that the line names each model in turn ("Getting AI model 1 of 5: stem separation..."). 4. Close the window during the download. It should close within a few seconds, and no `python.exe` from that folder should remain in Task Manager. 5. Start again and press Skip while the line says model 1 of 5. The notice should name the models that were not downloaded. Press Continue: StemDeck should open, and the first Split should show "Downloading the separation model N%".
thcp
added a commit
that referenced
this pull request
Nov 3, 2026
…ead (#908) Refs #405 ## What changes for the person running setup - The window now closes during the GPU step. Before, closing it during a CUDA torch install did nothing until pip finished: the whole step ran on the window's own thread. - Closing it ends what that step started: pip, and the step's own checks (nvidia-smi, the torch import and CUDA checks). On Windows they run in a job that the close ends, so no `python.exe` is left behind. - The pip progress line on the GPU step and on the karaoke split repair can now update while the download runs. Before, the step held the thread the window paints on. That follows from how Tauri runs the command; I did not watch it on a running app. - The same holds for the other setup steps that take seconds or minutes: checking the runtime, verifying and installing the runtime pack, downloading FFmpeg, starting the backend, and applying an in-app update. The window keeps painting and can close during each of them. ## Why, and how it works - **The cause.** A Tauri command that is not `async` runs on the main thread (tauri-macros 2.6.0, `body_blocking`). While one runs, the window cannot paint or take a close, and no other command reaches the shell. #907 fixed this for the model step. `ensure_torch_device` and `repair_torchvision` had the same problem, and so did six more commands. - **The fix.** Each of those commands is now `async` and does its work in `spawn_blocking`, as `warmup_models` does since #907. The pattern is one helper, `off_main_thread`, which `warmup_models` now uses too. - **The GPU step's own checks.** pip already ran through `run_setup_child` (#897, #907). nvidia-smi, the site-packages probe, the import checks and the CUDA verify did not. A CUDA verify wedged in the driver ran on for up to 2 minutes after StemDeck closed (#502). They now run through `run_setup_child` too, via `setup_probe_output`. - **Nothing starts after the close.** The GPU step runs pip passes and checks one after another. Once the window closes, the next one could start in the moment before StemDeck exits, and on Linux and macOS nothing would stop it. `stop_backend` now sets a `closing` flag, and `run_setup_child` refuses to start a child after it. - **The GPU step stops touching torch after the close.** A check stopped by the close reads as a failed check. Without a guard, the step would go on to move the working torch aside, or delete a build, while StemDeck exits. `still_open` is now checked before each move, the commit and the saved result. A swap left half done is put back at the start of the next GPU step (#731). - **The close waits for an update's renames.** `apply_app_update` replaces `backend/` and the executable with a few renames, and each one retries for up to about 5 s while a virus scanner holds a handle. Stopped between two renames, the install would have no `backend/` or no executable, with nothing left to repair it. - While the renames and the relaunch run, a close is held off (`prevent_close`). The update then relaunches and exits by itself. - A close that came first cancels the swap before it starts. - Before this change the main thread gave the same protection, only with a frozen window. - **One setup step at a time.** The main thread used to run these commands one after another. Now a reload of the wizard during a CUDA install could start the GPU step again beside the first one. Its nvidia-smi check would then be refused the one setup slot and read as "no GPU", and it could start the backend while pip was still writing into `python/`. - A `SetupStep` claim is now held by the GPU step, the torchvision repair, the model step, the runtime install and the FFmpeg download. A second one fails at once and says which step is running. - `start_backend` keeps its `starting` guard, and `apply_app_update` keeps its claim. - **A close in the middle of the other steps is recovered from.** - Runtime pack extract: `runtime.tmp` is cleared on the next try, and `ensure_workspace` puts `runtime.old` back. - FFmpeg: the same, with `ffmpeg.old`. - Backend start: the backend runs in its job on Windows, and watches StemDeck's pid elsewhere. ## Every command in the shell | Command | Was | Verdict | |---|---|---| | `ensure_torch_device` | blocking | **Fixed.** nvidia-smi, Python checks, several GB through pip. | | `repair_torchvision` | blocking | **Fixed.** A Python check, then a pip download. | | `start_backend` | blocking | **Fixed.** Spawns the backend and waits up to 90 s for health. | | `probe_runtime` | blocking | **Fixed.** Runs the interpreter and the FFmpeg pair. | | `verify_runtime_pack` | blocking | **Fixed.** SHA-256 of the runtime archive. | | `extract_runtime_pack` | blocking | **Fixed.** Hash and extract of the runtime archive. | | `ensure_external_assets` | blocking | **Fixed.** Downloads FFmpeg. | | `apply_app_update` | blocking | **Fixed.** Hash and extract of the update, up to 15 s backend stop. | | `warmup_models` | async (#907) | Unchanged, now through the shared helper. | | `ensure_workspace` | blocking | Stays. Creates folders, renames on one volume, copies two small JSON files. | | `runtime_pack_status` | blocking | Stays. Reads small files. | | `skip_model_warmup` | blocking | Stays. A lock and one job terminate. | | `build_target` | blocking | Stays. One file check. | | `open_url` | blocking | Stays. Spawns the browser opener and never waits on it. | | `native_file_drop` | blocking | Stays. A constant. | | `current_exports_dir` | blocking | Stays. A store read and one folder create. | | `store_get`, `store_set`, `reset_user_data` | blocking | Stay. The small JSON store. | | `mark_store_migration_done` | blocking | Stays. Writes an empty file. | | `download_runtime_pack`, `check_app_update`, `download_app_update`, `restart_backend`, `next_drop_signal`, `read_dropped_file`, `save_audio_file`, `pick_export_destination`, `download_to_path`, `reveal_saved`, `start_audio_drag`, `pick_stems_folder`, `pick_exports_folder` | async | Already off the main thread. | A test now holds this list: `no_command_that_can_run_long_holds_the_main_thread` fails if a command is blocking and not on the "stays" list. ## How it was verified Red then green: I ran each new test against the old code first. - `no_command_that_can_run_long_holds_the_main_thread` - Old code: failed, "these commands run on the main thread: probe_runtime, verify_runtime_pack, extract_runtime_pack, apply_app_update, ensure_external_assets, start_backend, ensure_torch_device, repair_torchvision". - Now: passes. - It reads `main.rs` and checks which commands are `async`, because that keyword is what tauri-macros uses to choose the main thread. No test here drives a real WebView's main thread. - `closing_the_window_ends_a_gpu_step_probe` - A stand-in interpreter that runs for 20 s, checked with the torchvision import check, then `stop_backend` as the close handler calls it. - Old code: failed, "the probe ran on for 20.17s". - Now: passes, ended at once. - `no_setup_child_starts_once_the_window_is_closing` - Old code: failed, a setup child was started after the close. - Now: passes. It also checks that `still_open` reports the close. - `a_second_setup_step_is_refused_while_one_runs` and `the_close_waits_for_an_update_swap_and_cancels_one_not_begun` - New. On the old code they do not compile, because the claims they test do not exist there. Neither was a behaviour test on the old code. - `cargo test` (Windows): 234 passed, 2 ignored. - `cargo fmt --check` and `cargo clippy --tests -- -D warnings` on Windows: clean. - Not compiled here: the Linux and macOS `cfg` lines (`verify_mps_torch`, the macOS branch of `repair_torchvision`, the unix stand-in interpreter). The PR's Rust check jobs cover them. - No real CUDA torch was installed anywhere: the tests use stand-in processes in throwaway folders. ## Guide and languages No Guide entry and no i18n change. No control changed, and no text in `desktop/ui` or `static/js` changed. ## Update path - Changes the desktop shell (Rust), so it needs a new binary, not a payload-only update. - Does not touch `uv.lock`. ## Not changed here - The GPU step has no Skip. Closing the window is the way out. A Skip would have to stop pip and also end the loop over CUDA builds, then put CPU torch back. That is its own change, with its own wording. - `python_stdlib_ok` (in `probe_runtime`) waits on `import encodings` with no time limit. It is now off the main thread, so a hang there no longer freezes the window, but it still has no timeout. - `apply_app_update` checks the archive's hash, then opens it again by path to extract it. `download_app_update` could now run in between and rewrite that file. Only the release URLs it accepts can be written there, and the page hides the download button by then. Hashing and extracting from one open file would close this gap. - A wizard reloaded while the torchvision repair runs on the fast path is refused the repair step. It says so in the console and goes on to start the backend while the first run's pip may still be writing torchvision. That is much narrower than before, but not closed. - A close during `start_backend`'s health wait, on Linux or macOS, leaves the backend running until its parent watchdog sees StemDeck gone, about 1 s later. On Windows the backend's job ends it at once. ## What needs a human On Windows, NVIDIA build, in a throwaway portable folder. No automated test here drives a real WebView2. 1. Unzip the NVIDIA portable build into a new folder, with no `data` folder next to it. 2. Start StemDeck. At the GPU step, check that the line shows pip's progress ("Downloading torch-... N / M MB") while it runs. 3. During that download, close the window. It should close within a few seconds. 4. In Task Manager, check that no `python.exe` and no `pip` from that folder remain. 5. Start again. The GPU step should run again from the start and finish, or fall back to CPU with the usual message.
This was referenced Nov 3, 2026
thcp
added a commit
that referenced
this pull request
Nov 4, 2026
…e StemDeck writes nothing outside its folder (#1002) A portable StemDeck now writes nothing outside its own folder in a normal session, measured on a package built from this branch. The one write I found outside the folder was Settings > Logs > Export logs: it is fixed here, and the repository gets a script that measures this on any build. Refs #399 ## What changes for the user - **Export logs in the desktop app now asks where to save the zip**, with the same save dialog as every other export. Before, the window downloaded the zip by itself, and WebView2 put it in the Downloads folder without asking. In a portable StemDeck that was a file outside its folder, in a place the user never chose. - The browser keeps its own download, unchanged. ## How I looked for writes outside the folder I extracted the hand-check zip (built from big-release 55a0d97) into new empty folders and ran it, three ways: 1. **Real profile, first run.** A full listing, before and after, of every file changed that day and every folder under `%LOCALAPPDATA%`, `%APPDATA%`, `%USERPROFILE%`, Documents and `%ProgramData%`, the entries beside the package folder, and every `HKEY_CURRENT_USER` registry value that names StemDeck. The session: setup, Settings opened, one 6 second WAV imported and split, the window closed normally. 2. **Redirected profile, first run, with the AI model step run in full.** `LOCALAPPDATA`, `APPDATA`, `TEMP`, `TMP` and `USERPROFILE` pointed at an empty folder. Nothing else on the machine writes there, so every file in it comes from the package: Python, pip, Hugging Face, torch hub, matplotlib, yt-dlp, WebView2 and the shell. 3. **Settings > Export logs**, pressed through the DevTools protocol without anything else changing how the window downloads. ## What I found | Written outside the folder | Who writes it | Outcome | |---|---|---| | `Downloads\stemdeck-logs-<time>.zip` on Export logs | WebView2, because `exportLogs` let the window download | **Fixed here** | | `HKCU\...\Shell\MuiCache`: the exe's name and company | Windows, for every program started | Cannot be prevented by any program | | NVIDIA `DXCache`, Direct3D `D3DSCache`, the shell's `Microsoft\Windows\Caches`, WinINet and account caches (seen only in the redirected run) | The graphics driver, Direct3D and Windows components the WebView2 GPU process uses | Cannot be redirected per program; listed as "Windows" by the script | Nothing else. In particular, with the package's own redirects in place (#897, #904, #909): no `%LOCALAPPDATA%\StemDeck`, no `%LOCALAPPDATA%\app.stemdeck.desktop`, no pip, matplotlib, Hugging Face, torch hub, yt-dlp, numba or Python cache in the profile, no Documents folder, no Start menu or autostart entry, nothing beside the package folder. ## Where the `%LOCALAPPDATA%` writes on this PC come from The folder that holds the everyday StemDeck on this PC is a **0.20.0 portable** (released before #897, #904 and #909). Its own logs show a start at 19:46:57 and a backend start at 19:47:10 on 3 November. At those same seconds `%LOCALAPPDATA%\app.stemdeck.desktop\EBWebView` and `%LOCALAPPDATA%\StemDeck\settings.json` were written. More than ten launches of portable packages that I made afterwards, from 55a0d97 and from this branch, did not change either one. So the writes still seen in `%LOCALAPPDATA%` come from the 0.20.0 build, and stop once it runs a build with these fixes. The fixed builds leave the folders that 0.20.0 already created in place. Removing them is a manual step, because the app cannot tell them apart from an installed StemDeck's. ## Why not more - **Disabling the GPU in the window** would stop the shader caches, but every waveform and animation would then draw in software. That is a real cost for a write that Windows makes for every program that draws, so I left it out. If you want it anyway, it is one browser argument, and the risk is slower drawing. - **Pointing `USERPROFILE` and `LOCALAPPDATA` at the package folder** would move those caches too. It would also copy the Windows account cache (sign-in tokens) into a folder that is carried around on USB sticks, and it would change what Python treats as home. I did not do it. ## The leak check (new, Windows only, manual) `scripts/windows/check-portable-leaks.ps1` runs one extracted package through the session above, using `scripts/windows/drive-portable.mjs`, and exits 1 if anything is written outside the folder. It sorts each change into one of these groups: - **StemDeck:** a path that names it, a download the window made by itself, or anything in the redirected profile. These fail the check. - **Windows:** registry and caches no program can prevent. - **Review:** names a tool StemDeck uses. - **Other:** another program on the machine. It refuses a package whose library points outside it, and a DevTools port that is already taken. It never deletes anything: `robocopy /L` only lists, and `reg query` only reads. It needs a desktop session and a built package, so it is not in CI. ## How it was verified - **Red then green, browser tests:** `tests/e2e/export-logs.spec.mjs`. On 55a0d97 both desktop tests fail: no save dialog is asked for, and the window downloads the zip. With this change all 4 tests pass: dialog, cancel, failed save, browser download. - **Red then green, on real packages, with the new check:** - 55a0d97 package: exit 1, `StemDeck (1): [window download] ...playwright-artifacts-...\<zip>`. - Package built from this branch (`make-portable.ps1 -CpuOnly`), first run: exit 0, `StemDeck (0)`, and "Export logs: save dialog shown and closed". - The same build, first run, with the redirected profile and the full model step. The window's DevTools port did not open in this mode, so the check ran the session over HTTP: settings read, one import split to done. - In the redirected profile there were only the NVIDIA shader cache and the shell's `Microsoft\Windows\Caches`, so nothing from Python, pip, Hugging Face, torch hub or matplotlib. - The one line counted as a leak was the `stemdeck.log` of my own browser-test server in the temp folder, from another run. I narrowed the check's name rule so that it no longer counts that file. This run used the older rule. - Full browser suite in 3 shards: 847 passed, 7 skipped, 1 failed. - The failure was `live-wave.spec.mjs` (recording waveform), which this change does not touch. - Run alone, all 4 of its tests passed, so I count it as a flake under machine load. - Reviews: frontend and security reviewers. I applied their findings: more test assertions, plus the leak check's guards against a reused package, a taken port and a failed session. ## Update path No change to `uv.lock` or to the desktop shell. The change is in `backend/static`, which the in-app update replaces.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two Windows fixes in the desktop shell: a portable package now keeps its browser profile in its own
data/folder, and closing StemDeck during a GPU install or the model download no longer leaves pip or Python running.What changes for the user
%LOCALAPPDATA%\app.stemdeck.desktop\EBWebView, even though everything else stays besideStemDeck.exe. Withportable.txtnext to the exe it now lives indata\webview\. Installer builds, macOS and Linux keep the default location.localStorageresets once: panel layout and collapsed sections, per-song mutes, the chosen input and camera devices, the measured recording latency per device, and a dismissed update notice. Songs, settings anduser-data.jsonare not in that profile and are not affected. The old folder in%LOCALAPPDATA%is left as it is.python\or the model cache. Both now start in the same kind of job, which Windows closes when StemDeck exits in any way, and which ends them and anything they started.How
desktop/src-tauri/src/main.rs:portable_webview_dir()decides the profile folder from the existingportable.txtcheck; the main window builder sets it withdata_directory()only when it returns a folder.run_setup_child()replaces the spawn, record pid, wait, clear pid sequence thatspawn_pipandwarmup_modelseach repeated, and spawns through the job helper. The Unix close path (SIGTERM throughsetup_child_pid) is unchanged.desktop/src-tauri/src/backendjob.rs:spawn_backend_processis renamedspawn_in_jobnow that three callers use it, and its log lines say "child process" instead of "backend".packaging/windows/README-WINDOWS.txt: one line saying the browser storage is indata\webview\.CREATE_NO_WINDOW, like pip and the backend already did. Its output is piped, so nothing visible changes.Verified
what_a_setup_child_starts_does_not_outlive_it(Windows): a setup child starts its own child that writes a file two seconds later. With the old plain spawn the file appears (fails: "a process the setup child started outlived it"); through the job it never appears (passes).a_portable_package_keeps_its_webview_profile_in_data: fails while no profile folder is chosen (leftNone), passes with the change.an_installed_app_keeps_the_default_webview_profilepasses in both.cargo fmt --check,cargo clippy --tests -- -D warningsandcargo test(212 passed, 2 ignored) on Windows. The Linux and macOS checks run in PR CI only.Shell binary and the update path
This changes the desktop binary. It does not touch
uv.lock, so the in-app updater still offers it.Needs a check by hand on Windows
The tests cannot see a real WebView2 profile or a real close, so on a portable build:
%LOCALAPPDATA%\app.stemdeck.desktopaside, launchStemDeck.exe, and confirmdata\webview\EBWebViewappears and%LOCALAPPDATA%\app.stemdeck.desktopis not recreated.python.exeorpipremains.python.exeremains.