Skip to content

fix(desktop): keep the portable WebView2 profile in data, end setup children with the app - #897

Merged
thcp merged 1 commit into
big-releasefrom
fix/b25-windows-console-profile
Nov 3, 2026
Merged

thcp merged 1 commit into
big-releasefrom
fix/b25-windows-console-profile

Conversation

@thcp

@thcp thcp commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

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

  • Portable package ([Bug]: Several issues with my portable package #399). The WebView2 profile (the window's local storage and cache) used to go to %LOCALAPPDATA%\app.stemdeck.desktop\EBWebView, even though everything else stays beside StemDeck.exe. With portable.txt next to the exe it now lives in data\webview\. Installer builds, macOS and Linux keep the default location.
    • One-time effect: an existing portable user starts with an empty WebView2 profile, so what the page keeps in localStorage resets 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 and user-data.json are not in that profile and are not affected. The old folder in %LOCALAPPDATA% is left as it is.
  • Closing during setup. On Windows only the backend ran in a kill-on-close job. pip (the CUDA torch install) and the model warmup did not, so closing the window mid-install left them running, still writing into 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.
  • The console window at launch is unchanged: it is intended.

How

  • desktop/src-tauri/src/main.rs: portable_webview_dir() decides the profile folder from the existing portable.txt check; the main window builder sets it with data_directory() only when it returns a folder.
  • run_setup_child() replaces the spawn, record pid, wait, clear pid sequence that spawn_pip and warmup_models each repeated, and spawns through the job helper. The Unix close path (SIGTERM through setup_child_pid) is unchanged.
  • desktop/src-tauri/src/backendjob.rs: spawn_backend_process is renamed spawn_in_job now 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 in data\webview\.
  • Side effect: the warmup child now starts with CREATE_NO_WINDOW, like pip and the backend already did. Its output is piped, so nothing visible changes.

Verified

  • Red then green, 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).
  • Red then green, a_portable_package_keeps_its_webview_profile_in_data: fails while no profile folder is chosen (left None), passes with the change. an_installed_app_keeps_the_default_webview_profile passes in both.
  • cargo fmt --check, cargo clippy --tests -- -D warnings and cargo 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:

  1. Rename %LOCALAPPDATA%\app.stemdeck.desktop aside, launch StemDeck.exe, and confirm data\webview\EBWebView appears and %LOCALAPPDATA%\app.stemdeck.desktop is not recreated.
  2. In the app, change something kept in browser storage, quit and relaunch, and confirm it stayed.
  3. On an NVIDIA machine, close the window while the CUDA torch install runs, and confirm in Task Manager that no python.exe or pip remains.
  4. Close the window during the model download step of setup, and confirm no python.exe remains.

…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
thcp merged commit 2118511 into big-release Nov 3, 2026
13 checks passed
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant