Skip to content

feat(config): let the player choose the launcher background - #207

Merged
Pixnop merged 2 commits into
devfrom
feat/background-selection
Aug 22, 2026
Merged

feat(config): let the player choose the launcher background#207
Pixnop merged 2 commits into
devfrom
feat/background-selection

Conversation

@Pixnop

@Pixnop Pixnop commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Issue #192 asks for a background the player picks rather than one the installer decides, and it is specific about where the pictures live: not inside the launcher. So there are three answers now, and the config stores which one is in force.

The bundled scene stays the default. Nothing about it changes, and a launch with no network paints it exactly the way it painted before this PR existed. The second answer is the catalog on the new backgrounds branch of this repository, eleven scenes plus a manifest.json, fetched when the settings section is opened and never at startup. The third is the player's own JPEG, copied into the same cache under a reserved id, mirroring the custom installation icons flow that already exists.

Adding a scene later is a push to backgrounds. No code, no release, no installer growth.

How a choice is applied

bg-image-vs compiles to background-image: var(--background-image-image-vs), and three elements use it: the app shell, the loader overlay, and PopupDialogPanel. So applying a background is one line setting that variable on the root element, and none of those three components learns that a choice exists.

The variable is set to two layers, the chosen scene over the bundled one. A background layer that fails to load is skipped and the layer under it paints, which is how "an id whose cached file has gone missing renders the default" costs nothing: no existence check at startup, no IPC, no flash of empty window. The section repairs the missing file the next time it is opened.

Every cached file is named after its id, so village-lane is always village-lane.jpg in the cache whatever the manifest called it on the branch. That is what lets the renderer paint a saved choice at startup knowing nothing but the id, which is the whole reason the manifest's file field never reaches the config.

One thing that needed care: the custom slot keeps one stable file name, so re-picking writes over custom.jpg and the id does not change. Without something else moving, the CSS URL is identical and the renderer keeps showing the picture that name used to hold. SET_BACKGROUND therefore bumps a session-only _backgroundRevision, which lands in the URL as ?r=N. It follows _notifiedModUpdatesInstallations: the normalizer never writes it back to disk.

The pictures

Eleven official Vintage Story screenshots from https://www.vintagestory.at/media.html, the same provenance as the background that ships today. Processed with PR #181's pipeline: linear-light Lanczos resize to 2560x1440, 4:4:4 chroma, progressive, metadata stripped.

Where I departed from #181 is the quality number. #181 fixed quality 92 for an asset that is compiled into the installer, where file size is close to free and the real cost is the decoded bitmap. These are downloaded over someone's connection instead, and #181's own matched-size experiment (4:4:4 at quality 70 beating 4:2:0 at quality 82) was run at about 675 KB, which is the band the issue asks for. So I held the pipeline and the 4:4:4 decision and let quality float to whatever lands each file at or under 700 KB. Flat scenes reached 92 and never needed less; the two busiest ones needed 71 and 72.

scene quality bytes
sunlit-highlands 86 713,596
frosted-sunrise 91 694,550
valley-ruins 72 707,932
village-lane 79 693,211
river-sailboat 84 715,410
lava-springs 71 715,283
misty-woodland 90 684,304
crystal-cavern 92 417,341
overgrown-ruin 89 653,004
collapsed-storeroom 89 687,044
workshop-desk 90 694,976

7,376,651 bytes for the whole catalog, and a player only ever downloads the ones they pick.

Nine of the eleven were already 16:9. Two were not, and both were centre-cropped rather than dropped:

  • crystal-cavern came in at 2321x1440. Cropped to 2320x1305, which takes 67 pixels off the top and the bottom. The subject is a cluster of salt crystals filling the middle of the frame and it survives untouched.
  • valley-ruins came in at 2560x1263. Cropped to 2240x1260, 160 pixels off each side. The stone pillars sit centre-right and are well clear of the cut; the pond in the bottom left loses part of its edge and still reads as a pond.

Both crops are exact 16:9 integers on purpose, so the resize to 2560x1440 is a clean scale rather than a fit that lands a pixel short. Nothing was dropped from the set.

Boundaries

Two allowlist rules for raw.githubusercontent.com, both restricted to /StratumServer/RiftLauncher/backgrounds. The manifest rule is listed first so its 32 KB ceiling wins the match, since findMatchingRule takes the first rule that covers the path; images get 2 MB, roughly triple the largest file on the branch and half the generic ceiling they would otherwise inherit. pathPrefixes matching is already segment-aware, so backgrounds-evil does not slip past backgrounds.

requestBoundedText grew a sibling rather than a flag. It now calls a new requestBoundedBuffer and decodes the result, because the utf8 decode was the only thing separating the two functions and a JPEG does not survive it. Ceiling, timeout, and the refusal to follow a redirect are shared, untouched.

The background: protocol handler mirrors createCacheModImageProtocolHandler in order and in shape: containment, extension gate, isSafeProtocolFile, then the read, then a buffered copy into the response. The one deliberate difference is the missing memory cache. Mod icon names are content-addressed so a hit can never be stale, but custom.jpg is one name whose bytes change, and a cache keyed on it would serve the old picture forever.

The scheme is registered non-standard. registerSchemesAsPrivileged replaces its list rather than appending to it, so the existing app registration moved into the same single call instead of gaining a second one beside it.

Both new IPC channels validate on the main side and never trust the id or the file name they are handed. The picker accepts JPEG only, which is what the protocol serves and what the catalog is, and is the same shape COPY_TO_ICONS has always had with PNG. A comment in the code names what changing that would cost.

Type

  • Feature

