Skip to content

Release 1.7.0-beta.14 - #760

Merged
Pixnop merged 68 commits into
mainfrom
dev
Oct 16, 2026
Merged

Pixnop merged 68 commits into
mainfrom
dev

Conversation

@Zaldaryon

@Zaldaryon Zaldaryon commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Merges with a merge commit, not a squash. The head is 2def7fed65f622a0abe4900ef72021375931f5b3, the dev content that was tested: the full local test of the packaged build (79 scenarios in 9 zones) ran on tree e253c3e9, #755, #756 and #759 had their delta pass on 2def7fed, and the packaged Windows install conformance passes on dev at this commit.

This beta is release candidate 1 for 1.7.0, and everything since beta.13 is fixes. Installation and VS Version handling now refuses nested or overlapping managed folders, keeps an interrupted version install out of the installed list, leaves the player's own files alone on uninstall, lets a version whose folder is already missing be uninstalled, and gives an accurate notice when a linked version is removed from the list. Backups and restores hold the main-process Installation lease and block Play, Join, and settings saves while running, keep older backups when a new backup fails, refuse a restore or delete that would erase a listed backup archive, and allow deleting world backups while enforcing backup limits. Optimum removal and updates no longer write older backups over an updated build, undo Optimum.GameContent.dll when Optimum is removed or a patch fails, and validate referenced Optimum assemblies. On the Mods and update side, an installed Mod stays in place until its replacement finishes downloading, ModDB detail batches stop after the first timeout, mod config imports handle case variants and symlinks safely, downloaded launcher updates no longer install on quit, "Restart and update" no longer opens the installer wizard on Windows or runs while the close guard is held, and the native LZMA2 addon stays loaded on the main thread on Windows so worker teardown cannot unload it. Unreadable clientsettings.json files are set aside instead of blocking launch, one refused folder no longer blocks later settings saves, a window on an unplugged monitor comes back on screen, empty translations fall back to English while dates, recovery notices, and file pickers follow the chosen language, and Weblate translations are updated.

Contents

Pixnop and others added 30 commits October 4, 2026 13:30
…607)

The test rewrote an 8-byte icon with 8 other bytes right after reading it. The protocol tells a refreshed file apart by its size and its times, and on a fast Windows runner both writes landed in the same timestamp tick, so the second read was served from the cache and the test failed on main after the beta.13 promotion (windows-latest, Node 26). A real ModDB logo refresh happens long after the first read, so the test now moves the file times two seconds on after the rewrite, the way a later write would. The size stays the same on purpose: the check must not rely on it alone.
* Fix case-variant mod config imports

* Preserve on-disk casing in config backups and applied records
* Address nonblocking mod config review follow-ups

Strengthen the refused-link pre-pass test with a mixed pack and assert no
outside bytes appear in the retained recovery folder. Avoid pruning older
recovery folders on partial imports that displaced no existing file. Fix the
long backup path assertion to inspect the configured custom backup root, and
tie the atomic write temp suffix test to write-file-atomic's temp generator.
Update stale descriptions in parseChosenConfigNames, useExportModpack, and
importModConfigsReasonNotLanded.

Fixes #599

* Address review feedback on mod config follow-ups

Update fr-FR translation for importModConfigsReasonNotLanded to match
the digest-mismatch meaning. Update the WINDOWS_LEGACY_MAX_PATH comment
to document why 260 is kept as a cautious compatibility guard for
tooling without extended-path awareness. Assert _getTmpname is a function
in the atomic write temporary suffix test, and update comments and test
names in parseChosenConfigNames, handler step 9, and the backup path test.
The root element was `<html lang="en">` and nothing changed it, so with the launcher set to French, German or Russian a screen reader still read every string with English pronunciation rules (WCAG 3.1.1).

`i18n.ts` now listens to the `languageChanged` event of i18next and writes the language to `document.documentElement.lang`. Every switch goes through i18next, whether it comes from the Config page or from the restore at start, so the one listener covers them all. A language that fails to load never reaches i18next, so the page keeps declaring the language it still shows. The listener is registered before `init`, which emits the event for the starting language straight away, so the page declares `en-US` before the first render. The restore at start loads its language after that render, so until it lands the page shows English and says so. The `en` in `index.html` is only what the page says until the script runs.

The language keys are already language-region tags (`fr-FR`, `zh-CN`, `pt-BR`), so they are used as they are. All fourteen languages read left to right, so there is no `dir` handling.

Fixes #612
…637)

GitHub's answer for the ten latest releases weighed 203 KB against a 256 KiB ceiling, and most of it is not notes: each release also carries its author and eight assets with an uploader object each, about 15 KB the launcher never reads and the releases API cannot be asked to leave out. The 58 KB of headroom shrinks as releases land, because each new entry displaces an older and smaller one, and the 1.7.0 note is written once for a long stretch of changes. Past the ceiling the fetch fails with too-large, the What's new dialog does not open and Info & Help says the notes could not be loaded.

The ceiling is now 1 MiB, which holds ten releases with every body at its 64 KiB cap plus today's metadata (about 800 KB) and is still a quarter of the generic response ceiling. per_page stays at ten: neither screen shows more than five releases, but both filter after the fetch (drafts, betas on a stable build, the dialog's window from the last seen version to the running one), so the ten is the pool they draw from, and a build several releases behind the newest finds its notes only if its release is in it. The comments that assumed notes under 7 KB and a 256 KiB ceiling are updated.

The tests build a ten-release answer from the shape of the real one (the author, sixteen assets with their uploaders and 10 KB of notes on each, 389,071 bytes): it is accepted, and the same answer padded to one byte over 1 MiB is refused.

Fixes #611
Update all filtered its list down to the Mods it may touch and ran over
the result even when that was empty. With no failures the run took the
success branch, so the player was told every Mod was updated and a Mod
Update Summary opened with no rows. The same happened when the only Mods
with an update were turned off or had their updates suspended: they are
still listed under Mods with updates, but the run leaves them out.

