A SWF a wrapper loads from memory saves under [[DYNAMIC]]/<n>, and
MochiAds does that in Ruffle but not in the standalone Flash Player, so
Bloons kept its progress at different paths in each. Saves differing
only by that step are now one save: the stream starts with Ruffle's
copy (the dynamic one when both exist) at every path it has for it, and
only saves the stream changed are copied back, to every Ruffle key for
them.
The stream starts with Ruffle's copy of each save, but Ruffle starts
first, and a game with progress only in the stream would write a fresh
save there that then replaced it. Before Ruffle starts a game it now
gets the stream's saves it doesn't have yet. This replaces the
first-switch rule, which kept a stale stream save over Ruffle progress.
Moving the Flash content type above GamePlayer's early return also
fixes a conditional hook.
Ruffle keeps SharedObjects in localStorage and the streamed Flash Player
as .sol files, in the same format under different names. Starting the
stream now adds the game's Ruffle saves, translated to Flash Player's
paths, and stream save updates are copied back into Ruffle's storage.
Root-path saves carry over only when this game made them. A game's
first switch keeps the stream's copy of a save both sides already had.
Drops the /flash and /html5 sections (old URLs redirect to /browse),
the GitHub star count endpoint, the unused HOME_RECENT_HOURS setting,
upstream's design notes and code review docs, and the issue template's
link to upstream discussions.
A Gitea workflow builds the app, session manager and streamer images
and pushes them to the instance's registry on pushes to main and on
version tags, reading the registry host at run time. Compose can pull
them with IMAGE_PREFIX. The update checker reads APP_UPDATE_URL instead
of upstream's GitHub releases and is off without it. Upstream's Docker
Hub workflow is removed.
Game detail and play are one portal page: a stage with the screenshot
and a Play button (or Enter) that loads the game in place, controls
under it, details, plays, rating, tags, favorite and add to playlist,
and related games.
Chilled color scheme as swappable tokens, Fredoka wordmark with a
configurable tagline, genre categories, search and random hotkeys.
The homepage offers a Game of the Day, a Time Machine with a date
picker, recently played games and Flashpoint playlist shelves.
Mature games are hidden unless opted in from the user menu, stored
in the browser and the account. Browse lists default to a seeded
shuffle and accept release date ranges.
The streaming client in a streamed game's iframe shares the app's origin and
keeps its settings in localStorage under its page address, which includes
the session. Those keys changed during play, so they were attributed to the
game and a new batch joined its save every session. Exclude them, and drop
any already recorded when gathering a save.
Saves games keep in the browser now sync with the signed-in player's
account: Ruffle SharedObjects and HTML5 localStorage, IndexedDB databases
(exported and imported whole, typed arrays and blobs included), and streamed
games' save files. Games share the app's origin, so storage that changes
while a game runs is attributed to it. Before a game starts, the account's
save is loaded unless this browser has a newer one; changes upload as they
happen; and a Saves dialog in the player restores any of the last 10, keeping
the save it replaces as a backup.
On load, a signed-out visitor first asks the backend to sign them in as the
user an authenticating proxy vouches for, before guest mode or a login
redirect takes over; an explicit logout skips this for the rest of the tab.
Settings gains a password card for signed-in users, where a proxy-signed-in
user can set a password without the current one to log in without the
proxy.
Flash games Ruffle can play now also report streamable, and the player
offers a switch to the original Flash Player streamed from the server.
Flash games launched from a page, which need the browser plugin, stream
instead of loading a page that can't play, and search no longer hides them
while streaming is enabled.
Games the backend reports as streamable get a Play button. The player starts
a stream session, embeds the stream, and keeps the game's saves in
IndexedDB: sent when the session starts and updated as the server pushes
changes.
When guest access is enabled, guests now receive games.play alongside
games.read and playlists.read, on both the backend guest fallback
(optionalAuth, sharedAccessAuth) and the frontend guest session. Play
statistics stay authenticated-only.
Ruffle publishes tagged releases and daily nightlies into a single
GitHub feed. The updater took whichever release came first carrying a
web-selfhosted.zip, so a stable release landing at the top of the feed
was installed on hosts tracking nightlies -- and because version
comparison normalised everything to a build date, the resulting semver
never matched, leaving the host reporting an update forever.
Selection now filters on GitHub's prerelease flag, and comparison
splits by channel: build date for nightlies, numeric parts for stable.
The channel lives in the ruffle.channel system setting (default
stable) and governs both manual installs and the scheduled job.
Switching channels records the choice and reports the target build;
nothing is downloaded until the install is requested.
Stable releases are months apart and can fall outside the recent page
the changelog is built from, so that channel falls back to the
latest-release endpoint.
The index was scraped from the Flashpoint Datahub wiki on every request.
That page now sits behind a Cloudflare managed challenge that no plain
HTTP client can pass, so the endpoint answered 500 for every caller.
Ship the list instead, extracted from a saved copy of the page into
data/community-playlists.json: 104 playlists across 9 categories. Only
entries whose download URL is on the allowlist are kept, so everything
the modal offers can also be imported. That drops the Old Default
Playlists section, hosted off the allowlist and still in the
pre-Flashpoint 10 format.
The modal now fetches only while open rather than on every render of the
playlists page, and shows when the snapshot was captured. Drops cheerio,
whose only consumer was the scrape.
- Show checkedAt on the up-to-date panel and only claim currency
when versions actually match, with a matching neutral toast path
for unreleased builds
- Add aria-expanded to the changelog toggle
- Reword a review-referencing comment/test title and pin the /app
route's settings.update permission gate in tests
isError stays true for any errored query state, including a
background refetch that failed after a prior successful fetch -
TanStack Query keeps the last-good data in that case. Using isError
in checkFailedEntirely hid an already-rendered update/changelog/
upgrade-command panel behind the generic failure panel on any
refetch error, most likely right after an admin follows the card's
own upgrade instructions and restarts the backend it polls.
isLoadingError is true only when there is no cached data to fall
back on, which is the actual "nothing to show" case the failure
panel is for.
The useQuery for /updates/app only had its always-200 "check failed"
field handled, not a genuine request failure (backend unreachable,
transport error, expired token). In that case info stayed undefined
with isLoading false, rendering an empty card below "Current Version:
Unknown". Fold isError into the existing checkFailedEntirely panel so
a query error gets the same treatment as a failed remote check.
The card always displayed 1.0.0. VITE_APP_VERSION was never set: CI
passes the release tag as a VERSION build arg, but the Dockerfile never
declared it or forwarded it to the frontend build, so the hardcoded
fallback always won.
Declare the build arg and export it as VITE_APP_VERSION, and resolve the
version in vite.config.ts for every other build: the env var, then the
release tag via git describe, then package.json. The image version label
was pinned at 1.0.0 for the same reason and now follows the arg too.
The Version Information card could not tell one Flashpoint install from
another: Ultimate names only a major version in version.txt, and nothing
showed when the metadata was last synced.
Serve two more values with the public settings. The package date is the
mtime of version.txt, falling back to .preferences.defaults.json. Both
are written when the package is built and never rewritten afterwards,
unlike preferences.json and flashpoint.sqlite, which the Launcher and
this app write to.
The last update comes from preferences.json. A missing source or the
1970-01-01 placeholder is normalized to null, which the UI renders as
"Never" - what Ultimate always shows, since it ships no metadata source.
On a network share the database copy and cache pre-warm run for minutes,
and the listener only opened after all of it, so anyone who loaded the
page during that window got a connection error.
Open the port immediately after registering the static frontend, and
hold every route that needs the database behind a gate that answers 503
with a machine-readable marker and the current startup phase. The SPA
shell needs nothing the gate blocks, so the frontend renders a "server
is starting" screen on the login page's own background, polls /health,
and reloads once the server reports ready.
Move the database copy out of the entrypoint: it does the same freshness
check the application does, and doing it before node starts kept the port
closed for the longest part of startup. Distinguish a dead server from a
slow one by requiring the backend's own marker, since a proxy in front of
a stopped backend still answers.
The card owns the radius but never clipped its children, and CardContent
clips to a square box flush with the card's top edge. The skeleton and the
gradient overlay both fill that box, and a hovered image scales into it, so
square corners rendered over the card's rounded ones.
Clip on the card itself, matching PlaylistCard. CardContent keeps its own
overflow-hidden to bound the scaled image to the media area.
LazyImage carried the abort logic but nothing rendered it -- every image in
the app was a plain <img>, so navigating away from a grid left dozens of
thumbnails downloading, holding the six connections and the server I/O the
next page needed. Removing an <img> from the DOM does not cancel its
request; removing the src attribute does.
Rewrite LazyImage around the real element: native lazy loading, internal
loaded and error state, a skeleton prop taking true, false or a node, and a
fallback prop for the error case. Migrate the seven game image sites to it,
each dropping its own loaded/error state. Drop LazyBackgroundImage and
useLazyLoad, which had no consumers either.
Skip the abort while the node is still connected: StrictMode runs effect
cleanups on a live element, and clearing src there blanks every image in
development with nothing to restore it.
Images normally arrive lazily: the proxy fetches one from the CDN when a
browser asks for a game the local mount lacks. That is wrong for an offline
host, where a sync can add thousands of games whose images would only ever
appear for whoever browses to them while online.
Add an opt-in setting (off by default) that, after a sync, fetches logos and
screenshots for exactly the games that sync touched. Runs as its own job with
progress and cancellation, and is never awaited by the sync, so a slow or
failing CDN cannot block or fail a metadata update.
This Vitest version's jsdom environment installed localStorage as a bare object
with no methods, so the auth store's persist middleware threw and took 69 tests
across 6 files down with it. A suite that red hides real regressions.
Install an in-memory Storage for localStorage and sessionStorage, cleared
between tests. That surfaced two failures it had been masking: PlaylistCard
asserted a stale string id in its href, and the MSW mocks lacked a handler for
POST /api/games, which is how search has worked since filter combinations
outgrew the URL length limit.
Frontend suite: 115/115.
LazyImage and LazyBackgroundImage preload through a detached Image and their
cleanup only set a cancelled flag, so the request kept downloading after
unmount. Navigating away from a grid left dozens of downloads occupying the
browser's six connections and the server's I/O, starving the page the user
actually opened.
Clear the handlers and reset src on cleanup, which is what aborts the request,
and have the image proxy return early when the client has already gone rather
than paying a mount round trip — or a 10s CDN fetch — for nobody.
The Ultimate edition ships preferences.json without gameMetadataSources, so
metadata sync was permanently unavailable there with no way to opt in.
Add an admin-only override (metadata.custom_source_enabled / _url) resolved
ahead of preferences, restricted to the two trusted FPFSS hosts and validated
on save. While it is off, the Metadata Update card explains that Ultimate does
not support metadata updates and that updating means a newer full package,
pointing advanced admins at the override. Enabling it requires confirming a
dialog that spells out what a sync rewrites.
Adds the test promised in the design spec's testing section: chip
removal must write an explicit '0' when the admin default is on,
otherwise clearing the URL param would silently re-apply the
downloaded filter. Covers all three removal paths in
GameBrowseLayout (handleRemoveChip, handleRemoveWithChildren,
handleClearAllFilters) against both default states.
Renders GameBrowseLayout with its data hooks (useGames,
useFilterOptions, useFavoriteGameIds, useBatchRatingAggregates,
useFeatureFlags) stubbed out and FilterPanel replaced with a minimal
stand-in exposing the exact callbacks it receives, so the real
handler logic runs without pulling in FilterPanel's own filter-
dropdown machinery or live network calls.
Task 2 already narrows filter-option lists on the backend when downloaded
is true, but useFilterOptions never forwarded the field into the query
params or query key, so the backend support was unreachable and the
dropdown/year-range options never narrowed to downloaded games.
Add the public feature flag features.enable_lucky_button (default on)
with an admin switch in Settings > Features. AppShell renders the
floating random-game button only when the flag is enabled.
Unlike the other feature flags, this one is not bypassed for admins:
it controls cosmetic chrome, so an admin hiding it must also stop
seeing it in their own session.
Closes#13
Collapse the separate backend (Node) and frontend (nginx) images into
one image where the Express backend serves the built React app in
addition to the API and game content, so deployment is a single
container and one origin.
- Add a root Dockerfile with frontend-builder + backend-builder stages
producing one runtime image; remove backend/ and frontend/ Dockerfiles,
the nginx config, and the frontend entrypoint
- Serve the frontend dist and SPA fallback from Express (config.serveFrontend
/ FRONTEND_DIST_PATH), before auth/maintenance so the shell always loads and
before the API 404 so client routes resolve to index.html
- Port the SPA-oriented CSP from nginx into Helmet, gated on serveFrontend;
keep the strict API-only policy otherwise
- Reduce both compose files to a single flashpoint-web service on WEB_PORT
-> 3100; drop API_PORT, BACKEND_HOST, BACKEND_PORT
- Build one image in the release workflow instead of a two-service matrix
- Update .env.example, README, workflow README, and env var docs
Also add .gitattributes (*.sh eol=lf) and normalize the entrypoint line
endings — without it, Windows checkouts bake CRLF into the image and the
shebang fails with "no such file or directory".
- Use label/fieldset/legend and Checkbox ids so playlist rows are
keyboard- and screen-reader-navigable
- Add aria-live status regions for loading and empty states and
aria-hidden on decorative icons
- Move action buttons into DialogFooter
- Drop the duplicate 'Playlist created' toast already fired by the
create mutation
Add community game ratings with a 5-star system:
- Backend: GameRatingService, /api/ratings routes, and migration
002_game_ratings.sql for the ratings table and aggregates
- Frontend: StarRating and GameRatingSection components, useRatings
hooks, ratings API client, and rating types
- Surface aggregate ratings on game cards, list items, detail view,
and a Highest Rated section on the home page
- Gate the feature behind an enableRatings feature flag with a
settings toggle
- Add --star theme tokens and Tailwind star color
Clicking "Browse as Guest" put the app into a redirect loop back to
/login. Guest mode is intentionally unauthenticated (isGuest, no token),
but the home page fetches an auth-only endpoint on load
(GET /favorites/game-ids), which returns 401. The response interceptor
treated that 401 as an expired session: since a guest is not
authenticated, it fell into the `!isAuthenticated` branch, cleared auth
state, and did `window.location.href = "/login"`. So guest mode never
survived the first page load.
Short-circuit the 401 handler for guests: a 401 from an auth-only
endpoint is expected for a guest (there is no token to refresh), so
reject and let the caller handle it instead of tearing down the session.
Authenticated-user and shared-access flows are unchanged.
Adds a regression test covering both the guest (no redirect) and
authenticated (redirect on failed refresh) paths.
Switch games search from GET to POST with JSON body to avoid URL
length limits when combining multiple filters. Filter options now
return context-aware results based on active selections.
Key changes:
- Backend: POST /api/games with array-based filter params
- Backend: Filter options cached per page combination + 30s TTL
for dynamic filter states
- Backend: Fix semicolon-delimited field matching (developers,
publishers, playModes, languages) using INSTR
- Frontend: Add URL compression (lz-string) for shareable filter URLs
- Frontend: Multi-select support in filter dropdowns and chips
- Frontend: Context-aware filter options hook
- Add YearRangeFilter component with from/to inputs and popover UI
- Check both game_data and game table for Flash SWF detection
- Add context-aware sort labels (Oldest/Newest vs A-Z/Z-A)
- Validate page param with isNaN() and Math.max(1, ...)
- Memoize filter handlers, updateFilter, and visibleConfigs
- Add error state with retry button for filter options
- Add LIMIT 100 to play modes query for consistency
- Stabilize useFilterDropdowns with useRef pattern