Checklist

  • Targets dev, not main.
  • npm run typecheck passes.
  • npm run lint:ci passes (0 errors, 15 pre-existing react-hooks/exhaustive-deps warnings, none in files this PR touches).
  • npm run format:check passes.
  • npm run test:coverage passes, coverage above every floor in vitest.config.ts (statements 91.82 vs 87, branches 88.83 vs 85, functions 90.73 vs 85, lines 93.41 vs 89).
  • npm run build:unpack passes.

Testing

118 test files, 1368 passing, 2 skipped, the same two skips dev already has. 39 of those tests are new: the manifest parser and id rules in tests/domain/backgrounds.test.ts, normalization rows in tests/ipc/configManager.test.ts, reducer rows in tests/renderer/configReducer.test.ts, allowlist and ceiling rows in tests/ipc/api-url-ceiling.test.ts, the two IPC channels in tests/ipc/backgroundHandlers.test.ts, the protocol handler in tests/main/backgroundProtocol.test.ts, and the settings section in tests/renderer-dom/configPageBackground.test.tsx.

Every one of them was checked by breaking the thing it guards and confirming it went red:

mutation what failed
normalization uses a plain string check instead of the id pattern "falls back to the default for anything that is not a usable id"
SET_BACKGROUND stops bumping the revision "SET_BACKGROUND bumps the revision, even when the id does not change"
image rule prefix widened to /StratumServer/RiftLauncher "refuses everything else raw.githubusercontent.com serves"
manifest rule given the image ceiling "caps the manifest far tighter than the images"
protocol drops the .jpg extension gate "rejects a real non-JPEG file on disk without serving it"
protocol drops isSafeProtocolFile three rows: the symlink swap, the direct symlink, missing files and directories
manifest entries accept the reserved ids "drops a bad row without losing the good ones beside it"
the settings section loses its failed branch the retry row and the unusable-manifest row
selection happens whether or not the download worked "selects nothing when the download fails, and says so"
the cache-busting revision is dropped from the URL four rows, including the re-pick
the opportunistic re-download is removed "re-downloads the selected scene when the section opens"

The extension gate mutation is the one worth calling out. It is the one that survived review on the mod icon handler, and the row that catches it has a real non-JPEG file sitting on disk under the requested name, readable, with nothing but the name check between it and the renderer.

Two checks outside the suite. electron-vite build emits one JPEG asset and both the stylesheet and the JS chunk that sets the variable reference the same hashed filename, so the fallback layer points at the real bundled scene and not a dead URL. And the built CSS confirms the mechanism the whole feature rests on: .bg-image-vs{background-image:var(--background-image-image-vs)}, with the theme declaring that variable on :root, which an inline style on the root element overrides.

Not covered: I did not run the packaged app and click through the section, so the CSS layer fallback and the background: scheme are argued from the built stylesheet and the handler tests rather than from having watched a scene download and paint. If a reviewer runs it, the thing worth a second pair of eyes is what the grid looks like on a first visit, since a scene only shows its picture once it has been downloaded and a first visit is therefore a grid of names. That is deliberate, it is the price of not pulling seven megabytes down the moment somebody opens their settings, and a comment in the code says the fix would be small thumbnails on the branch rather than a prefetch.

Related issues

Fixes #192

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.
@Pixnop
Pixnop requested a review from Zaldaryon August 22, 2026 11:52

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checked out 9f76559 in an isolated worktree and ran every local gate directly rather than trusting the description: typecheck, lint:ci (0 errors, 15 warnings, matching dev), format:check, the full test:coverage suite (118 files, 1368 passed, 2 skipped, coverage 91.8/88.79/90.73/93.41 percent, matching the body's numbers to within rounding), and build:unpack. Confirmed the two claims the description makes about the built output directly: the compiled CSS contains exactly .bg-image-vs{background-image:var(--background-image-image-vs)}, and the build emits one hashed background JPEG asset alongside it.

Traced the security-relevant surface by hand rather than taking the write-up on faith. The allowlist rule ordering is correct: findMatchingRule takes the first covering rule, and the manifest's 32 KB ceiling is listed ahead of the images' 2 MB one, confirmed by the new api-url-ceiling tests including the backgrounds-evil prefix-segment case. Both IPC channels revalidate id and file server-side (isCatalogBackgroundId, isBackgroundFileName) rather than trusting whatever the renderer computed from an already-validated manifest entry, which is the right call at a trust boundary. writeBackground refuses to write over anything that is not a plain file, closing the same symlink-swap class of bug the custom icon flow already guards against. The background: protocol handler mirrors createCacheModImageProtocolHandler's check order exactly (containment, extension, isSafeProtocolFile, then read), and deliberately skips the memory cache cachemodimg: uses, for the right reason: custom.jpg is one name whose bytes change, so a cache keyed on that name would go stale. registerSchemesAsPrivileged replacing rather than appending its list is a real thing to get right, and the diff does: app's registration moved into the same call rather than gaining a second one beside it.

The one thing I'd flag, not against this PR specifically: the JPEG magic-byte check this PR adds (isJpegBytes) is stronger than the extension-only validation the existing custom icon flow (COPY_TO_ICONS, icons:) still uses for PNG. Left a comment on #206 suggesting the same check get backported there for consistency.

No blocking findings. Approving.

# Conflicts:
#	src/renderer/src/locales/en-US.json
@Pixnop
Pixnop merged commit 6bd8628 into dev Aug 22, 2026
7 checks passed
@Pixnop
Pixnop deleted the feat/background-selection branch August 22, 2026 12:46
Zaldaryon pushed a commit that referenced this pull request Aug 22, 2026
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.
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