The run now returns before it starts when nothing is left. An info toast
says there is nothing to update and that disabled Mods and Mods with
suspended updates are left as they are, no summary opens, and the
Installation is not marked as updating, so a run that changes nothing
does not rewrite the config either. Both the action bar and the
Installation check's Update these call this function, so the guard is
here rather than on a button. The page's own update count includes the
held-back and turned-off Mods by design (#194, #287), so a button greyed
from it would still run empty in the second case.

Two DOM tests cover a folder with no update and a folder whose only
updates are held back or turned off. The new string is in en-US and
fr-FR, and the translation status page is regenerated.

Fixes #608
Flipping a Config switch whose notice says "takes effect the next time"
twice in a row put one record into the toast stack twice. The second
flip only folds into the record the first one made, which changes
records and runs the hand-off effect again. A click renders ahead of the
update that effect queued on its first run, so the second run read the
same queue and the same empty stack and admitted the same id. The two
banners then shared a key, and with another banner waiting one copy
stayed on screen after both were closed, its close button doing nothing.

The update that appends to the stack now skips an id the stack already
holds. It is the only place the stack grows and the one place handed the
stack as it really is, so one id twice cannot happen whatever the
timing. Closing another banner in that window did the same, so the
repeat was only one way in.

The tests deliver the second click with flushSync inside one act(),
while the first hand-off is still pending, and fail without the guard.

Fixes #613
The prune in makeInstallationBackup counted records, not archives, and always took from the oldest end. A record whose archive had been deleted from the Backups folder by hand still held a slot, so with a limit of 2 and one such record the walk deleted the only archive left on disk. Since #507 a missing archive is read as deleted, but only when the walk reaches its record, and a stale record among the newest ones is never reached before a live one.

The prune now sorts every record first. An archive on disk counts, and the walk deletes from those, oldest first. An archive missing from a folder that is still there was taken out by the player: its record is reported as deleted, which removes it from the installation, and it does not count. An archive missing from a folder that is missing too, or one the host will not answer about, is an unplugged drive, an offline share or a Backups folder that moved: its record is kept, uncounted, never reported and never deleted, so it is listed again when the drive is back. The folder is asked about only for a missing archive, through the file system port and a new parentFolder helper in domain/paths. The walk deletes the same archives as before or fewer, never one the old walk would have kept. World backups have no limit and no prune, so nothing changes there.

Two existing tests change with it: the trace of a backup now holds one exists call per record before the first delete, and the #507 test drops both stale records instead of the one the walk used to reach.

Fixes #610
On the first attempt of runs 37157170594 and 36321434830 the launcher
disappeared during the install step, and the report said only that the launcher
process was probably gone. The script kept the child's stdout and stderr and
nothing else: once attached it never asked the process anything, the report had
no exit code or signal, and neither the launcher's own logs nor Crashpad were
looked at. A crash in the launcher and a fault in the harness looked the same,
and both reruns passed.

The exit is now read off the child, which keeps exitCode and signalCode once it
ends, so it is there in whatever phase the run is in. The report takes it before
the script's own kill, so that kill is not taken for a death. A closed DevTools
socket waits up to five seconds for the exit event and then says the launcher
process exited with code 3221225477 (0xC0000005), was ended by signal SIGKILL, or
was still running 5000ms later, in place of "probably gone". The wait for the
window uses the same reading, which also stops it at once for a launcher ended by
a signal instead of after 90 seconds.

When the launcher has died, the report also holds the last 60 lines of each
Logs/*.log under its user data folder and a listing of its Crashpad folder. A
native crash writes nothing to those logs, so a step that runs only on failure
prints the Application Error and Windows Error Reporting events for
RiftLauncher.exe from the last hour, which name the faulting module. It reads at
most fifty events, stops after two minutes and prints one line when there are
none.

The parts that need no Windows moved to tests/e2e/launcher-death.ts, since the
script exits on import anywhere else, and a new test covers them with a fake
process and a temporary folder, along with the step's failure-only condition.

Fixes #614
…#639)

With the ModDB unreachable and nothing else wrong, the Installation
check opened collapsed under the header "Nothing to fix.". How many Mods
it could not check, and how many archives it could not read, were in the
collapsed body, and the header never looked at either.

That line was never true when it showed. The panel draws nothing when
there is no finding, no unchecked Mod and no unreadable archive, so the
only way to reach it was with one of those counts above zero. It is
gone from the 14 locale files, and its review entries from
drafted.json.

The header now counts everything the panel holds: problems, updates,
Mods not checked and archives unreadable. The panel still opens itself
only for a blocking finding. The unchecked count takes in every Mod the
ModDB has no entry for, so a single hand-made Mod would hold the panel
open over the Mod list on every visit.

The two new plural families are written in en-US and fr-FR only, where
the removed key was, and the status page is regenerated.

Fixes #609
What's new stripped anything shaped like an HTML tag before it looked at code spans, so the `<file>` in "`certutil -hashfile <file> SHA256`" and in "`sha256sum <file>`" went with the real markup, and the closing backtick of the second span, left between two spaces, stayed on screen. The comment, script and tag strips no longer see the `<` of an inline code span: it is written as `&lt;` first, which `toPlainText` already decodes once the strips have run, so a span comes out as the author wrote it. Text outside a span is stripped as before, and the notes are still shown as React text only, so a tag kept inside a span is printed, never rendered.

Fixes #623
…644)

The favorites filter ran inside the search, against the favorites held by the
render that started it, and only the filtered list was kept. A star click
changes the favorites but nothing runs the search again, so with the filter on
a Mod whose star was just turned off stayed in the list until the next search.

The search result is now kept as it comes back, and the list shown is worked
out from it and the current favorites. With the filter on, turning a star off
drops its card at once and starts no search. With the filter off the list is
the search result itself, so starring a Mod moves nothing else.

Fixes #617
The notice that says config.json could not be read is turned into a
sentence once, when main hands it over, and its record keeps that
sentence. The renderer starts in en-US and applies the stored language a
moment after its first render, so the sentence came out in English
whenever the notice arrived first, which was about every other launch
for a player whose language is not English.

The notice now waits for the stored language before it is worded.
restoreStoredLanguage in i18n.ts is what App already did on mount,
moved there so the provider can wait on it as well. Only a launch that
has a notice waits, and a language that cannot be loaded does not hold
it back: the notice comes out in English, as before.

Every other notification also keeps a finished sentence, so the record
stays as it is instead of carrying a key and its parameters for this
one message.

The tests hold the notice, or the stored language, back and expect the
French notice either way, plus the English one when the language cannot
be loaded. The first of them fails without the change.

Fixes #619
Four screens printed Spanish dates whatever the launcher language was:
the installations list, the backups list, the servers page and a Mod's
release table all passed "es" to toLocaleString or toLocaleDateString.
Manage Worlds passed nothing, so its dates followed the system's locale
and not the launcher's. "4/10/2026" read as 4 October to a Spanish
reader and as 10 April to an English one, on lists that exist to tell
the newest entry from an older one.

The screens that were right had each got there their own way
(i18n.resolvedLanguage, i18n.language, a private releaseDate), which is
how the others drifted. useDateFormat now reads i18n.resolvedLanguage in
one place and returns formatDateTime and formatDate. Every date on
screen goes through it, the four that were already right included. It is
a hook rather than a function taking the language, so a caller cannot
hand it the wrong one.

formatDate is the old releaseDate of the Mod details panel, moved so the
release table can use it. A release whose date is missing or unreadable
now leaves its cell empty in the release table instead of printing
"Invalid Date", as the panel already did.

Spanish reads as before, since es and es-ES spell every instant alike.
The new tests mount each screen in en-US and in fr-FR with one moment,
4 October 2026, and compare its date. On dev they fail for the five
screens that were wrong and pass with this change.

Fixes #616
An AppImage that keeps the name it was downloaded under, such as
riftlauncher-1.7.0-beta.13.AppImage, is not replaced by an update.
electron-updater deletes it and writes riftlauncher-1.7.0.AppImage
beside it, so every shortcut, desktop entry or script that pointed at
the old name stops working after each update. A file with no version
number in its name, RiftLauncher.AppImage for instance, is replaced at
the same path and keeps working.

The Linux install guide never mentioned a rename, and its chmod line
used the downloaded name, so players ended up with the versioned file.
The move step now says to rename the file to RiftLauncher.AppImage, or
to any name without a version number, and gives the reason in a
sentence. The chmod command uses the new name. The portable section
needed no change: the marker is looked up in the folder of the
AppImage, not by the file's name, and the updater writes a renamed file
into that same folder.

Fixes #649
…652)

electron-updater's autoInstallOnAppQuit defaults to true, and
registerAutoUpdaterEvents turned autoDownload off and left that one on. Once a
download finished, the updater hooked the app's quit event and ran the
installer silently the next time the launcher closed, whatever the player did
with the "ready" toast. In the beta to stable test the toast was closed, then
the window, and the AppImage was replaced on the way out with nothing on screen
saying so.

The flag is now turned off next to autoDownload. electron-updater reads it when
a download finishes and again at quit, so with it off from the start no quit
hook is ever registered, and a finished download installs through "Restart and
update" and nowhere else. That button does not depend on the flag, and closing
its banner leaves the Activity Center entry in place. A download left waiting
stays in electron-updater's cache: the next launch offers the update again, and
accepting the offer finds the cached file by its checksum and reports it
downloaded without requesting it a second time. A newer release empties the
cache and downloads the new file instead. No toast or locale text promised an
install on close, so no string changes.

The tests hand registerAutoUpdaterEvents a fake updater that starts with
electron-updater's default and check that the flag is off after registration
and still off after a whole download. A third closes the "ready" banner and
installs from the Activity Center. The first two fail without the line.

Fixes #648
)

Every dialog but the first one of a session left the page behind it in reach
of assistive technology. Headless UI hides the application, with aria-hidden
and inert on the top-level node that holds it, from one effect that runs when a
Dialog turns open. That node is only found one render after the Dialog mounts,
and nothing the effect depends on changes then, so a Dialog that mounts already
open runs it too early, finds nothing to hide and never runs it again.
PopupDialogPanel renders its Dialog only while the panel is open, so every
dialog mounted already open. The first dialog of a session escapes because
Headless UI also holds back "open" until its own first effect, which is why the
launch prompt looked right and what came after it did not. Only aria-modal and
the focus trap kept a screen reader inside a dialog, and not every screen
reader honours aria-modal.

The Dialog now mounts closed and opens from the first effect, the order
Headless UI is written for. The state sits in a small component inside
PopupDialogPanel, which every dialog goes through, so it starts closed again
each time a dialog mounts. The exit animation, onExitComplete, the focus trap
and Escape are the code they were. A dialog opened over another hides the one
under it, and the page comes back when the last one is gone.

Hiding the root took the notifications with it, because the toast region sat
inside it. The login dialog stays open when a login fails and the banner is the
only place the reason is given, and some banners carry the only way forward,
like the keyring guide. With the dialog change alone, five tests that show a
banner over an open dialog fail. The region now renders into the body, beside
the root. Headless UI leaves any other top-level node alone and counts a click
on it as a click inside the dialog, so the banners stay announced and
clickable while a dialog is open. It is fixed instead of absolute, because the
root's overflow no longer clips a banner sliding in from the right, and it
carries its own select-none, which it used to inherit from the root. Clicking a
banner no longer closes the dialog behind it: discarding or answering one used
to count as a click outside the dialog. Tab still stays inside the dialog, so
as before the banners are not on its path.

popupDialogInert.test.tsx mounts the real App and a pair of nested dialogs.
jsdom has no inert, so the file gives it one and makes focus() refuse inside an
inert element, as Chromium does, which is what lets it check that focus returns
to the control that opened the dialog. It also uses a Headless UI portal once
first, since the first dialog is the exception above. Two tests in
notificationsOverlayPlacement.test.tsx pin where the region sits. Two existing
tests leaned on the old behaviour and say so now: modProfiles closed a dialog
by discarding a banner, and sessionButtonSwitchAccount looked for the trigger
by role behind a confirm dialog that is still open.

Fixes #624
When a switch failed on some archives and the profile also listed a Mod
the folder no longer holds, the warning and the Profiles dialog counted
and named only the failures: with two archives refused and one Mod gone,
the log said "2 failed, 1 missing" and the screen said 2. A switch with
no failure already reports those Mods, so the partial branch now counts
plan.missing and plan.unresolved with the failures and lists
plan.skippedMods after them.

The planner flags a skipped group when the folder holds no archive of the
Mod, and the dialog writes it as "<modid> (not installed)", so a list
that mixes refused archives with absent Mods says which is which. The new
string is in en-US and fr-FR, and the translation status page is
refreshed.

Fixes #622
A check that failed left two lines in the log files, and both carried the
releases feed. With the GitHub provider the error message is a first line
naming the reason, HTTP 406 while no regular release is published, followed by
the whole releases.atom feed. electron-updater writes that message itself, at
error level, from a listener on its own "error" event, before the check's
promise rejects. scheduleUpdateCheck then logged the same message at info
level. logMessage cuts a line at 16 KiB, so every launch where the check
failed added two lines that were mostly feed, one of them to error.log, and
the logs are what players share when they ask for help.

The catch in scheduleUpdateCheck now writes one warn line holding the first
line of the message and nothing after it. The updater's own line is the
duplicate, so the check runs with the error level of the updater's logger
switched off, and the logger goes back whatever the check does. A download or
an install that fails has no check around it and keeps the updater's own
record. The line is written in the catch and not from the updater's error
event because a check that never goes out, when the beta setting cannot be
read, fails without the updater hearing of it. What the renderer is told is
unchanged: the error event still reaches it, with nothing in it.

The new tests fail a check with a message that is a reason followed by a large
feed and read what reaches electron-log. One file runs the real
electron-updater with a provider that fails that way, wired as main/index.ts
wires it, so the package's own line is really there to be silenced.

Fixes #650
The import dialog builds its rows from the ModConfig listing, and the listing
left links out. The walk keeps only regular files, so a link at a name the pack
carries, whether it points at a file or at a folder, was simply absent. The
dialog read that as "This Installation has no file at this name" and ticked the
row. The apply then refused the name, because it never writes through a link,
and the summary said the file could not be written. Nothing outside the
Installation was touched, but the player was told a name was free, ticked it on
that word, and learned afterwards that something else sits there. A folder that
is a link did the same to every name under it.

The listing now answers { ok, configs, linked }. linked holds the names of the
links the walk meets, as relative keys with / between directories and under any
name, since a link to a folder has no .json in it. They come from the directory
entry: the walk opens none, follows none and descends into none, and they stay
out of configs, which the export builds its rows from. The dialog treats a
carried name as blocked when it equals a link's name or sits under one,
comparing whole folder names with the case fold the replace check already uses.
A blocked row is unticked, cannot be ticked, and says "Blocked by a link: the
launcher never writes through a link". A folder holding only links no longer
reads "empty, so every file below is new" above such a row.

The apply is untouched: the destination check, the pre-pass that refuses a
linked folder, the case-variant resolution and the prune gate are all as they
were. A request that names a link anyway still ends in write-failed. A new test
asks for a link to a file, a link to a folder, a name below a linked folder and
a link inside a real folder, and checks that nothing was read or copied through
any of them and that nothing outside changed. A folder, or a file, sitting where
a name or its folder belongs is refused the same way and is not covered here.

The new string is in en-US and fr-FR at the same position, and the translation
status page is regenerated.

Fixes #621
…#662)

Optimum's patcher now deploys Optimum.GameContent.dll to the game root,
backing up a copy the build already had under .optimum/vanilla/, or
writing an .absent marker there when it had none
(StratumServer/Optimum#131). The launcher restores a build from that
folder itself and then deletes the whole state folder, and it only knew
about Optimum.Api.Contracts.dll, so Remove Optimum and the rollback of a
failed run both left the assembly in a folder that was otherwise vanilla
again, with the record of what had been there before gone too.

restoreVanillaBuild now copies the backup back over the live file when
there is one and removes the live file otherwise, before the state
folder goes. The refusals are unchanged, and a failure in the new step
leaves the state folder for a second try. A live file with neither a
backup nor a marker is removed here where Optimum's own rollback leaves
it, as the contracts assembly already is.

Part of #578
)

i18next escapes every value it puts into a sentence for HTML unless
interpolation.escapeValue is false, and i18n.ts never set it. React then
escapes the finished sentence a second time when it renders it as text, so a
name or a path holding ' " & < > or / reached the screen as an entity. An
Installation named Bob's World read "Bob&#39;s World" in the warning shown when
deleting a VS Version it uses, and a mod config at Sub/x.json read
"Sub&#x2F;x.json" in the export refusals. Eleven lines in nine files worked
around it with interpolation: { escapeValue: false } on the call. Many other
calls did not, among them the backup task names, the modpack import
description, the suggestions title and the export refusals.

The setting is now off where i18next is initialised, with a comment saying why.
It is what react-i18next documents for React, and it is safe here because
nothing in the renderer hands a sentence to the page as HTML. There is no
dangerouslySetInnerHTML, innerHTML or insertAdjacentHTML, no markdown or HTML
renderer, and the seven Trans usages pass components only, in sentences that
carry no placeholder. A value holding {{x}} or $t(key) still comes back as
written, because i18next skips interpolation inside values. The main process
and the preload have no i18next instance of their own.

Those per-call overrides are redundant now. They stay for a separate cleanup,
because open pull requests touch several of the files that hold them.

A new test asks the app's own i18n module for the suggestions title with a
value holding all six characters. Another renders ModSuggestions, whose title
sets no override of its own, and reads the heading by its name. Both fail on
the code as it was, with the entities in the text, and both fail again when
the setting is put back to true.

Fixes #663
Every native picker opened through SELECT_FOLDER_DIALOG (an Installation
or VS Version folder, a custom icon, a background) was titled
"Selecciona una carpeta", whatever language the launcher is set to and
also when it asked for a file. It was a leftover from the code the
launcher started from.

The title is now "Select a file" when the caller asks for a file and
"Select a folder" otherwise, in English like the two modpack dialogs,
since the main process has no translations. No caller passes the multi
mode, so there is no plural title. Translating the native titles stays a
change of its own.

Fixes #666
* Refuse bidi controls and zero-width characters in config keys

In a modpack settings block, keys carrying bidirectional controls or
invisible zero-width characters pass the filename validation rules.
Windows and Unix file systems admit these characters, but they alter
visual rendering: bidi controls (such as U+202E right-to-left override)
change the display order of following characters so an extension or stem
displays as something else, while zero-width characters (such as U+200B
zero-width space) make distinct names look identical or render as empty.

Add BIDI_OR_ZERO_WIDTH_CHARACTER matching Unicode Bidi_Control, format
characters (Cf), and the Mongolian vowel separator. In assertModConfigKey,
refuse segments containing any of these characters alongside existing
Windows forbidden characters. Add unit tests verifying refusal in
assertModConfigKey and parseModpackSettings.

Fixes #594

* Address review feedback for issue 594
Restart and update called quitAndInstall(false, true), so the Windows
installer ran without /S. It is the assisted installer (oneClick is false in
electron-builder.yml), and --updated skips only its license and folder
pages. The launcher closed, then the wizard waited for the player on the
install mode page ("Who should this application be installed for?") and
again on the Finish page. The first one also offers "Anyone who uses this
computer", which would install a second, per-machine copy, and Cancel would
leave the old version in place with nothing running.

The restart itself rested on a checkbox. When the install is not silent,
electron-updater drops the second argument and passes autoRunAppAfterInstall
in its place, and the assisted installer reads --force-run only when silent.
What brought the launcher back was "Run RiftLauncher", ticked by default on
the Finish page.

The call is now quitAndInstall(true, true), so the installer gets --updated
/S --force-run: it updates the existing install in place with no wizard and
starts the launcher again. That is the silent install beta.13 already runs
on quit, and it left the version, the uninstall entry and the profile
correct in the Windows test of #615. The AppImage, deb, rpm and pacman
updaters never read isSilent, and the flag they do read, isForceRunAfter, is
true before and after, so Linux is unchanged. The macOS updater takes no
arguments.

This cannot help the update from beta.13 to 1.7.0, which runs beta.13's own
code. Windows beta players will still meet the wizard once.

Fixes #668
Deleting a VS Version that one Installation still runs on warned "Alpha
still use this VS Version. Deleting it now means they won't launch until
you point them at another one." The sentence only had a plural form and
ListVersions rendered it without a count, so with a single name the verb
and the pronouns disagreed with it. The French one had the same flaw.

The key is now a plural family: _one and _other in en-US, plus _many in
fr-FR, which French selects for a count of a million and tests/i18n
require of any family it carries. The page passes the number of
Installations on the version as the count, the ones folded into "and N
more" included, since the label names five at most.

The other twelve locales keep their single sentence. i18next tries the
plural form and then the bare key inside the same language, so they keep
reading it until their plural forms come through Weblate, and the status
page counts each leftover as stale in the meantime.

The refused-uninstall case of describeUninstallFailure keyed to the same
sentence. Nothing shows it today, but a notification passes only the
version's name, which would have rendered the raw key of a plural family.
It keys to the warning's title now.

Fixes #660
…itself (#674)

With #672, Restart and update closes the launcher, installs the update
silently and opens the new version by itself. On Windows nothing is on
screen for 13 to 30 s in between, most of it the installer's own checks,
and a player who takes that for a crash and starts the launcher again
would land in the middle of the install. The notice that offers the
restart only said the update was ready to install.

It now says, in en-US and fr-FR, that RiftLauncher closes and opens again
by itself and that this can take up to half a minute. The AppImage, deb,
rpm and pacman updaters also close the launcher and start it again, only
faster, so the sentence is true on Linux too. It is a second sentence in
the same string, notifications.body.updateDownloaded: the banner and the
Activity Center both draw the body as one wrapping paragraph, so no key
was added. The other twelve languages keep their current sentence until
Weblate updates them.

Fixes #673
In the export picker, files a modpack cannot carry (names Windows refuses,
invisible Unicode characters, or names colliding in letter case) and symbolic
links are shown with explanations under their names. Disabled rows prevent
selecting names Windows refuses or names containing hidden characters, as well
as symlinks and linked directories. Colliding names start unticked but remain
selectable. When no configs are selected, the dialog displays a zero sentence
and disables the export button.

Move key validation, collision diagnosis, and hidden character utilities
to a shared domain module re-exported by the IPC handler. In the export
dialog, render dedicated disabled rows for links and hidden-character files,
spelling invisible Unicode code points with hex tags. Update export refusal
notifications in en-US and fr-FR to explicitly name unticking the row as an
alternative to renaming or stopping, and update translation status.

Fixes #587
…es (#658)

* Give mod detail lookups catalog timeout and track update check failures

Opening the Mods page shortly after launch triggers the catalog download while the deferred update check runs mod detail lookups concurrently. The generic 15s API timeout cut mod detail lookups starved behind the catalog download on slow connections, and failed lookups were collapsed into not-found, causing GlobalModUpdateChecker to falsely record the installation as notified with an undercounted update total.

Give /api/mod lookups the catalog 90s timeout allowance in API_URL_RULES, distinguish failed lookups from missing mods in useGetCompleteInstalledMods, and refrain from dispatching ADD_NOTIFIED_MOD_UPDATE in GlobalModUpdateChecker when lookups fail. Also update the stale unmount comment on the folded state in useModSuggestions.

* Address review feedback for issue 618

Drop the action button from the notification when zero updates are confirmed with failed lookups, allowing the notification to timeout and fold into duplicate notifications instead of persisting indefinitely. Add updatesCheckFailedSome localization strings for en-US and fr-FR, update translation status, refine the timeout comment in validation.ts, and add a test verifying that /api/mod/123 cuts off at 15s when headers arrive immediately and no body data follows.
…659)

* fix(worlds): allow deleting world backups and enforce backup limits

World backups had no deletion mechanism from the launcher UI and lacked
eviction limits, causing backup counts and disk space to grow without bound.

- Add deleteWorldBackup and removeWorldBackupRecord in worldsHandlers
- Expose deleteBackup via worldsManager preload bridge
- Add individual backup delete button with confirmation dialog in ManageInstallationWorlds
- Prune world backups exceeding backupsLimit and clean up missing archive files
- Cap worldBackups at 100 in configManager normalization
- Return deletedBackupIds in WorldBackupResult so renderer UI state stays synchronized
- Add comprehensive unit and DOM tests for deletion and eviction limits
- Fixes #620

* fix(worlds): address review feedback on deletion safety, domain pruning, and limit warnings

- Enforce safe world backup deletion paths via removeWorldBackupArchive helper validating UUID format, archive filename match on both prune and delete routes, Worlds directory, and exclusion of Installations subfolder

- Vet deletion target using assertManagedDeletionPath and remove the verified safe path

- Always report deletedBackupIds from pruning to ensure renderer UI state stays synchronized

- Skip offer to back up world before delete when backupsLimit is 0

- Fix Sonar smell in addWorldBackupRecord by checking presence via some

- Regenerate docs/contribute/translation-status.md

- Add tests covering UUID validation, path mismatch refusal, Installation folder exclusion, and prune protection against foreign world archives

* fix(worlds): remove redundant installations check in removeWorldBackupArchive
Pixnop and others added 22 commits October 12, 2026 17:54
)

The checkbox lived in page state and only the confirm path cleared it. Cancel, Escape
and a click outside the dialog all go through the dialog's close, which only cleared the
selected Installation. A player who ticked the box for one Installation and backed out
found it still ticked on the next Installation they deleted, and confirming wiped that
folder and every backup with it.

Close now goes through one helper that clears both, and the confirm path uses it too.
The tests cover Cancel, Escape, a click outside and a confirmed delete.

Fixes #706

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…us it on a second start (#720)

The saved window position went straight to setPosition. When it was
saved on a monitor that is no longer connected, Windows leaves the
window there, so the player saw a taskbar button and no window. A
second start hit the single instance lock and exited, and nothing
listened for second-instance, so the running window was never told to
come forward.

The saved position is now checked against the work areas of the
connected displays (its title bar has to be reachable) and falls back
to the center of the primary display. A second-instance handler
restores, repositions if needed, and focuses the running window.
Positions on a connected display, the saved size and the maximized
state are unchanged.

Fixes #709

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…after the launcher's (#722)

Every Installation has its own clientsettings.json, but sessionToAdopt took any
sessionkey that differed from the store's as newer. The Installation launched
least recently always holds an older key, so launching it replaced the store's
live key with a retired one, and a login made in the launcher was thrown away
the same way. With two or more Installations and one login elsewhere, every
switch handed the game a key it had to prompt for, and never settled.

The stored session now records when it last changed (set at login, and to the
file's modification time when a game-written key is adopted), and a key in a
settings file counts only if the file was written after that. A session stored
before the date existed keeps the old rule until the next login or adoption.

That alone still leaves the store one launch behind, because the game's own
prompt retires the keys in every other Installation and the new key is only
seen at the next launch of the same Installation. So the key the game saved is
also adopted when the game closes, unless it is the key the game started with.

When the game closes, the launcher's session is compared with the one the game
was handed. If a login was made in the launcher meanwhile, it is written into
the settings file and nothing is adopted: the game saves its settings as it
closes, which makes the file younger than the login, and without this the next
launch of that Installation would take the file for the newer one and throw
the login away. An account removed while the game ran has no stored session,
so nothing the game saved is stored back under its uid.

Fixes #712

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…723)

A restore of Installation B runs in the renderer and only took the close guard, so the main process never knew B was being replaced. A world moved from A into B meanwhile was copied into B's live Saves folder and deleted from A, then went into the set-aside folder when the restore swapped it out, and both operations reported success. A backup of B had the same gap and could archive a half-copied world file. Play and Join were not refused during either operation for the same reason.

Restore and backup now reserve the Installation in the main process, through a small acquire and release channel, with the same lease the world handlers take. It is held for the whole run and given back in the existing finally, so the extract, set aside and swap order and the close guard are untouched. The acquire channel accepts any Installation id the config accepts (1 to 128 characters, no NUL), since a hand-edited id outside the usual alphabet must still be backed up and restored. A world transfer into or out of the Installation answers busy, a restore or backup answers busy or playing when a world operation or a game holds the Installation, and a launch is refused while the lease is held.

Fixes #715

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…ls (#725)

A world backup that fails at the config write answers "operation failed"
and removes the archive, but saveConfig keeps what it was given in memory
whether or not the write worked. The record then rode along with the next
save, a window move or any renderer save, and after a restart Manage
Worlds listed a backup that no longer existed. Deleting that world then
skipped the offer to back it up first, and restoring the backup answered
archive-not-found.

addWorldBackupRecord now takes its own record back out of the cached
config when the save fails, and writes what is left. The write matters
when the record's config was coalesced with a later one in the same write
loop: the first config (record included) reaches the disk, the next one
fails, and every awaiter gets false. The cache is clean by then, so
without that write nothing would repair config.json before the player
quits. It is best effort: a read-only session refuses it, and a failure
leaves things no worse than before. saveConfig itself is unchanged: a
read-only session and the callers that read, change and save the config
rely on the cache holding the latest change, and a write that is still
coalescing with a concurrent save must not be hidden from a reader in
between. Other changes the cache holds are left alone.

Deleting a recorded backup had the mirror image of the same bug: the
archive was removed first, then a failed record write answered a failure,
so the renderer kept listing a backup that was already gone, and deleting
its world skipped the backup offer. Once the archive is gone the delete
now answers ok whatever the record write does. Main's copy has already
dropped the record, so the renderer save that follows writes the clean
list to disk.

Fixes #711

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…stalled (#721)

The row for a VS Version is saved before its download starts, and the only
flag that said "still installing" was never written to config.json. A crash,
power cut, kill or the launcher's own update restart during the install left
a plain installed version pointing at an empty or half copied folder. Play
then failed, and the reinstall the error asked for was refused as already
installed.

Game versions now carry an incomplete flag that is saved with the row and
cleared only when the install finishes. It is written only while set, so no
schema bump is needed, and a linked version never keeps it. A row that still
has it at the next start is shown as an interrupted install, Play refuses it
with a message that says to remove it, and the Optimum actions stay off.

Removing it has to work in every state it can be found in. The target folder
is only created once the first download response arrives, so an install cut
off before that leaves a row with no folder, and the host refuses to delete a
path it cannot find. Uninstalling an unfinished install now checks for the
folder first and drops the row when there is none; a folder that is there is
deleted as before, and a finished install is untouched.

Reinstalling is reachable from the page too. The default folder suggestion no
longer counts the interrupted row of the selected version as taken, so it
lands on that folder and Install reuses the row, keeps its id so Installations
stay linked, and keeps it on the list if that attempt fails too.

Fixes #710

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
* Keep the installed Mod until its replacement has downloaded

installMod deleted the old archive first and downloaded the new one
after, so a download that failed or timed out left the Mod gone from the
Mods folder while the summary and the toast said it was left as it was.
Update all started every deletion at once and only then queued the
downloads three at a time, so a dropped connection or a crash could
empty the folder of every Mod that had not finished. The single update,
the Mod page and a modpack import over an installed copy go through the
same function.

The new archive is now fetched first and the old one is removed only
once it is on disk. If the old one then refuses to go, the new one is
taken back out and the install stops with old-version-delete-failed, so
the folder still never holds two versions of one Mod. If the take-back
fails too, the install stops with its own outcome and message, since the
player then has both versions and has to remove one. Update all and the
modpack import, which keep per-Mod toasts off, name those Mods in their
summary, and Update all counts them in its own message instead of
calling them left as they were. Whether the new archive landed on the
old one's own path is judged against where the download says it landed,
the form the scan reads, so a spelling that differs cannot delete the
file that was just written. A path that differs only by letter case
keeps the previous order, since on a case-insensitive volume it is the
same file and a delete after the download would remove the new one.

Keeping the old archive during the download makes it visible: it stays
listed with its buttons live until the swap. Every install of a Mod now
claims that Mod's write key in useInstallMod, which every caller goes
through, so the Mod page and the modpack import are covered as well as
the row and the card, and a second install of a Mod being written is
refused. A copy counts as busy while its Mod's key is held, on the
browse card and on the Manage Mods row, and toggling, deleting or batch-
changing it is refused meanwhile, because a rename or a delete under the
swap makes the delete after the download fail and the new archive get
taken back out.

Fixes #703

* chore(l10n): update translation status table

---------

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…ated build (#727)

* Stop Remove and Update Optimum from writing older backups over an updated build

A linked official install can be updated in place after Optimum was added. The
installer rewrites the game and leaves .optimum/ where it was, so the backups
there belong to the older build. Remove Optimum copied them over the new
assemblies and deleted the only copies, and Update Optimum did the same by a
second road: the patch takes its source from .optimum/vanilla whenever it
exists, so it patched the older build and recorded hashes that made the result
look intended.

Both now classify the live assemblies first (readLiveBuild). One is the
patch's when it hashes to the patchedHash in .optimum/manifest.json, equals its
backup, is gone, or is a managed image that references an Optimum assembly;
the last is what a killed Update Optimum leaves, a newer overlay's file under
the older record. A managed image with no Optimum reference is a build the
patch never saw.

Remove Optimum on such a folder copies nothing, drops .optimum/ and the Optimum
root assemblies, clears the row and says the files were already original, so
the refusal is no dead end. A mix of patched and updated files, or a file that
is not a managed image, is refused with build-unrecognized and a sentence that
says to reinstall the VS Version and remove Optimum again. Add or Update
Optimum refuses with build-changed before it runs the CLI and says to remove
Optimum first. A failed patch rolling itself back keeps restoring without the
check, since its live files may be half written.

The restore also copies each backup to a sibling file and renames it over the
live one, creating its folder first so a game folder with no Mods/ still
restores. A launcher stopped mid-copy, or a copy that fails and has the live
file unlinked, leaves whole files and a second attempt finishes the job.

Fixes #714

* chore(l10n): update translation status table

---------

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…ked up or restored (#718)

With automatic backups off, which is the default, nothing stopped the game from
starting during a backup or a restore. A restore swaps the extracted backup into
place and deletes the old folder, so the play session was written to files that
no longer existed and was lost on exit (on Windows the swap failed instead). A
backup read the save while the game wrote it and could hold a torn save.

launchGame now refuses with the existing backup and restore messages when
_backuping or _restoringBackup is set, which covers Play and Join. Manage Worlds
had the same gap: world moves and copies now stay disabled while either
Installation of the transfer is playing, backing up or restoring, and deleting
or restoring a world is disabled while its own Installation is backing up or
restoring. Backing a world up only reads, so it is left alone.

Fixes #705

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
… folder (#726)

Restoring an Installation sets its whole folder aside and deletes it, and
deleting one with its data removes the folder the same way. Nothing stopped
a player from placing a second Installation inside the first, or from
pointing the Backups or Installations folder inside one, so the restore or
the delete also erased the inner Installation and every backup archive, and
still reported success. On macOS, two Installations whose names differ only
by case shared one folder for the same reason.

Creating an Installation now refuses a folder that is inside, around or the
same as the Backups folder, another Installation or a VS Version, and one
that is or holds the Installations or VS Versions folder from Config (inside
the Installations folder stays allowed, it is the default layout). The
Config folder pickers refuse a folder inside an Installation, and a Backups
folder around one. Folder comparison is case-insensitive on macOS as it
already was on Windows.

The host's deletion policy refuses a path that holds a protected folder,
another Installation or a VS Version, and a path that more than one config
entry points at (duplicates from an older or hand-edited config). It also
compares each managed folder where it really lives, so one reached through a
symbolic link is seen. A restore or a delete of an outer Installation stops
before anything moves.

A config that already has nested folders is left as it is: nothing is
rewritten or moved. Restoring or deleting the data of the outer Installation
now says which folder it holds, before any archive is extracted, and asks
the player to move that folder or remove it from the launcher first.

Fixes #713

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
* Refuse Installation folders that sit inside or around another managed folder

Restoring an Installation sets its whole folder aside and deletes it, and
deleting one with its data removes the folder the same way. Nothing stopped
a player from placing a second Installation inside the first, or from
pointing the Backups or Installations folder inside one, so the restore or
the delete also erased the inner Installation and every backup archive, and
still reported success. On macOS, two Installations whose names differ only
by case shared one folder for the same reason.

Creating an Installation now refuses a folder that is inside, around or the
same as the Backups folder, another Installation or a VS Version, and one
that is or holds the Installations or VS Versions folder from Config (inside
the Installations folder stays allowed, it is the default layout). The
Config folder pickers refuse a folder inside an Installation, and a Backups
folder around one. Folder comparison is case-insensitive on macOS as it
already was on Windows.

The host's deletion policy refuses a path that holds a protected folder,
another Installation or a VS Version, and a path that more than one config
entry points at (duplicates from an older or hand-edited config). It also
compares each managed folder where it really lives, so one reached through a
symbolic link is seen. A restore or a delete of an outer Installation stops
before anything moves.

A config that already has nested folders is left as it is: nothing is
rewritten or moved. Restoring or deleting the data of the outer Installation
now says which folder it holds, before any archive is extracted, and asks
the player to move that folder or remove it from the launcher first.

Fixes #713

* Keep the player's own files when a VS Version is uninstalled

Add VS Version let a player Browse to a folder that already held
other things (a games folder, Documents, a folder with an Installation
in it), warned in a toast, and installed the game straight into it.
Uninstalling that version later removed the whole folder and reported
success, so the other files and any Installation kept inside it with
its worlds were gone. On Linux the install also set 0755 on every file
in the folder.

Browse now refuses a folder that is not empty for a VS Version, with
an error that says why. The folder suggested by default is untouched.
The Installation folder picker keeps its warning.

The host's delete policy already refuses a folder that holds a managed
folder (the change that refuses nested Installation folders, #713), so a
version registered on such a folder could never be removed: every retry
failed with a generic error. The VS Versions list now asks the same
question first, through the same helper and the same comparison, and a
version whose folder holds an Installation, another VS Version or a
configured folder leaves the list with its folder kept. The dialogs and
the success message say why. A test pins the list and the policy to the
same answer on ten layouts.

Config schema 9 stamps linked, again, on the versions outside the
default versions folder. Beta builds stored the versions installed
through Browse unlinked, and a folder with loose files but nothing
protected in it was still deleted whole. The launcher's own installs
are spared where the path shows them: a version below a folder named
RiftLauncherGameVersions (or the older VSLGameVersions) stays unlinked,
so the versions installed through the default suggestion keep being
deleted on uninstall after Settings changed the versions folder (it
moves nothing) or portable mode rewrote it. Deliberate trade-off: a
version the launcher installed into an empty folder of its own, under
a root moved in Settings to a folder with another name, is stamped
too and is only removed from the list from then on. The launcher
cannot tell it from a registered folder, a folder left behind costs
disk space, and a folder deleted by mistake costs the player's files.

The uninstall dialog said the version could be downloaded again and
nothing else. It now names the folder it deletes and says everything
in it goes. The old string is retired from the 14 locale files and
the new ones are written in en-US and fr-FR only.

Fixes #707

* Fix platform detection in pathPolicy version deletion test

---------

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
…hes (#694)

* Set an unreadable clientsettings.json aside instead of blocking launches

A power cut can leave an Installation's clientsettings.json as something
that is not JSON. Every launch after that stopped on it with "Couldn't
save your login to this installation. Try logging in again.", which
logging in cannot fix. The launcher refused on purpose, so that nobody's
game settings are written over, but the refusal outlived its cause and
was blamed on the login.

The file is now moved aside in its own folder as
clientsettings.unreadable-<UTC time>.json, whole and never deleted, and
the launch carries on as it would with no file: the session goes into a
fresh one, or, with no session of our own to write, nothing is written.
The move goes through the same path policy as the rest of the launch,
never overwrites a name that is taken, and takes its name from the same
helper as the config's own unreadable copy. When the file cannot be moved
either, the launch is still refused, now with a reason and a sentence of
its own that say the file cannot be read or moved aside and that renaming
it lets the Installation launch again.

The launch result carries the kept file's name, and the launcher shows
one info notification for it once the game closes. The log says that a
file was set aside, and never what it held.

Fixes #691

* Tell the player the set-aside settings file name on failed launches too

The launcher moves an unreadable clientsettings.json aside before it
does anything else with the launch, and the new name is generated, so
the answer to the launch is the only place it is ever told. It rode on
the exit alone: a launch that then failed to spawn, found no .NET
runtime, found the Installation busy or could not write the session
into the fresh file left the kept file in the Installation's folder and
the player with no way to learn what it was called. The renderer also
only showed the notice for an ok result.

Every result a launch can give after the move now carries the name, and
the renderer shows the info notice whenever the name is there, apart
from the notice for how the launch ended. The sentence no longer says
the game started, since it may not have.

Part of #691

* Wait for config load in useInstallVersion tests

Waiting on zero game versions succeeded before getConfig finished populating state, leaving isConfigLoaded false and dropping saveConfig calls under heavy load.

---------

Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
* fix(config): retry transient save renames

Serialize per-path retries so an older save cannot overwrite a newer one. Keep the config failure notice visible until dismissed.

Fixes #678

* Advance timers twice in config save sticky notice test

A single advance keeps the sticky notice render batched in act(), allowing an 8-second default duration to pass unnoticed. Advancing across [8_001, 30_000] ensures the test fails if duration: null is removed.

* Regenerate translation status
* Try a failed VS Launcher import again at the next start

A player coming from VS Launcher whose first-start copy failed once, for
example on an EPERM from an antivirus scanner holding the freshly copied
files, got an empty RiftLauncher folder that every later start accepted as
already migrated. The import never ran again, nothing on screen said it had
failed, and the only way out was deleting the folder by hand.

The final rename of the copied folder now waits out EPERM, EACCES and EBUSY
for up to three seconds in all, whatever the number of entries moved, in the
default and the portable setup alike. When the import still fails, a marker
file is left in the empty folder and planUserDataMigration treats a marked
folder as not migrated yet, so the next start imports into it. The retry moves
Icons first and config.json last, so a retry that dies half way never leaves
VS Launcher's config.json next to a missing Icons folder. The marker goes away
once an import succeeds and at the first config save that holds anything the
player chose, so a retry never overwrites their work. A folder without the
marker still wins over VS Launcher's. A start whose import failed now shows a
notice saying so and that the import will be tried again as long as nothing
was changed; the startup log line says it too.

A portable profile set up after a failed default import no longer copies the
empty marked profile: it imports VS Launcher's folder instead. When that import
fails too, it does not stop the launcher either: the portable folder starts
empty with the same marker and notice, and the next start imports into it. A
portable copy that fails with no failed default import behind it keeps the
behaviour #675 chose: it stops the launcher with an error naming the reason,
creates no profile, and retries on the next start. In short, a failed import on
a normal start, or in portable mode behind a failed default import, starts on an
empty profile and is tried again; any other failed portable import stops the
start with the reason. The marker is removed before the save that holds a
player's choice is written, and put back if that write fails.

Removing a temporary copy folder is best effort everywhere. fs-extra's
removeSync does not wait out a lock, and the same scanner handle that fails the
rename would make it throw too and stop the launcher. A folder that cannot be
removed is reported in the startup log and cleaned at the next start, and no
copy is ever made into a path that still holds one: that start counts as a
failed import.

The README and the Linux installation page describe both modes, and the
Windows portable paragraph describes the portable exception.

Fixes #708

* Skip the foreign-owner marked folder test on Windows

The test mocks process.getuid, which does not exist on Windows, so Vitest throws there. The ownership check is a no-op on Windows by design, matching the other foreign-owner tests in this file that are already skipped.

* Decide the marked-folder import from the config.json found at start

A start imported into a marked folder as soon as the marker was there, and relied on the save that holds a player's choice to remove it first. Two gaps followed. The ModDB field was treated as filled in by the first start, so answering the prompt or changing the policy on the Config page kept the marker, and the next start replaced config.json with VS Launcher's copy, which could also restore a consent the player had just revoked. And a marker that could not be removed (a scanner holding it open on Windows) survived a successful write of config.json, with the same result.

The marker now only says that an import may be pending. At start the folder counts as pending when its config.json is absent or holds nothing the player chose, with one predicate shared by the save path and the start path. A marker next to a config.json that holds a choice is stale: the profile is kept and the marker is removed on a best-effort basis, so removing it at save time no longer decides anything. A config.json that cannot be read as a config (invalid or truncated JSON, not an object, not a readable file) is never imported over and keeps the marker for a later start. The portable setup uses the same check for a marked default folder and for a marked portable folder.

The ModDB field is no longer exempt, since nothing writes it before the player does. The window position, the changelog version stamp and the schema version stay exempt.

* Carry the ModDB answer across the import retry instead of ending it

Counting the ModDB answer as use of the profile ended the retry for almost everyone: the prompt shows by itself on the first launch of a version with no answer, so on the empty profile a failed import leaves it appears at once and nearly every player answers it. The next start then kept that empty profile and VS Launcher's data was never imported.

The ModDB field is exempt again from the player-choice predicate, with the window, the changelog stamp and the schema version, so an answer or a policy change on the Config page no longer ends the retry or removes the marker at save time. The answer is neither lost nor reversed: when the retry imports into a marked folder, the folder's current answer is written into the temporary copy of VS Launcher's config.json before anything is moved into place, whatever that file holds for the field or whether it has the field at all. A default answer leaves VS Launcher's file byte for byte. If the answer cannot be read or carried (unreadable current config.json, a VS Launcher config that is not an object, a patched document past the 16 MiB limit, a failed write), the import fails for that start with the marker kept and the profile untouched, and the next start tries again. The portable setup carries it for a marked portable folder and for the marked default folder it replaces.

The removal of the marker after a successful import is best effort, so a scanner holding it open no longer turns a finished import into a reported failure; the next start reads it as stale and drops it. The documentation comment of the start-time marker check now sits above its function.

* Replace the marked folder's config.json in place so a failed rename cannot lose the ModDB answer

fs-extra's overwrite removes the destination before it renames. With a rename that never works inside the 3 second budget, or a crash in between, the marked folder's own config.json, which holds the player's ModDB answer, was already gone: the next start read it as absent and imported VS Launcher's value. The config.json entry now renames over the existing file inside the same retry loop and budget, so the folder keeps its file until the new one is in place. Icons keep the overwrite move.

* Remove the failed-import marker only after the chosen config reaches disk

The marker was removed before the atomic write of config.json. A launcher
that exited before the rename left the untouched default config with no
marker, so the next start skipped the VS Launcher import for good.

Write config.json first, then remove the marker on a best-effort basis.
Startup already reads a marker next to a config that holds a choice as
stale, so a removal that fails cannot cost the saved choice. The restore
step that only existed for the old order is gone.
Uninstall only skipped the delete for a missing folder when the row was an
interrupted install. A finished row went on to ask the host to delete it,
and the host refuses a path that does not exist, so the uninstall failed on
every try and the row stayed listed for good. The folder can be gone because
the player deleted it by hand, because its drive is unplugged, because a
VS Launcher import listed folders that were already gone, or because the
launcher was killed after the delete and before the list was saved.

The folder check now runs for every row that is not linked: a missing folder
drops the row without calling the host, and a check that throws still counts
as present so the delete answers for it. The host's deletion policy is
unchanged, and a folder that is there but cannot be deleted still fails and
keeps its row.

Fixes #732
…cher still lists (#741)

#726 made the host and the Installations and Backups pages refuse a
restore or a delete with data when the Installation's folder holds
another Installation, a VS Version or one of the three Config folders.
The refusal told the player to move that folder elsewhere or to remove
it from the launcher. Both ways out still lost data.

Moving the Backups setting out of an Installation is accepted, but the
archives already made stay in the old folder and the config still lists
them, for that Installation and for every other one. Neither check looked
at the recorded archive paths, so the next restore or delete with data
went through, reported success, and took those archives with it. A listed
backup of another Installation then failed with a missing file.
Removing an Installation that sits inside another one only keeps its
files in place, so the next restore of the outer one erased its worlds.

assertManagedDeletionPath and findHeldManagedFolder now count every
recorded backup and world backup archive path as a managed entry that the
folder being wiped must not hold. The archive itself can still be deleted
or moved, two records on one path do not trip the shared-entry check, and
the #726 refusals are unchanged. A VS Version folder holding a listed
archive is kept too, on both sides.

A delete with data does not count the Installation's own archives: they
leave with it and their records go with the entry. DELETE_PATH passes
ownArchivesLeave and the delete page ignoreOwnArchives. A restore keeps
the records and wipes the folder, so MOVE_PATH and the restore page still
count them.

What the folder holds is reported in one order: the three Config folders,
then every listed archive, then the other Installations, then the VS
Versions, so each archive is dealt with before any entry is removed (the
launcher forgets an Installation's archives when its entry goes).
describeHeldFolder returns the kind with the path, and each kind has one
short message for restore and for delete in en-US and fr-FR: change the
Config setting, copy then delete the listed backup, move the Installation
out and add it again (the message says its backups and world backups are
forgotten, so copy any first), remove the entry of the Installation that
shares the folder, uninstall an installed VS Version first and never move
its folder by hand, for a VS Version on the Installation's own
folder (linked or not) remove it from the VS Versions page first, and for a
linked VS Version inside the folder (whose folder an uninstall leaves in
place) move the folder out, remove it from the list and add it again with the
Add an already installed VS Version button from the new folder. An Installation
held in the folder is named as well as its folder. The notice stays up, as it carries steps. The
unused restoreHoldsManagedFolder and deleteHoldsManagedFolder keys are
removed from en-US and fr-FR only.

Fixes #733
* chore(l10n): update Portuguese (Brazil) translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
Language: Portuguese (Brazil)
Progress: 46.2% (417 of 902 strings)
Translate-URL: https://hosted.weblate.org/projects/riftlauncher/launcher/pt_BR/
Translation: RiftLauncher/Launcher

* chore(l10n): update Russian translation

chore(l10n): update Russian translation

chore(l10n): update Russian translation

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: tehtelev <tehtelev@users.noreply.hosted.weblate.org>
Language: Russian
Progress: 26.9% (243 of 902 strings)
Progress: 28.6% (258 of 902 strings)
Progress: 29.1% (263 of 903 strings)
Translate-URL: https://hosted.weblate.org/projects/riftlauncher/launcher/ru/
Translation: RiftLauncher/Launcher

---------

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
Co-authored-by: tehtelev <tehtelev@users.noreply.hosted.weblate.org>
A folder the player picks is trusted for ten minutes only. A VS Version
added from Look for a VS Version after that was listed and announced as
added, but main refused the save with unauthorized-path. The renderer
saves its whole state on every change and reverted nothing, so the
refused row rode along in every later save and got each of them refused
too: Installations, logins, favourite Mods. All of it was gone at the
next start, and the notice sent the player to the Config page, which did
not help.

Main now names the refused paths in the unauthorized-path answer. The
config provider drops those entries from its state (a VS Version, an
Installation, a backup, or sends a refused folder setting back to the one
main holds) and tells the player which ones, so the next save goes
through. A late refusal for entries already dropped is not counted as a
failing save. A refusal that names nothing the state holds keeps the old
save health notice.

Add in Look for a VS Version asks main about the folder again before it
registers anything, and sends the player back to pick it when the trust
has run out. It also waits for the save before saying the version was
added, and says so when the save did not go through, taking the row back out so
that Add works again for the same folder. Main still refuses a
path the player never picked.

Fixes #731
#755)

Removing a linked VS Version from the list showed the sentence about a folder that holds Installations, other VS Versions or launcher folders, although the folder was only registered and left alone. The page treats a linked version as one that keeps its folder, which is right for the dialogs, but the hook picked the "holds others" toast whenever that flag was set, so the "left alone" toast could never appear.

The hook now gives the "left alone" sentence to a linked version, whatever its folder holds, the same order the delete dialog already uses. An installed version whose folder really holds other managed folders keeps the "holds others" sentence, and a plain uninstall is unchanged.

Regression from #728.
* Keep the native LZMA2 addon loaded on the main thread

The packaged Windows conformance run 38941539698 died right after "Extracted 20084 files" with exit code 0xC0000005. The Windows Application log named the faulting module as lzma.win32-x64-msvc.node_unloaded, fault offset 0xe5494. About one packaged run in four has ended this way since beta.11.

The RUN_INSTALLER worker is the only place @napi-rs/lzma is loaded, and the pool terminates it as soon as the extraction finishes (maxIdle is 0). Node unloads an addon loaded only by a worker when that worker's environment goes away. @napi-rs/lzma 1.5.1 is built with napi 3.10.5, which shuts its tokio runtime down in the background on the last unload, so the runtime threads are still running inside the DLL when FreeLibrary unmaps it. That is napi-rs/napi-rs#3443, fixed in napi 3.12.1 but not yet in any @napi-rs/lzma release. Every Inno install on Windows is exposed, and about one in four crashes.

extractInstallerPayload now awaits the existing cached loader before it acquires the worker. Node never closes an addon loaded on the main thread, so the library stays mapped and the runtime stays alive. The loader already swallows a missing binary, so the TypeScript decoder fallback is unchanged.

Tests: a child-process fixture runs 100 worker load, decode and terminate cycles and must exit 0 with the main-thread load; a Windows-only detector asserts the same run crashes without it. A unit test pins that the load completes before acquireWorker is called. Linux never unmaps the library, so only the Windows CI legs can fail the cycle test.

* Drop the Windows test that waits for the unload crash

CI run 38948085097 ran it on both Windows legs: without the main-thread load the cycle script crashed, with it the script survived 100 cycles. That shows the cycle test can see the crash. Kept, it would wait for a crash that only happens by chance, so it would make CI flaky.
It writes and walks 2001 files and timed out at the 5 s default on the windows-latest Node 26 leg of CI run 38948085097, unrelated to the change under test there.
@Zaldaryon
Zaldaryon requested a review from Pixnop October 16, 2026 22:41
@github-actions

Copy link
Copy Markdown

Translation status

en-US is the source and carries 957 keys.

Locale Keys Missing Stale To review on Weblate
be-BY 882 112 0 Needs editing
de-DE 855 102 0 Needs editing
es-ES 882 112 0 Needs editing
fr-FR 994 0 0 Needs editing
hu-HU 855 102 0 Needs editing
it-IT 882 112 0 Needs editing
nl-NL 855 102 0 Needs editing
pl-PL 882 112 0 Needs editing
pt-BR 883 111 0 Needs editing
pt-PT 882 112 0 Needs editing
ru-RU 972 24 2 Needs editing
uk-UA 882 112 0 Needs editing
zh-CN 828 92 0 Needs editing

The links open the strings Weblate marks Needs editing for each language: the machine drafts made before Weblate, plus any string whose English changed since it was translated. See #496.

@Pixnop Pixnop left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for opening it. The head, 2def7fe, is exactly the commit we tested and pinned as release/1.7.0-beta.14:

  • Full local test of the packaged build (79 scenarios in 9 zones) on tree e253c3e9, which is this head minus #755, #756 and #759; then a delta pass on 2def7fe itself (gates, a real 1.22.7 install, the #755 toast, the AppImage).
  • Merging this into main changes nothing the app ships: against the tested dev tree, the merge result only adds .github/workflows/discord-prs.yml and moves brace-expansion, a dev-only dependency, to main's versions.

Two things are still running before it can merge, so please hold the merge for now:

  1. Packaged Windows conformance, ten green runs in a row on 2def7fe, since #756 fixes a crash that hit about one run in four. Two passed so far. One failed on the runner's connection to the game catalog (connect ETIMEDOUT before any install, no crash event), so the count restarted.
  2. A manual pass on Windows: native pickers, two Installations with one login, a damaged clientsettings.json, monitors, portable profile.

When both are done, it merges with a merge commit pinned to this head (--match-head-commit 2def7fed65f622a0abe4900ef72021375931f5b3), so anything that lands on dev in between waits for the next one. Then main's CI on the merge commit, then the tag.

One nit on the description: its summary paragraph lists portable profiles and mod configs in modpacks, which shipped in beta.13. Everything since beta.13 is fixes, as the release notes say.

@Zaldaryon
Zaldaryon marked this pull request as ready for review October 16, 2026 22:53
@Zaldaryon

Copy link
Copy Markdown
Collaborator Author

Thanks Leon. The description is updated to remove mentions of portable profiles and mod configs in modpacks, keeping the summary focused on the fixes and stability work across configs, installs, translations, and file operations. Holding the merge until your 10 Windows conformance runs and manual pass finish.

@Pixnop Pixnop left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both conditions are met on 2def7fe. Packaged Windows conformance: 14 runs, 12 full installs of 1.22.7 with no crash event, and the two failures were the runner timing out on api.vintagestory.at before the launcher started. A manual pass on Windows is done and clean. Merging with a merge commit pinned to this head.

@Pixnop
Pixnop merged commit 26cd25e into main Oct 16, 2026
39 of 41 checks passed
@Pixnop

Pixnop commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@Zaldaryon the v1.7.0-beta.14 release was back to a draft with different notes; I think it went through the release page's "Save draft" button while editing, which unpublishes it. As a draft its files return 404, so beta players' update checks were failing. I republished it with the notes we checked against all 68 PRs (they keep the beta.13 Windows wizard instruction and the known issues: Optimum 0.3.20 and #735, and the ModDB timeout claim is narrower because #750 is still open). The text you wrote is kept in our notes folder. If you want to change something in them, tell me and I'll edit them, so they stay published.

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.

2 participants