Skip to content

Windows: leaner portable package and opt-in in-app updater (#421) - #423

Merged
thcp merged 8 commits into
mainfrom
next
Aug 23, 2026
Merged

Windows: leaner portable package and opt-in in-app updater (#421)#423
thcp merged 8 commits into
mainfrom
next

Conversation

@thcp

@thcp thcp commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #421 (as far as code goes; see Not yet verified before merging).

Integration branch for the next release. I will likely push more features here before this merges.

Why

#421 asked for Python embedded in a single EXE so updating would not mean copying ~20k loose files over an existing install. A onefile EXE is not viable for this stack: onefile modes re-extract the whole multi-GB payload on every launch, and torch/onnxruntime fight frozen-import hooks. So this fixes the root cause instead. Ship less, and stop making users hand-copy a full zip for a release that only changed app code.

Leaner package

scripts/windows/make-portable.ps1:

  • Stripping is now unconditional. The -StripVenv opt-in gate was a silent regression risk: nothing stopped a future workflow edit from shipping the unstripped venv with no error.
  • Also strips stdlib base/Lib/test and per-package test/tests dirs.
  • Deliberately does not strip .dist-info/RECORD. pip needs it to replace a package, and install_cuda_torch pip-installs into this venv on every NVIDIA machine at first run. Removing it produces Failed to uninstall ... missing RECORD file, which I hit for real while working on this.
  • Adds a post-strip import check, so an over-aggressive strip fails the build rather than a release.

Updater

New Tauri commands installed_runtime_id, download_app_update, apply_app_update, built on the existing runtime-pack download/verify primitives rather than tauri-plugin-updater (that plugin expects to replace an installed NSIS/MSI bundle; this project ships a hand-assembled portable folder, and bundle.targets is ["app"]).

Opt-in. The launch-time check is unchanged. Download and apply are each an explicit click. It never auto-applies and never interrupts a running job.

It replaces StemDeck.exe and backend/ only. python/ is never touched. An NVIDIA install rewrites that directory with CUDA torch at first run, and torchDeviceSettled (desktop/ui/setup.js:246) skips ensure_torch_device once the device is already cuda, so swapping the directory would silently drop that machine back to CPU with no recovery path. I had this wrong in the first draft and caught it on review.

The runtime id is a compatibility gate, not a download trigger. It is derived from uv.lock plus the interpreter major/minor, so it is stable across releases that do not change dependencies and identical for the CPU and NVIDIA variants. If a release changed the Python dependency set, the updater stands down and points at the full download. Only 19 of the last 200 commits touch uv.lock, so the fast path covers roughly 90% of releases and the rest fall back to today's behavior.

apply_app_update stages and validates everything before any destructive rename, stops the backend synchronously first (the existing stop_backend hands the kill to a thread and returns before the process dies, which would have made every update fail on Windows), and retries renames past transient AV/indexer handles.

Known gap, documented in code: the two exe renames are back to back but not atomic. A hard crash in that window leaves StemDeck.exe.old needing a manual rename. Closing it properly needs a bootstrap launcher that is never itself replaced, which is out of scope here.

CI

Publishes StemDeck-Windows-x64-app.zip, its .sha256, and -runtime-version.json alongside the unchanged full zips. Fresh installs are completely unaffected.

i18n

The 5 new strings are translated into all 7 language tables, not just English. t() falls back to English silently, so an English-only key looks correct in testing and ships untranslated text to six locales.

Verified

Check Windows Linux (WSL)
clippy clean, no new warnings clean, no new warnings
tests 39 pass 39 pass
rustfmt clean n/a

JS syntax and all four JS suites pass, PowerShell parses, ruff check and ruff format --check clean. Two new unit tests pin the JSON contract between the PowerShell writer and the Rust reader.

Not yet verified

Nothing here has run end to end against a real release. Before merging:

  1. Build the package and run a real Demucs job through it (the widened strip needs a runtime check, not just an import check).
  2. Measure the strip, so [Feature]: Embed Python into EXE #421 gets a real file-count/size number instead of a claim.
  3. Drive the full download to restart to apply loop, confirming data/ survives, portable.txt/cpu-only still gate correctly, and an NVIDIA install still reports demucs_device: cuda after updating.
  4. Confirm the compatibility gate falls back to the full download when uv.lock differs.
  5. Kill the app mid-apply and confirm the *.old / _update_app.tmp sweep recovers.

Note that /releases/latest excludes pre-releases, so testing needs either a full release or a locally served fake release manifest.

Also outstanding per the repo's own rule: decide which image version the Unraid template should pin before this lands on main.

Thales added 2 commits August 23, 2026 08:48
Issue #421 asked for Python embedded in a single EXE so updating would not
mean copying ~20k loose files over an existing install. A onefile EXE is not
viable for this stack (onefile modes re-extract the whole multi-GB payload on
every launch, and torch/onnxruntime fight frozen-import hooks), so this
addresses the root cause instead: ship less, and stop making users hand-copy a
full zip for a release that only changed app code.

Leaner package (make-portable.ps1):
- Stripping is now unconditional. The -StripVenv opt-in gate was a silent
  regression risk: nothing stopped a future workflow edit from shipping the
  unstripped venv with no error.
- Also strips stdlib base/Lib/test and per-package test/tests dirs.
- Deliberately does NOT strip .dist-info/RECORD. pip needs it to replace a
  package, and install_cuda_torch pip-installs into this venv on every NVIDIA
  machine at first run; removing it yields "Failed to uninstall ... missing
  RECORD file".
- Adds a post-strip import check so an over-aggressive strip fails the build
  rather than a release.

Updater (main.rs, catalog.js):
- New commands installed_runtime_id, download_app_update, apply_app_update.
- Opt-in: the check on launch is unchanged, but download and apply are each an
  explicit click. It never auto-applies and never interrupts a running job.
- Replaces StemDeck.exe and backend/ only. python/ is never touched, because an
  NVIDIA install rewrites it with CUDA torch at first run and torchDeviceSettled
  skips ensure_torch_device once the device is cuda, so swapping the directory
  would silently drop that machine to CPU with no recovery.
- The runtime id (uv.lock + interpreter major.minor) is a compatibility gate,
  not a download trigger: if a release changed the Python dependency set the
  updater stands down and points at the full download. Only 19 of the last 200
  commits touch uv.lock, so the fast path covers most releases.
- apply_app_update stages and validates everything before any destructive
  rename, stops the backend synchronously first (the existing stop_backend
  returns before the process dies, which would have made every update fail on
  Windows), and retries renames past transient AV/indexer handles.
- Known gap, documented in code: the two exe renames are not atomic. A hard
  crash in that window leaves StemDeck.exe.old needing a manual rename. Closing
  it needs a bootstrap launcher that is never itself replaced.

CI publishes -app.zip, its .sha256 and -runtime-version.json alongside the
unchanged full zips. Fresh installs are unaffected.

i18n: the 5 new strings are translated into all 7 language tables, not just
English. t() falls back to English silently, so an English-only key looks
correct in testing and ships untranslated to six locales.

Verified: Windows and Linux (WSL) both compile clean with no new clippy
warnings, 39 Rust tests pass on both, JS suites pass, ruff clean. Two new unit
tests pin the JSON contract between the PowerShell writer and the Rust reader.
Not yet verified: no end-to-end run against a real release.
…nd (#421)

Built both packages on a real Windows box and drove the whole flow. Four bugs
that only surfaced by running it, none of which static checks could see.

1. Stale version after updating. app_version() read installed dist metadata,
   which lives in python/ -- the directory the updater deliberately never
   replaces. A self-updated install kept reporting the old version and would
   re-offer an update it had already applied, forever. It now prefers the app
   layer's static/version.json, which moves with backend/. Gitignored, so Docker
   and source checkouts still fall through to the hatch-vcs metadata.
   Proven: after a real update, python/ dist-info says 0.13.0 while /api/health
   reports 0.13.1.

2. The page CSP blocked the whole feature. The UI is served over http by the
   Python backend, so its connect-src applies: api.github.com is allowed,
   github.com and objects.githubusercontent.com are not, and that is where
   release assets live. Fetching the checksum and runtime id from JS was
   refused, so the pill would simply never appear. Those two reads moved into
   Rust (check_app_update), whose HTTP client is not bound by the page CSP, so
   the policy from #171 stays exactly as tight as it was.

3. plugin:event|listen refused by the Tauri ACL. App-defined commands are not
   ACL-gated but plugin commands are, and the capability does not cover the
   remote http origin the UI is served from. The progress bar is now
   indeterminate instead of granting a remote origin event permissions to put a
   percentage on a 5 MB download.

4. The post-strip import check re-bloated the package. Running Python
   regenerated 1,912 files / 39 MB of __pycache__ that the strip had just
   removed, cancelling nearly all of it: the net saving was 180 files. Swept
   once after the last interpreter run, and backend/ no longer ships a
   developer's local __pycache__ either.

Also: the *.old sweep now runs on every launch rather than only on a version
change. apply_app_update relaunches then exits, so on the first launch of the
new build Windows still holds StemDeck.exe.old open, the delete fails silently,
and gated on a change that already happened it would never retry. Observed for
real: 15.7 MB stranded. Verified swept on the next launch.

UI: "Update now" is an accent pill BESIDE Download, not a replacement, so the
zip stays one click away and is the escape hatch if an update fails.

Measured against the published v0.13.0 package: 18,143 -> 16,056 files
(-2,087, -11.5%) and 883 -> 850 MB. The real win for #421 is the update path
itself: 5 MB and 123 files instead of 284 MB and 16,056.

Verified on this machine: a real 6-stem Demucs separation through the stripped
package; the full notify -> Update now -> download -> restart -> relaunch cycle,
after which user data (job, 7 stems, 130 MB of models), portable.txt, cpu-only
and python/ were all untouched; and the safety gate correctly declining, with
no download attempted, when the release's runtime id differs.

Not covered: the NVIDIA package was not built, though the risk that motivated
the gate is structurally gone now that python/ is never swapped.
@thcp

thcp commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Verified end to end on real Windows + RTX 3080

Built both packages locally and drove the whole update flow against a local stand-in for the Releases API. Testing found four bugs that static checks could not see, all fixed in 8c0a966.

What the run proved

Check Result
Real 6-stem Demucs separation through the stripped package 7 stems, status=done
notify -> Update now -> download -> restart -> relaunch completed, app relaunched on its own
Version after update /api/health reports 0.13.1 while python/ dist-info still says 0.13.0
User data survived job + 7 stems + 130 MB models intact
portable.txt / cpu-only / python/ untouched
Safety gate with a changed runtime id declines, no download attempted, only Download shown
*.old leftovers 15.7 MB, swept on next launch

The four bugs

  1. Stale version forever. app_version() read dist metadata from python/, the one directory the updater never replaces, so an updated install would keep re-offering the update it had just applied. Now prefers the app layer's static/version.json.
  2. CSP silently killed the feature. The UI is served over http by the backend, so its connect-src applies: api.github.com is allowed, github.com is not, and that is where assets live. The pill would never have appeared. Those reads moved into Rust, so the Desktop webview has CSP off while global Tauri IPC is exposed #171 policy is unchanged.
  3. plugin:event|listen refused by the Tauri ACL (app commands are not gated, plugin commands are, and the capability does not cover the remote origin). Progress is now indeterminate rather than granting a remote origin event permissions for a 5 MB download.
  4. The post-strip check re-bloated the package, regenerating 1,912 files / 39 MB of __pycache__ and cancelling out nearly the whole strip. Net saving had been 180 files.

Measured, not estimated

Against the published v0.13.0 package: 18,143 -> 16,056 files (-11.5%), 883 -> 850 MB.

The real answer to #421 is the update path: 5 MB / 123 files instead of 284 MB / 16,056.

UI

"Update now" is an accent pill beside Download, not a replacement, so the zip stays one click away as the escape hatch.

Separate pre-existing bug found (not fixed here)

A second StemDeck instance silently adopts the first one's backend. reserve_port binds 127.0.0.1:8000 even while another process holds 0.0.0.0:8000, the spawned uvicorn then dies with [Errno 10048], and wait_for_health(8000) gets a 200 from the other instance and proceeds. Worth its own issue.

Still not covered

The NVIDIA package was not built. The risk that motivated the gate is structurally gone now that python/ is never swapped, but a GPU install has not been exercised.

Comment thread tests/test_health_api.py Fixed
Comment thread tests/test_health_api.py Fixed
Comment thread tests/test_health_api.py Fixed
Comment thread app/main.py Fixed
Both findings from the automated review were fair.

Narrow the bare `except Exception: pass` in app_version() to
(OSError, ValueError, AttributeError). That is bandit B110, which this repo's
own security conventions call out. The three cover every real failure here --
absent or unreadable file, invalid JSON or bad encoding, and valid JSON that is
not an object so has no .get -- while letting an actual bug in the function
surface instead of silently degrading the reported version. Bandit now reports
no issues for the file.

Use one import style in test_health_api.py so app.main is no longer imported
both as `import app.main as main` and `from app.main import app` in the same
module. Also added a "[]" case: JSON that parses but is not an object, which is
the AttributeError branch the narrowed except now names explicitly.
@thcp

thcp commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Both automated findings were fair, fixed in 766c3e6.

Empty except (app/main.py) — this is bandit B110, which this repo's own security conventions list as a flag to watch, so it deserved more than a comment. Narrowed to (OSError, ValueError, AttributeError), which covers every real failure at that site:

  • OSError — marker absent or unreadable (the normal case in Docker and source checkouts)
  • ValueError — invalid JSON, or a bad encoding (JSONDecodeError and UnicodeDecodeError both subclass it)
  • AttributeError — valid JSON that is not an object, so has no .get

A genuine bug in the function now surfaces instead of silently degrading the reported version. bandit -ll reports no issues for the file.

Mixed import styles (tests/test_health_api.py) — unified on from-import, so app.main is no longer pulled in both ways in one module. Also added a "[]" case to the fallback test: JSON that parses but is not an object, which is exactly the AttributeError branch the narrowed except now names.

Linux ships the same shape as Windows -- executable, backend/ and python/ side
by side -- so the updater generalises rather than needing a second design. The
platform-specific parts are now three small seams: the archive format, the
executable name, and one new gate.

Rust:
- widen the updater's cfg gates from `windows` to `any(windows, linux)`, and
  replace extract_zip_archive with extract_update_archive, which uses zip on
  Windows and the existing extract_tar_archive on Linux
- APP_EXE_NAME so the swap and the leftover sweep stop hardcoding StemDeck.exe
- stop_backend_and_wait now sends SIGTERM and waits before escalating on unix,
  matching what stop_backend already does on window close
- new app_root_is_writable gate: packaging/linux/install.sh offers a global
  install into /opt/stemdeck, which is root-owned while the app runs as the
  user. check_app_update declines up front rather than failing part way through
  a swap. Windows portable installs are user-writable by construction, but the
  probe is cheap and honest on both.

tar rather than zip on Linux is deliberate: it preserves the executable bit. A
zip would land StemDeck without +x and the relaunch after an update would fail
with a permission error.

Packaging (scripts/linux/make-portable.sh):
- write python/runtime-version.json using the same uv.lock + interpreter
  major.minor formula as the Windows script, so the compatibility gate behaves
  identically on both
- bring the strip to parity: stdlib test/, per-package test/tests, a post-strip
  import check, and a final __pycache__ sweep after the last interpreter run
- PUBLISH_UPDATER_ASSETS=1 emits the slim app-layer tarball, its checksum and
  the runtime marker; wired into the CPU build in linux-release.yml since
  StemDeck and backend/ are identical between both variants

Frontend: updaterAssetNames() maps the platform to its asset names, and the
wiring is gated on that rather than on os === "windows".

macOS is deliberately still excluded, and the comments now say why rather than
just that it is: backend_dir() resolves the backend inside the downloaded
runtime pack rather than the .app, so its app layer is a different thing and
the existing runtime-pack updater already covers most of it.

Verified: both platforms compile clean with no new clippy warnings, 42 tests on
Windows and 43 on Linux (the extra one is the read-only-root gate, which is
meaningless on Windows). The app-layer archive was round-tripped on Linux to
confirm it contains exactly StemDeck + backend/, that python/ does not leak
into it, that the executable bit survives, and that replacing a running binary
works. Not yet run end to end against a real Linux release.
@thcp

thcp commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Linux support added (8238eb0)

Linux ships the same shape as Windows — executable, backend/ and python/ side by side — so the updater generalised rather than needing a second design. The platform-specific surface is three small seams: archive format, executable name, and one new gate.

The gate that mattered

packaging/linux/install.sh offers a global install into /opt/stemdeck, which is root-owned while the app runs as the user. An in-place swap there fails. check_app_update now probes writability and declines up front, falling back to the normal download — the same path a dependency mismatch already takes. Windows portable installs are user-writable by construction, but the probe is cheap and honest on both.

tar, not zip

Deliberate, and verified rather than assumed: tar preserves the executable bit. A zip would land StemDeck without +x and the relaunch after an update would fail with a permission error.

Verified

Windows Linux (WSL)
clippy clean, no new warnings clean, no new warnings
tests 42 pass 43 pass

The extra Linux test is the read-only-root gate, which is meaningless on Windows and correctly skipped there.

The app-layer archive was round-tripped on Linux to confirm it contains exactly StemDeck + backend/, that python/ does not leak into it, that the executable bit survives extraction, and that replacing a running binary works.

Not covered

A full make-portable.sh run end to end against a real Linux release. The archive mechanics and both compilers are verified; the complete packaging path is not. That is the same state Windows was in before I built it.

Worth noting in context of the audit: no Rust compiler pass ran in CI for this commit either. windows-check.yml and macos-check.yml are workflow_dispatch only, so all of the above rests on local verification. A paths: filter on desktop/src-tauri/** would close that.

macOS

Still excluded, and the comments now explain why rather than just stating it: backend_dir() resolves the backend inside the downloaded runtime pack rather than the .app, so its app layer is a different thing entirely and the existing runtime-pack updater already covers much of it.

Thales added 2 commits August 23, 2026 19:33
Two gaps that would each have undermined the update flow on release day.

The update check polled /releases/latest, which GitHub defines as the most
recent NON-PRERELEASE, non-draft release. Ship a version with the pre-release
box ticked and it becomes invisible: no notification, no update button, on any
platform, with nothing in the logs to explain it. StemDeck has always published
even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false),
which is the only reason this has not bitten yet -- it was a trap waiting on
someone ticking a box. Now polls the releases list and takes the newest
non-draft, so it is correct either way. Drafts stay excluded: they are already
invisible unauthenticated, and a maintainer should not be offered a release
whose assets do not exist yet.

windows-check.yml and macos-check.yml now also run on pull requests that touch
desktop/src-tauri/**, not workflow_dispatch only. This PR added roughly 600
lines of mostly cfg-gated Rust across two commits and every CI check passed
without compiling a single line of it; the comment at the top of
windows-check.yml notes that exact gap already shipped a broken Windows build
in v0.11.1's first release attempt. Scoped by path so the self-hosted runners
see no extra load from the majority of PRs, which never go near src-tauri.

This also gets the macOS branch compiled for the first time. Local verification
covered Windows and Linux, so the cfg(not(any(windows, linux))) arm of the
three updater commands has never been near a compiler.
The update-check stub returned a single release object, which was right for
/releases/latest. The app now polls the releases list so a pre-release is still
seen, so the fixture has to return an array or checkForUpdate bails and the
release card never appears.

Caught by frontend-e2e on the previous commit, which is the suite doing exactly
its job: the only assertion that covers this path is
report-failure.spec.mjs:98, and it went red immediately.
@thcp

thcp commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

All 12 checks green, including Rust for the first time

check result
Windows Rust Check pass (1m14s)
macOS Rust Check pass (3m56s)
test / lint / frontend-e2e pass
CodeQL js + python pass
bandit / trivy / deps-audit pass
js-syntax / linux-installer pass

Both check jobs are new here. Until this PR they were workflow_dispatch only, so roughly 600 lines of Rust across two commits had passed CI without being compiled once. They now run on any PR touching desktop/src-tauri/**, path-scoped so the self-hosted runners see no extra load from the majority of PRs.

The macOS run matters beyond the gate itself: local verification only covered Windows and Linux, so the cfg(not(any(windows, target_os = "linux"))) arm of the three updater commands had never reached a compiler until now.

Two bugs this round

The pre-release trap. The update check polled /releases/latest, which GitHub defines as the most recent non-prerelease release. Publish a version with the pre-release box ticked and it is invisible: no notification, no update button, on any platform, and nothing in the logs to explain it. StemDeck has always published even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false), which is the only reason it never bit. Now polls the releases list and takes the newest non-draft, so it is correct either way rather than dependent on remembering not to tick a box.

A regression I introduced, caught by the suite. That API change broke frontend-e2e, because the stub in tests/e2e/helpers.mjs returned a single release object rather than the list shape. report-failure.spec.mjs:98 is the only assertion covering that path and it went red immediately. Fixture corrected in 23b91cc.

Worth noting given the audit's finding on the test suite: this is the second time in this PR that an existing test caught something real. The suite is worth keeping honest.

Thales added 2 commits August 23, 2026 19:48
French is a complete table, not a partial one: 435 keys, the same set German
and Portuguese carry (English's 443 minus the ten Polish-only .few/.many forms
and the bare upload.skippedFiles, plus singular forms for the three
playlist.skip.* families). French takes the one/other buckets, so plural()
needs no change.

Verified with the checks from .claude/rules/i18n.md: the drift check reports
clean, and separately there are zero {placeholder} mismatches and zero HTML tag
mismatches against English. The 27 strings identical to English are genuinely
identical in French (Piano, Solo, Transport, Position, LUFS, Standard, Port,
the brand names, CUDA (NVIDIA), MPS (Apple Silicon)).

Separately: the runtime id was being computed from the raw bytes of uv.lock, so
a Windows checkout with core.autocrlf=true hashed CRLF and Linux hashed LF, and
the same lockfile produced two different ids -- caught by building the Linux
package and seeing py3.12-d74d6ef80c5e9d1f where Windows had produced
py3.12-dbda45e38e1044cf. Each platform stayed self-consistent so the gate still
worked, but the id would shift spuriously if a runner's autocrlf ever changed,
silently declining app-only updates that were in fact compatible. Both scripts
now hash the content with newlines normalised; PowerShell, bash and a reference
Python implementation all agree on d74d6ef80c5e9d1f.
Per .claude/rules/unraid-template-version.md this is an explicit decision each
time, not a default. Confirmed for this release.

The 0.14.0 GHCR image is published by docker-publish.yml when the release is
created, so the tag exists shortly after this lands.
@thcp

thcp commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Merging

11 of 12 checks green, including windows-check on the head commit. The one red is macos-check, and it is a stalled self-hosted runner rather than a defect:

commit Rust delta macos-check
cde9e6b Rust + CI triggers pass
23b91cc e2e fixture pass
2d5c12c every remaining Rust change pass
cc47ce1 none — one line of XML fail

cargo fmt --check passed and then cargo build never reported a conclusion; the run hung ~25 min against a 30 min timeout, and the re-run sat queued for 51 min with no runner picking it up. There is zero Rust difference between the commit macOS compiled green and the one it failed on, so the macOS result is already established. Merging on that basis, with the runner to be brought back separately.

What shipped

Windows and Linux both get a click-initiated update. An accent "Update now" pill sits beside Download, which stays as the escape hatch. The check is automatic; download and apply are each an explicit click, and it never applies silently or interrupts a running job.

Verified on real hardware, not asserted:

Windows Linux
real 6-stem separation on the stripped package 7 stems 7 stems
full notify → download → restart → relaunch yes GUI unverified (WSLg cannot render WebKitGTK)
user data / markers / python/ survive yes n/a
compatibility gate declines on changed deps yes logic shared
clippy + tests 42 43

Nine real bugs found by building and running rather than reading: stale version after update; CSP silently blocking the whole feature; the Tauri ACL refusing plugin:event|listen; the post-strip check re-bloating the package by 39 MB; *.old leftovers never swept; the pre-release trap; an e2e fixture shape regression; the /opt writability gap on Linux; and a runtime id that differed between platforms purely from CRLF vs LF.

Measured: 18,143 → 16,056 files, 883 → 850 MB. The update path itself is 5 MB / 123 files instead of 284 MB / 16,056.

Zero new test failures: identical 14 on main and next, all pre-existing Windows-environment issues.

French added as a complete table (435 keys), drift clean across all 8 languages with zero placeholder or HTML-tag mismatches.

@thcp
thcp merged commit 9b655d5 into main Aug 23, 2026
11 of 13 checks passed
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.

[Feature]: Embed Python into EXE

1 participant