Skip to content

fix(desktop): keep a portable run's temp files, tool caches and settings copy in its folder - #904

Merged
thcp merged 1 commit into
big-releasefrom
fix/b25b-portable-stays-in-folder
Nov 3, 2026
Merged

thcp merged 1 commit into
big-releasefrom
fix/b25b-portable-stays-in-folder

Conversation

@thcp

@thcp thcp commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

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.

…ngs copy in its folder

The portable Windows build still wrote outside its own folder: the backend
kept a settings.json copy in %LOCALAPPDATA%\StemDeck, matplotlib its font
cache in %LOCALAPPDATA%\matplotlib, WebView2 and Python their temp files
(an upload over 1 MB among them) in the Windows temp folder, and the NVIDIA
setup pip's cache of the CUDA torch wheels in %LOCALAPPDATA%\pip.

One place sets this now: first thing in main, a portable package points
TEMP, TMP, TMPDIR, PIP_CACHE_DIR and MPLCONFIGDIR into data\, so the shell,
WebView2 and every child inherit them. The backend gets no settings mirror
in a portable package. Explorer, started for links and Reveal, gets the
user's own values back. Installed builds are unchanged.
@thcp
thcp merged commit 9c35a64 into big-release Nov 3, 2026
13 checks passed
thcp added a commit that referenced this pull request Nov 3, 2026
…gs or library (#909)

A portable StemDeck folder now starts from its own data folder only. It
no longer reads the settings or the library of an installed StemDeck on
the same Windows account.

## What changes for the user

- A new portable folder starts with default settings and keeps its
library in `data\jobs`. To use a library you already have, choose its
folder in Settings, StemData location (an empty current library can
point at an existing one; `move_library` brings nothing and adopts it).
- An in-place upgrade (unzip over the old folder, or the in-app update)
is unchanged: `data\settings.json` stays and names the same library.
- Installed builds (Windows installer, macOS, Linux) are unchanged: the
library default is still `~/Documents/StemDeck/jobs`.

## For the release notes

- A portable user whose library sat in `Documents\StemDeck\jobs` only
because an older version adopted it (no saved StemData location) opens
an empty library after updating. Nothing is lost or touched: choose that
folder once in Settings, StemData location, before importing a new song
(with a song in `data\jobs`, the move refuses a folder that holds
another library).
- A portable folder an older version already seeded from an installed
app keeps that `data\settings.json`, which can still name the installed
app's library. It cannot be detected without reading outside the folder,
which is the thing this change forbids, so the note should ask portable
users to check Settings, StemData location.

## Why

A fresh portable folder copied `%LOCALAPPDATA%\StemDeck\settings.json`
from an installed StemDeck in `ensure_workspace`. That file can name the
installed app's library, so the portable run opened it, and a factory
reset or an auto-delete sweep in the portable run would delete those
songs. PR #904 stopped the portable package from writing that copy; this
stops the read.

The same audit found a second path out of the folder: when
`~\Documents\StemDeck\jobs` had anything in it, a portable package
adopted it as its default library, and copied
`~\Documents\StemDeck\user-data.json` into its own. That folder is the
installed app's default library. A portable package now never resolves
Documents at all.

## What was removed, and why that is safe

- `ensure_workspace`: the portable seed from `%LOCALAPPDATA%\StemDeck`.
- `documents_dir_for_jobs`: the "adopt Documents if it has entries"
rule, and `directory_has_entries`, which only it used.
- The settings mirror (`shared_settings_dir`, `settings_mirror`,
`STEMDECK_SETTINGS_MIRROR`, and in `app/core/settings.py`
`_mirror_path`, `_mirror_settings` and the recovery from the mirror).
Its only purpose was to feed the portable seed. A portable package
already had no mirror (#904), and for an installed build the mirror path
is exactly its own `settings.json` (`shared_settings_dir` returned what
`local_data_dir` returns for a non-portable install), so the mirror
wrote the same file twice and its recovery read the file it had just
quarantined. One real effect went away with it: a desktop run with
`STEMDECK_DATA_DIR` pointed at a test folder wrote that run's settings
into the real `%LOCALAPPDATA%\StemDeck\settings.json`.

A corrupt `settings.json` is still moved aside for diagnosis
(`settings.json.corrupt-*`), as before.

## How it was verified

- Red then green, Rust:
`a_new_portable_folder_never_takes_an_installed_apps_settings` runs the
real `ensure_workspace` in a child test process with `STEMDECK_ROOT` set
to a throwaway portable folder and `LOCALAPPDATA` set to a throwaway
folder holding an installed app's `settings.json`. On `big-release` it
fails with "a portable folder copied the installed app's settings"; with
this change it passes.
- Red then green, Rust: `a_portable_package_never_looks_in_documents`
fails while a portable package still consults Documents, and passes with
the gate. `an_installed_app_keeps_its_library_in_documents` covers the
unchanged installed path.
- `cargo fmt --check`, `cargo clippy --tests -- -D warnings` and `cargo
test` (233 passed) on Windows. Linux and macOS compile on PR CI only.
- Full pytest suite on Windows (3860 passed, 12 skipped), ruff check and
ruff format.
- The child test only runs `ensure_workspace` when its root is a
throwaway portable folder and its data folder is that root's `data`, so
`cargo test -- --ignored` in a real environment does nothing.
- The acceptance check UPG-01 (not in CI, needs a real build) now
upgrades in place: the 0.20.0 `settings.json` goes in the package's own
data folder instead of a fake `LOCALAPPDATA`. Not run here, since it
needs a packaged build.

## Update path

No change to `uv.lock`. The shell binary changes (`main.rs`), as any
shell fix does.
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