Skip to content

fix(desktop): a portable folder never reads an installed app's settings or library - #909

Merged
thcp merged 1 commit into
big-releasefrom
fix/b25c-portable-own-settings
Nov 3, 2026
Merged

thcp merged 1 commit into
big-releasefrom
fix/b25c-portable-own-settings

Conversation

@thcp

@thcp thcp commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

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 (fix(desktop): keep a portable run's temp files, tool caches and settings copy in its folder #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.

…gs or library

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 its factory reset or
auto-delete sweep would delete those songs. A portable package also adopted
~\Documents\StemDeck\jobs as its default library whenever it had entries,
and copied Documents\StemDeck\user-data.json into its own jobs folder.

A portable package now starts from its own data folder only, with its own
defaults, or the library the user picks in Settings. It never resolves
Documents. Installed builds keep ~/Documents/StemDeck/jobs as before.

The settings mirror (STEMDECK_SETTINGS_MIRROR) only existed to feed the
portable seed. A portable package already had none, and for an installed
build it was the same file as settings.json, so its write and its corrupt
file recovery did nothing. It is removed from the shell and the backend. A
corrupt settings.json is still moved aside for diagnosis.
@thcp
thcp merged commit 55a0d97 into big-release Nov 3, 2026
13 checks passed
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