Skip to content

Promote dev to main for v1.7.0-beta.3 - #217

Merged
Pixnop merged 19 commits into
mainfrom
dev
Aug 22, 2026
Merged

Promote dev to main for v1.7.0-beta.3#217
Pixnop merged 19 commits into
mainfrom
dev

Conversation

@Pixnop

@Pixnop Pixnop commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Everything merged since the beta.2 promotion, aligned onto main for the third prerelease.

Field reports answered: custom icons name their refusal instead of failing silently (twelve refusal points had no message and no log line between them), installations can be reordered, and the launcher no longer overwrites the session the game refreshed, which was turning a single invalidated login into a prompt on every launch. Requested features: per-mod update suspension, the data folder following the typed installation name, and the background selector with its repository-hosted catalog plus a custom image option. Reliability: consent before an update downloads with a real progress bar, the install popup failing honestly instead of spinning forever, the update task completing only when the download actually lands, image validation by signature rather than file name, and the test suites no longer writing log files into the developer's home directory.

After this merges: tag v1.7.0-beta.3, let the release workflow build the artifact set, review the draft, publish as a prerelease.

Pixnop and others added 19 commits August 21, 2026 22:05
… fails to load (#188)

The install popup asked the ModDB for a Mod's releases and had no way to say
that the answer never came: a rejected query left it on the loading spinner
forever, under a description that read MOD NOT FOUND for a Mod the user had
just clicked on a list.

The versions query now reports failure the way the game version catalog
already does, with a loading/failed/retry shape, and the popup renders one
sentence and a reload button in place of the endless spinner. The description
falls back to the name the clicked row already carries, then to the ModID,
so the not-found wording cannot show up for a Mod that was picked off a list.

Refs #186
#189)

The launcher downloaded its own updates silently. autoDownload was left at
its default, so finding an update was the same event as fetching it, and the
only thing the user ever saw was a toast saying a download they never agreed
to had already happened.

Finding an update now only offers it. The toast names the version and carries
two buttons, and only the accepting one reaches the new DOWNLOAD_UPDATE
channel, which is the single path to autoUpdater.downloadUpdate anywhere in
the app. That channel refuses anything but the trusted renderer, refuses to
run before the main process has actually found an update, and refuses to
start a second download of the same one.

The accepted download reports itself. Every tick of electron-updater's
download-progress becomes a task in the task manager, which means the update
draws the same progress bar in the same list as every other download the
launcher runs, rather than a widget that only exists for this one case. The
task completes when the update lands and fails if the download dies, so the
bar always has somewhere to end.

The restart flow is untouched: the same downloaded flag, the same trusted
UPDATE_AND_RESTART channel, the same quitAndInstall.
…be retried (#190)

A toast with actions could be answered more than once. removeNotification
takes it out of the list straight away, but AnimatePresence keeps the node
on screen for the length of its exit animation and the buttons stay live
the whole way out, so a double click on "Update now" sent DOWNLOAD_UPDATE
twice and raised two "Starting download" toasts. The main process refused
the second real download, so this was cosmetic, but visible. The handler
now refuses to run twice for the same toast, synchronously on the click.

The startup update check had no catch, so launching a packaged build with
no network threw an unhandled rejection in the main process. It logs now,
and tests/security-boundaries.test.ts holds the call and its catch
together the way it already holds the other main-process properties.

A download that failed left a red task and nothing else until the launcher
was restarted, despite the previous description claiming otherwise. The
updater's error event now clears the re-entrancy guard in the handlers and
the renderer offers the same version again, so a retry is possible without
relaunching. An error that arrives before anyone accepted anything is a
failed check rather than a failed download and offers nothing.
* fix(account): carry the service's refusal reason into the login verdict and log it

* fix(account): stop reporting a network failure as invalid credentials
A player who deliberately keeps a Mod at an older version, because the newer
one is a release candidate or because updating it would touch an existing
world, had two options: update it with everything else, or never press Update
all. Suspending one Mod is the missing middle.

The suspension is a modid list in the config, same shape as favMods, and it is
read in exactly one place: the list Update all builds. The row keeps its update
notice and keeps its own update button, because watching for the new version is
the reason to suspend in the first place, and an update the player asks for by
hand leaves the suspension standing.
The name field and the data folder on the Add Installation page were
independent: you typed a name, then went and appended it to the folder
path yourself, and renaming before saving left the folder on the old
text. The Add Version page already solved this with
useVersionInstallFolder, so this is the same hook shape applied to
installations.

useInstallationFolder derives the folder from the configured
installations folder plus the cleaned-up name, re-derives on either
side changing, and stops for good once the user takes the field over.
It differs from the versions hook on one point: typing in the folder
input counts as taking it over. On the versions page the other half of
the suggestion is a version picked from a list, so it settles early and
typing can stay harmless. Here the name is free text the user keeps
editing, and a folder they typed themselves would be thrown away on the
next keystroke in the name field.

The base folder is now part of what the suggestion tracks. It arrives
empty on the first render and only fills in once the config has loaded,
so a suggestion built before that has to be rebuilt after.

cleanFolderName picks up the last two things the path layer refuses in
assertSafeFileName: names longer than 255 characters, and dot-only
segments such as "." and "..". Both come back empty now, and the
installation folder appends nothing rather than a folder named after
punctuation. The backup file names built from the same helper already
had a fallback for an empty result, so they gain the same protection.

useCleanFolderName and useDefaultInstallationPath were wrappers with no
React state in them and one consumer each, so they go away.

Co-authored-by: Pixnop <pixnop@users.noreply.github.com>
* test: rename the mods grid suite to what it actually pins

The suite mocks ModListCard and wraps its own spy in memo(), so removing memo()
from the real component leaves it green. What it does catch is a prop losing
referential stability between ListMods and the cards: adding an unstable
dependency to onSelectMod's useCallback fails it. Renamed the file and the
assertions accordingly, and pointed the comments at modListCardMemo.test.tsx
for the memo pin on the real export.

* docs(styles): correct the skip-offscreen-render comment

Table.tsx uses AnimatePresence but never useInView; only Grid.tsx imports it.
The comment credited both files with both, so it named a reason for keeping
items mounted that Table.tsx does not have.

* fix(versions): trim a hand-typed game version before registering it

A version made only of spaces is falsy to a person but truthy to the guard, so
it passed the missing-folder-or-version check and got written to the config with
its whitespace intact, where it matched nothing. Trimming before the guard also
keeps a padded version from being stored padded.
PR #169 recompressed this file but left it at 3840x2160, and the review
pointed out that the dimensions are where the memory actually is: Chromium
decodes to the intrinsic size, so every window paid for a 4K bitmap no
matter how small it was. The decoded ceiling drops from 31.6 MiB
(3840*2160*4) to 14.1 MiB (2560*1440*4).

Re-exported from the pre-#169 quality-99 original recovered from git
history rather than from the shipped quality-76 file, which avoids a
second generation of JPEG loss. Lanczos in linear light, then quality 92
with 4:4:4 chroma. Measured at 40.49 dB PSNR against an uncompressed
Lanczos downscale of that original, matching the 40.53 dB bar #169 held
itself to. File size 1,581,834 -> 1,381,147 bytes.

2560x1440 lands 1:1 on a maximized window on a 1440p panel and still has
headroom for a 1280-wide window at devicePixelRatio 2. A maximized window
on a 4K panel upscales 1.5x, which sits under the 2px backdrop-blur the
root div already applies over this image.
…182)

RUN_INSTALLER's payload read called runTrackedWorker directly while every
other archive-shaped operation went through archiveConcurrency. That let an
install start a third CPU-bound archive reader alongside two already-running
extractions or compressions, against a limit written to allow two.

The read is the same kind of work the limit describes even though it spawns
no 7-Zip: src/domain/inno decodes the installer's LZMA2 payload in plain
TypeScript and CRC32s every chunk, so it saturates a core inside its worker
thread for the whole read.

Queueing it cannot expire it. ConcurrencyLimiter.run awaits acquire() before
calling the task, and runTrackedWorker arms its WORKER_TIMEOUTS_MS bound
inside the promise it builds when called, so the clock starts on the slot and
not on the call.

The spawnInstaller fallback stays outside the limiter on purpose, documented
next to it: it waits on a real installer process rather than a decode, it only
runs after the reader already released its slot, and it resolves on every
outcome where the limiter would introduce a rejection.
The open-mods-folder action reused the update-all tooltip. Give it its own string in en-US and pt-BR.
The install popup and the installed-mods scan both map or iterate releases directly, so a detail response without the array crashed them instead of reaching their failure states.
#206)

COPY_TO_ICONS answered eight different refusals with the same bare
{ status: false } and wrote nothing to the log, so a player whose icon
was rejected got one sentence that named no reason and left no trace to
read afterwards. The renderer made it worse in the two places it awaited
the bridge without a catch: a rejected invoke travelled back up through
the popup's own async onClick, where nothing handled it, and the flow
ended with no notification at all.

The channel now returns a typed reason (unsupported-format,
source-unavailable, copy-failed) and logs the error behind it at debug.
describeAddCustomIconFailure maps those plus the two renderer-side
refusals onto their own en-US sentences, following the same shape as the
delete, uninstall and backup flows.

The extension gate was checked for case sensitivity and is already
case-insensitive at all four points that compare .png, so an icon named
ICON.PNG has never been the problem. There is an acceptance row pinning
that now.

Closes #202
… lands (#200) (#208)

The main process rounds the download percentage before it reaches the renderer,
so a tick at 99.6 percent arrived as 100 and the task listener treated that as
the download finishing. Bytes were still moving at that point, and on Windows
the last stretch overlaps the installer signature check, so the task list could
show Completed while the update was not yet on disk.

The progress listener now only ever moves the bar. update-downloaded, which
fires when electron-updater has the verified file, is the only route to the
completed state.
…ting it (#209)

The launcher wrote its stored session into an installation's
clientsettings.json on every launch. Once that stored session was
invalidated by a login somewhere else, the game prompted, the player
logged in, the game wrote a working session into the same file, and the
next launch put the dead one straight back. A prompt every launch,
forever.

writeClientSettingsSession now reads the file first, as it already did,
and steps aside when it finds a session key that is not ours on an
account whose playeruid is. That can only have come from the game
storing what the auth service accepted, so it is newer and it works. The
new "adopted" outcome carries the secrets back to EXECUTE_GAME, which
puts them in the same encrypted store LOGIN writes to.

A session belonging to a different playeruid is still overwritten:
adopting one would leave the launcher showing one account while holding
another account's credentials.

Refs #204
The background stops being a fixed asset and becomes a choice with three
answers: the scene bundled with the app, a scene downloaded from the
repository's backgrounds branch, or a picture the player supplies. The bundled
scene stays the default and the offline answer, so a first launch with no
network looks exactly as it did before.

Closes #192.
… (#212)

The Installations list renders the config array in order, with nothing
sorting it on the way to the screen, so the stored order already was the
display order. It just had no way to change it once an Installation was
added.

Each row gains an up and a down icon button dispatching a single
MOVE_INSTALLATION action that swaps the entry with its neighbour. Up is
disabled on the first row and down on the last, so neither wraps around.
ConfigProvider's existing save effect persists the new order like any
other config change.
COPY_TO_ICONS decided whether a picked file was a PNG by reading its
extension, so a JPEG renamed to .png was copied into the Icons folder and
served through the icons: protocol without anything ever looking at its
bytes. The background flow stopped doing that in #207, where isJpegBytes
confirms the SOI marker before a picture is cached or served.

isPngBytes joins isJpegBytes in src/domain/backgrounds.ts and answers the
same question about the same kind of file. The handler reads the first
eight bytes through a file handle rather than pulling the whole picture
into memory, since a custom icon has no size ceiling of its own, and a
mismatch comes back as unsupported-format, the reason #206 already gave a
file that is not a PNG.
@Pixnop
Pixnop requested a review from Zaldaryon August 22, 2026 14:18
@Pixnop
Pixnop merged commit c827a5f into main Aug 22, 2026
14 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.

2 participants