chore: remove leftovers from upstream
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.
This commit is contained in:
@@ -105,7 +105,6 @@ ENABLE_CACHE_PREWARM=true
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Application
|
# Application
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HOME_RECENT_HOURS=24
|
|
||||||
# ENABLE_CGI=false
|
# ENABLE_CGI=false
|
||||||
# Streamed play for games that can't run in a browser (Java, Shockwave, the
|
# Streamed play for games that can't run in a browser (Java, Shockwave, the
|
||||||
# original Flash Player, Windows games). Set a token (e.g. `openssl rand -hex 32`)
|
# original Flash Player, Windows games). Set a token (e.g. `openssl rand -hex 32`)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ask a Question
|
|
||||||
url: https://github.com/darkraise/flashpoint-web/discussions
|
|
||||||
about: Use Discussions for questions, help, and general conversation
|
|
||||||
- name: Flashpoint Archive
|
- name: Flashpoint Archive
|
||||||
url: https://github.com/FlashpointProject
|
url: https://github.com/FlashpointProject
|
||||||
about: For issues with Flashpoint itself (not this web interface)
|
about: For issues with Flashpoint itself (not this web interface)
|
||||||
|
|||||||
@@ -1193,13 +1193,8 @@ Before submitting code, verify:
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
FLASHPOINT_PATH=D:/Flashpoint # Required - path to Flashpoint installation
|
FLASHPOINT_PATH=/path/to/flashpoint # Required - path to Flashpoint installation
|
||||||
JWT_SECRET=your-secret # Required - random string for JWT signing
|
JWT_SECRET=your-secret # Required - random string for JWT signing
|
||||||
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
|
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
|
||||||
LOG_LEVEL=info # Optional - debug, info, warn, error
|
LOG_LEVEL=info # Optional - debug, info, warn, error
|
||||||
```
|
```
|
||||||
|
|
||||||
## Source References
|
|
||||||
|
|
||||||
- Flashpoint Launcher: `D:\Repositories\Community\launcher`
|
|
||||||
- Flashpoint Game Server: `D:\Repositories\Community\FlashpointGameServer`
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ BCRYPT_SALT_ROUNDS=10
|
|||||||
# ENABLE_CGI=false
|
# ENABLE_CGI=false
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
HOME_RECENT_HOURS=24
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Database Performance Optimization (for network storage)
|
# Database Performance Optimization (for network storage)
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ export const config = {
|
|||||||
|
|
||||||
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
|
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
|
||||||
|
|
||||||
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
|
|
||||||
|
|
||||||
// Session manager that streams games which can't run in a browser, such as
|
// Session manager that streams games which can't run in a browser, such as
|
||||||
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
|
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
|
||||||
streamingApiUrl: process.env.STREAMING_API_URL || null,
|
streamingApiUrl: process.env.STREAMING_API_URL || null,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// In-memory cache for GitHub star count (avoid hitting 60/hr unauthenticated rate limit)
|
|
||||||
let cachedStars: { count: number; fetchedAt: number } | null = null;
|
|
||||||
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
// Proxy to avoid browser CORS issues with GitHub API
|
|
||||||
router.get(
|
|
||||||
'/stars',
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
if (cachedStars && Date.now() - cachedStars.fetchedAt < CACHE_TTL_MS) {
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: { stars: cachedStars.count },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
|
|
||||||
|
|
||||||
const response = await fetch(repoUrl, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/vnd.github.v3+json',
|
|
||||||
'User-Agent': 'Flashpoint-Web-App',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// If rate-limited and we have a stale cache, return it
|
|
||||||
if (cachedStars) {
|
|
||||||
logger.debug('[GitHub] Rate limited, returning stale cache');
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: { stars: cachedStars.count },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error(`GitHub API error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { stargazers_count: number };
|
|
||||||
|
|
||||||
cachedStars = { count: data.stargazers_count, fetchedAt: Date.now() };
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
stars: data.stargazers_count,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -25,7 +25,6 @@ import ratingsRouter from './ratings';
|
|||||||
import gameSavesRouter from './game-saves';
|
import gameSavesRouter from './game-saves';
|
||||||
import discoveryRouter from './discovery';
|
import discoveryRouter from './discovery';
|
||||||
import ruffleRouter from './ruffle';
|
import ruffleRouter from './ruffle';
|
||||||
import githubRouter from './github';
|
|
||||||
import domainsRouter from './domains';
|
import domainsRouter from './domains';
|
||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import cacheRouter from './cache';
|
import cacheRouter from './cache';
|
||||||
@@ -40,7 +39,6 @@ export function setupRoutes(app: Express): void {
|
|||||||
app.use('/api/settings/auth', authSettingsRouter);
|
app.use('/api/settings/auth', authSettingsRouter);
|
||||||
app.use('/api/settings', systemSettingsRouter);
|
app.use('/api/settings', systemSettingsRouter);
|
||||||
app.use('/api/ruffle', ruffleRouter);
|
app.use('/api/ruffle', ruffleRouter);
|
||||||
app.use('/api/github', githubRouter);
|
|
||||||
app.use('/api/domains', domainsRouter);
|
app.use('/api/domains', domainsRouter);
|
||||||
app.use('/api/jobs', jobsRouter);
|
app.use('/api/jobs', jobsRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
|
|||||||
@@ -80,11 +80,6 @@ router.get(
|
|||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
> = systemSettings.getPublicSettings();
|
> = systemSettings.getPublicSettings();
|
||||||
|
|
||||||
if (!publicSettings.app) {
|
|
||||||
publicSettings.app = {};
|
|
||||||
}
|
|
||||||
publicSettings.app.homeRecentHours = config.homeRecentHours;
|
|
||||||
|
|
||||||
// Inject edition/version from config (auto-detected from version.txt, not stored in DB)
|
// Inject edition/version from config (auto-detected from version.txt, not stored in DB)
|
||||||
if (!publicSettings.metadata) {
|
if (!publicSettings.metadata) {
|
||||||
publicSettings.metadata = {};
|
publicSettings.metadata = {};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const INFO = {
|
|||||||
updateAvailable: true,
|
updateAvailable: true,
|
||||||
isUnreleasedBuild: false,
|
isUnreleasedBuild: false,
|
||||||
publishedAt: '2026-08-12T07:11:45Z',
|
publishedAt: '2026-08-12T07:11:45Z',
|
||||||
releaseUrl: 'https://github.com/darkraise/flashpoint-web/releases/tag/v1.0.42',
|
releaseUrl: 'https://git.example.com/owner/dotcom-arcade/releases/tag/v1.0.42',
|
||||||
changelog: '## Changes',
|
changelog: '## Changes',
|
||||||
checkedAt: '2026-08-12T08:00:00.000Z',
|
checkedAt: '2026-08-12T08:00:00.000Z',
|
||||||
lastCheckFailed: false,
|
lastCheckFailed: false,
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ export class AppUpdateService {
|
|||||||
return this.buildInfo();
|
return this.buildInfo();
|
||||||
}
|
}
|
||||||
// A forced check still respects a 60s floor. Express rate limiting allows
|
// A forced check still respects a 60s floor. Express rate limiting allows
|
||||||
// 100 requests a minute, which is no protection for a GitHub quota of 60 an
|
// 100 requests a minute, which is no protection for a release API's quota
|
||||||
// hour that this shares with the star count and the Ruffle update check.
|
// (GitHub allows 60 an hour, shared with the Ruffle update check).
|
||||||
const maxAge = force ? FORCED_REFRESH_FLOOR_MS : CACHE_TTL_MS;
|
const maxAge = force ? FORCED_REFRESH_FLOOR_MS : CACHE_TTL_MS;
|
||||||
const age = this.cached === null ? Infinity : Date.now() - this.cached.fetchedAt;
|
const age = this.cached === null ? Infinity : Date.now() - this.cached.fetchedAt;
|
||||||
// A failure also respects the 60s floor, even when force is true, so a
|
// A failure also respects the 60s floor, even when force is true, so a
|
||||||
// GitHub outage or rate limit can't be hammered every request while it
|
// source's outage or rate limit can't be hammered every request while it
|
||||||
// still shares the API's blocking timeout with each caller.
|
// still shares the API's blocking timeout with each caller.
|
||||||
const failureAge = this.lastFailedAt === null ? Infinity : Date.now() - this.lastFailedAt;
|
const failureAge = this.lastFailedAt === null ? Infinity : Date.now() - this.lastFailedAt;
|
||||||
const withinFailureFloor = failureAge < FORCED_REFRESH_FLOOR_MS;
|
const withinFailureFloor = failureAge < FORCED_REFRESH_FLOOR_MS;
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ services:
|
|||||||
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
||||||
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
|
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
|
||||||
# Application
|
# Application
|
||||||
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
|
|
||||||
# "Latest release" API URL (GitHub or Gitea) for update checks; off when unset
|
# "Latest release" API URL (GitHub or Gitea) for update checks; off when unset
|
||||||
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
|
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
|
||||||
- ENABLE_CGI=${ENABLE_CGI:-false}
|
- ENABLE_CGI=${ENABLE_CGI:-false}
|
||||||
|
|||||||
@@ -205,9 +205,9 @@ Routes are defined in `App.tsx` with nested routing structure:
|
|||||||
<AppShell>
|
<AppShell>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route path="/flash-games" element={
|
<Route path="/browse" element={
|
||||||
<ProtectedRoute requireAuth={false}>
|
<ProtectedRoute requireAuth={false}>
|
||||||
<FlashGamesView />
|
<BrowseView />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
|||||||
@@ -286,16 +286,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const gameNavItems: NavItem[] = [
|
const gameNavItems: NavItem[] = [
|
||||||
{
|
{ path: '/', icon: Home, label: 'Home' },
|
||||||
path: '/flash-games',
|
|
||||||
iconImage: '/images/Flash.png',
|
|
||||||
label: 'Flash Games',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/html5-games',
|
|
||||||
iconImage: '/images/HTML5.png',
|
|
||||||
label: 'HTML5 Games',
|
|
||||||
},
|
|
||||||
{ path: '/animations', icon: Film, label: 'Animations' },
|
{ path: '/animations', icon: Film, label: 'Animations' },
|
||||||
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
|
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
|
||||||
];
|
];
|
||||||
@@ -360,11 +351,6 @@ const isNavItemActive = (item: NavItem) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case: Home (/) should highlight Flash Games
|
|
||||||
if (location.pathname === '/' && itemPath === '/flash-games') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return location.pathname === itemPath;
|
return location.pathname === itemPath;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,24 +6,24 @@ Dotcom Arcade frontend.
|
|||||||
## Route Structure
|
## Route Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
/ → Redirects to /flash-games
|
/ → HomeView (discovery homepage)
|
||||||
/login → LoginView (no auth required)
|
/login → LoginView (no auth required)
|
||||||
/register → RegisterView (no auth required)
|
/register → RegisterView (no auth required)
|
||||||
/unauthorized → UnauthorizedView
|
/unauthorized → UnauthorizedView
|
||||||
|
|
||||||
# Game Views (Public/Guest Accessible)
|
# Game Views (Public/Guest Accessible)
|
||||||
/flash-games → FlashGamesView
|
|
||||||
/html5-games → HTML5GamesView
|
|
||||||
/browse → BrowseView
|
/browse → BrowseView
|
||||||
/animations → AnimationsView
|
/animations → AnimationsView
|
||||||
/games/:id → GameDetailView
|
/browse/:id → GamePage (also /animations/:id, /games/:id)
|
||||||
|
/flash/*, /html5/* → Redirect to the same path under /browse
|
||||||
|
|
||||||
# Protected Routes (Auth Required)
|
# Protected Routes (Auth Required)
|
||||||
/games/:id/play → GamePlayerView (requires "games.play" permission)
|
/browse/:id/play → GamePage, game started (requires "games.play")
|
||||||
/dashboard → DashboardView
|
/dashboard → DashboardView
|
||||||
/favorites → FavoritesView
|
/favorites → FavoritesView
|
||||||
/playlists → PlaylistsView
|
/playlists → UserPlaylistsView
|
||||||
/playlists/:id → PlaylistDetailView
|
/playlists/:id → UserPlaylistDetailView
|
||||||
|
/flashpoint-playlists → PlaylistsView
|
||||||
/settings → SettingsView
|
/settings → SettingsView
|
||||||
|
|
||||||
# Admin Routes (Permission-Based)
|
# Admin Routes (Permission-Based)
|
||||||
@@ -91,17 +91,6 @@ interface ProtectedRouteProps {
|
|||||||
|
|
||||||
## Key Views
|
## Key Views
|
||||||
|
|
||||||
### FlashGamesView / HTML5GamesView
|
|
||||||
|
|
||||||
Pre-filtered game browsing views.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
|
|
||||||
- Platform-specific filtering
|
|
||||||
- Grid/List view toggle
|
|
||||||
- Pagination
|
|
||||||
- Add to favorites/playlists
|
|
||||||
|
|
||||||
### BrowseView
|
### BrowseView
|
||||||
|
|
||||||
Main game browsing view with full filtering.
|
Main game browsing view with full filtering.
|
||||||
@@ -191,9 +180,9 @@ Routes are configured in `App.tsx`:
|
|||||||
<AppShell>
|
<AppShell>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route path="/flash-games" element={
|
<Route path="/browse" element={
|
||||||
<ProtectedRoute requireAuth={false}>
|
<ProtectedRoute requireAuth={false}>
|
||||||
<FlashGamesView />
|
<BrowseView />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
|
|||||||
- [GET /api/metrics/summary](./admin-api.md#get-metrics-summary)
|
- [GET /api/metrics/summary](./admin-api.md#get-metrics-summary)
|
||||||
- [GET /api/metrics/endpoints](./admin-api.md#get-endpoint-metrics)
|
- [GET /api/metrics/endpoints](./admin-api.md#get-endpoint-metrics)
|
||||||
- [GET /api/jobs](./admin-api.md#list-all-jobs)
|
- [GET /api/jobs](./admin-api.md#list-all-jobs)
|
||||||
- [GET /api/github/stars](./admin-api.md#get-github-star-count)
|
|
||||||
- [GET /api/settings/auth](./admin-api.md#get-auth-settings)
|
- [GET /api/settings/auth](./admin-api.md#get-auth-settings)
|
||||||
- [PATCH /api/settings/auth](./admin-api.md#update-auth-settings)
|
- [PATCH /api/settings/auth](./admin-api.md#update-auth-settings)
|
||||||
- [GET /game-files/\*](./admin-api.md#get-game-file)
|
- [GET /game-files/\*](./admin-api.md#get-game-file)
|
||||||
|
|||||||
@@ -213,19 +213,6 @@ Returns `202 Accepted`
|
|||||||
|
|
||||||
Returns progress percentage, stage, estimated completion time.
|
Returns progress percentage, stage, estimated completion time.
|
||||||
|
|
||||||
## GitHub Endpoints
|
|
||||||
|
|
||||||
### Get GitHub Star Count
|
|
||||||
|
|
||||||
`GET /api/github/stars` - No auth required
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": { "stars": 2847 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Statistics Endpoints
|
## Statistics Endpoints
|
||||||
|
|
||||||
### Get Archive Statistics
|
### Get Archive Statistics
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ Two notes that save debugging time:
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| -------------------- | ------- | ------------------------------------------------ |
|
| -------------------- | ------- | ------------------------------------------------ |
|
||||||
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
|
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
|
||||||
| `HOME_RECENT_HOURS` | 24 | Hours to look back for "recently added" games |
|
|
||||||
|
|
||||||
**Frontend Serving:**
|
**Frontend Serving:**
|
||||||
|
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
# Games Filter Panel & Filter Logic - Code Review
|
|
||||||
|
|
||||||
**Date:** 2026-02-15
|
|
||||||
**Reviewer:** Claude
|
|
||||||
**Status:** Review Complete
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The filter system spans frontend components, hooks, API layer, and backend services. Overall, the implementation is solid with good patterns like config-driven rendering and proper caching. However, there are several issues and improvement opportunities.
|
|
||||||
|
|
||||||
## Files Reviewed
|
|
||||||
|
|
||||||
### Frontend Components
|
|
||||||
- `frontend/src/components/search/FilterPanel.tsx`
|
|
||||||
- `frontend/src/components/search/FilterDropdown.tsx`
|
|
||||||
- `frontend/src/components/search/FilterChips.tsx`
|
|
||||||
- `frontend/src/components/search/filterConfig.ts`
|
|
||||||
- `frontend/src/components/search/SearchBar.tsx`
|
|
||||||
- `frontend/src/components/search/SortControls.tsx`
|
|
||||||
- `frontend/src/components/library/GameBrowseLayout.tsx`
|
|
||||||
|
|
||||||
### Frontend Hooks & Types
|
|
||||||
- `frontend/src/hooks/useGames.ts`
|
|
||||||
- `frontend/src/hooks/useFilterOptions.ts`
|
|
||||||
- `frontend/src/hooks/useFilterDropdowns.ts`
|
|
||||||
- `frontend/src/types/game.ts`
|
|
||||||
- `frontend/src/lib/api/games.ts`
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- `backend/src/routes/games.ts`
|
|
||||||
- `backend/src/services/GameService.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issues Found
|
|
||||||
|
|
||||||
### 1. Type Safety Issues
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `FilterPanel.tsx:22-23` | Unsafe `split()` on potentially undefined value | Medium |
|
|
||||||
| `GameBrowseLayout.tsx:72` | Type assertion `as 'asc' | 'desc'` without validation | Medium |
|
|
||||||
| `FilterDropdown.tsx:42` | No validation that `selectedValues` array items exist in `options` | Low |
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// FilterPanel.tsx:22-23 - currentValue could be number/undefined for yearFrom/yearTo
|
|
||||||
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
|
||||||
// This works but paramKey includes non-string fields (yearFrom, yearTo, page, limit)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Missing Year Range Filter in UI
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `filterConfig.ts` | `yearFrom`/`yearTo` filters defined in `GameFilters` but not exposed in UI | Medium |
|
|
||||||
| `FilterPanel.tsx` | No year range slider or inputs despite backend support | Medium |
|
|
||||||
|
|
||||||
The `FilterOptions.yearRange` is fetched but never used - users cannot filter by release year.
|
|
||||||
|
|
||||||
### 3. Inconsistent Filter Chips Implementation
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `FilterPanel.tsx:105-140` | `renderFilterBadges()` duplicates logic already in `FilterChips.tsx` | Low |
|
|
||||||
| `GameBrowseLayout.tsx:110-198` | Separate filter chip generation vs `FilterChips` component | Low |
|
|
||||||
|
|
||||||
Two different chip rendering systems exist:
|
|
||||||
- `FilterPanel.tsx` renders inline badges
|
|
||||||
- `GameBrowseLayout.tsx` creates `FilterChip[]` array for `FilterChips` component
|
|
||||||
|
|
||||||
### 4. Performance: Redundant Handlers Recreation
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `FilterPanel.tsx:68-74` | `createFilterHandlers()` called on every render for every filter | Low |
|
|
||||||
| `FilterPanel.tsx:113-119` | Same handlers recreated in `renderFilterBadges()` | Low |
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Called inside map() on every render
|
|
||||||
const handlers = createFilterHandlers(
|
|
||||||
config.paramKey,
|
|
||||||
config.id,
|
|
||||||
filters,
|
|
||||||
updateFilter,
|
|
||||||
dropdowns
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Missing Search Debouncing in SearchBar
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `SearchBar.tsx:10-15` | No debounce - navigates immediately on form submit | Low |
|
|
||||||
| N/A | No "search as you type" with debounce option | Enhancement |
|
|
||||||
|
|
||||||
### 6. Backend: Filter Options Limit Inconsistency
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `GameService.ts:706-707` | Series limited to 100 | Low |
|
|
||||||
| `GameService.ts:730-731` | Developers limited to 100 | Low |
|
|
||||||
| `GameService.ts:780-781` | Play modes NOT limited | Inconsistent |
|
|
||||||
| `GameService.ts:849` | Tags limited to 100 | Low |
|
|
||||||
|
|
||||||
Play modes query lacks `LIMIT 100` that other filter queries have.
|
|
||||||
|
|
||||||
### 7. Missing Input Validation on Frontend
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `GameBrowseLayout.tsx:72` | `page` parsed without `isNaN()` check | Medium |
|
|
||||||
| `GameBrowseLayout.tsx:58-68` | Year parsing validates `isNaN()` - good! | N/A |
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Line 72 - could pass NaN to API
|
|
||||||
page: parseInt(searchParams.get('page') || '1', 10),
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. URL Encoding Issues
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `GameBrowseLayout.tsx:134` | `encodeURIComponent()` used for chip IDs but inconsistent | Low |
|
|
||||||
| `FilterPanel.tsx:125` | Badge key uses raw `value` - may have special chars | Low |
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Line 125 - key should be encoded for consistency
|
|
||||||
<Badge key={value} variant="secondary" className="gap-1">
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. Missing Error Boundary for Filter Options
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `useFilterOptions.ts` | No `onError` handler | Low |
|
|
||||||
| `FilterPanel.tsx:79` | Falls back to empty array `|| []` but no loading state | Low |
|
|
||||||
|
|
||||||
If filter options fail to load, dropdowns show "No options available" with no retry mechanism.
|
|
||||||
|
|
||||||
### 10. Accessibility Issues
|
|
||||||
|
|
||||||
| Location | Issue | Severity |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| `SortControls.tsx:26` | Native `<select>` used without styling wrapper | Low |
|
|
||||||
| `FilterDropdown.tsx:103` | `<label>` wraps checkbox - good | N/A |
|
|
||||||
| `SortControls.tsx:35-45` | Sort order button shows "A-Z/Z-A" but not meaningful for dates | Medium |
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// "A-Z" / "Z-A" makes sense for title, not for dateAdded
|
|
||||||
<span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Improvements Recommended
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
|
|
||||||
1. **Add Year Range Filter UI**
|
|
||||||
- Add a dual-handle range slider component
|
|
||||||
- Use `yearRange` from `FilterOptions`
|
|
||||||
- Connect to `yearFrom`/`yearTo` URL params
|
|
||||||
|
|
||||||
2. **Validate Page Parameter**
|
|
||||||
```typescript
|
|
||||||
// GameBrowseLayout.tsx:72
|
|
||||||
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1),
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Fix Sort Order Label for Dates**
|
|
||||||
```typescript
|
|
||||||
const sortOrderLabel = useMemo(() => {
|
|
||||||
if (['releaseDate', 'dateAdded', 'dateModified'].includes(sortBy)) {
|
|
||||||
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
|
|
||||||
}
|
|
||||||
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
|
|
||||||
}, [sortBy, sortOrder]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
|
|
||||||
4. **Consolidate Filter Chip Rendering**
|
|
||||||
- Remove inline badge rendering from `FilterPanel.tsx`
|
|
||||||
- Use `FilterChips` component consistently
|
|
||||||
- Pass chip generation logic via props or shared utility
|
|
||||||
|
|
||||||
5. **Add Filter Options Loading State**
|
|
||||||
```typescript
|
|
||||||
const { data: filterOptions, isLoading: filterOptionsLoading, error } = useFilterOptions();
|
|
||||||
|
|
||||||
// Show skeleton or disabled state while loading
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Memoize Filter Handlers**
|
|
||||||
```typescript
|
|
||||||
const handlers = useMemo(() =>
|
|
||||||
FILTER_CONFIGS.reduce((acc, config) => ({
|
|
||||||
...acc,
|
|
||||||
[config.id]: createFilterHandlers(config.paramKey, config.id, filters, updateFilter, dropdowns)
|
|
||||||
}), {}),
|
|
||||||
[filters, updateFilter, dropdowns]
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Add Search-as-You-Type with Debounce**
|
|
||||||
```typescript
|
|
||||||
// In a new SearchInput component
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const debouncedSearch = useDebounce(inputValue, 300);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debouncedSearch) {
|
|
||||||
navigate(`/browse?search=${encodeURIComponent(debouncedSearch)}`);
|
|
||||||
}
|
|
||||||
}, [debouncedSearch]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
|
|
||||||
8. **Consistent LIMIT on All Filter Queries**
|
|
||||||
```sql
|
|
||||||
-- GameService.ts:780-781
|
|
||||||
ORDER BY count DESC, playMode ASC
|
|
||||||
LIMIT 100 -- Add this
|
|
||||||
```
|
|
||||||
|
|
||||||
9. **Add Filter Presets**
|
|
||||||
- Save/load common filter combinations
|
|
||||||
- "Popular Flash Games", "Recent Additions", etc.
|
|
||||||
|
|
||||||
10. **Add Filter Count in Dropdown Trigger**
|
|
||||||
- Show total matching games for current filter combination
|
|
||||||
- Update counts dynamically based on other selected filters (cascading filters)
|
|
||||||
|
|
||||||
11. **Keyboard Navigation Improvements**
|
|
||||||
- Arrow keys to navigate dropdown options
|
|
||||||
- Enter to toggle selection
|
|
||||||
- Escape to close dropdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Quality Observations
|
|
||||||
|
|
||||||
### Positive Patterns
|
|
||||||
|
|
||||||
| Pattern | Location | Notes |
|
|
||||||
|---------|----------|-------|
|
|
||||||
| Config-driven filter rendering | `filterConfig.ts` | Eliminates 7 duplicate filter definitions |
|
|
||||||
| Proper `asyncHandler()` usage | `games.ts` routes | All async routes wrapped |
|
|
||||||
| Zod validation | `games.ts:27-49` | Comprehensive query validation |
|
|
||||||
| Window function for count | `GameService.ts:247` | Efficient single-query pagination |
|
|
||||||
| Cache pre-warming | `GameService.ts:158-166` | Filter options cached at startup |
|
|
||||||
| Proper URL param handling | `GameBrowseLayout.tsx` | `searchParams` synced correctly |
|
|
||||||
|
|
||||||
### Areas for Improvement
|
|
||||||
|
|
||||||
| Area | Current | Recommended |
|
|
||||||
|------|---------|-------------|
|
|
||||||
| Handler memoization | Created inline per render | Use `useMemo` or move outside component |
|
|
||||||
| Error handling | Silent fallback to `[]` | Show error state with retry |
|
|
||||||
| Year range UI | Missing | Add range slider component |
|
|
||||||
| Filter persistence | URL only | Consider localStorage for user defaults |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Category | Issues | Improvements |
|
|
||||||
|----------|--------|--------------|
|
|
||||||
| Type Safety | 3 | Validate all URL params |
|
|
||||||
| Missing Features | 2 | Year filter, presets |
|
|
||||||
| Performance | 2 | Memoize handlers |
|
|
||||||
| Consistency | 3 | Unify chip rendering |
|
|
||||||
| Accessibility | 2 | Sort labels, keyboard nav |
|
|
||||||
| Backend | 1 | Consistent LIMIT |
|
|
||||||
| **Total** | **13** | **10 recommendations** |
|
|
||||||
|
|
||||||
The filter system is functional and well-architected with the config-driven approach. The main gaps are the missing year range filter UI, sort order labels that don't match the sort type, and some performance optimizations around handler memoization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Action Items
|
|
||||||
|
|
||||||
- [x] Add year range filter slider to FilterPanel *(Fixed: 2026-02-15)*
|
|
||||||
- [x] Fix page parameter validation in GameBrowseLayout *(Fixed: 2026-02-15)*
|
|
||||||
- [x] Update SortControls to show context-aware labels (Oldest/Newest vs A-Z/Z-A) *(Fixed: 2026-02-15)*
|
|
||||||
- [x] Add LIMIT 100 to play modes query in GameService *(Fixed: 2026-02-15)*
|
|
||||||
- [x] Consolidate filter chip rendering to use FilterChips component only *(Fixed: 2026-02-15)*
|
|
||||||
- [x] Add loading/error states for filter options *(Fixed: 2026-02-15)*
|
|
||||||
- [x] Memoize filter handlers in FilterPanel *(Fixed: 2026-02-15)*
|
|
||||||
|
|
||||||
## Fixes Applied (2026-02-15)
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
|
|
||||||
1. **`frontend/src/components/library/GameBrowseLayout.tsx`**
|
|
||||||
- Added `isNaN()` validation for page parameter with `Math.max(1, ...)` fallback
|
|
||||||
- Added year range chip generation and removal handling
|
|
||||||
|
|
||||||
2. **`frontend/src/components/search/SortControls.tsx`**
|
|
||||||
- Added context-aware sort order labels: "Oldest/Newest" for date fields, "A-Z/Z-A" for text fields
|
|
||||||
- Improved accessibility with `aria-label`
|
|
||||||
|
|
||||||
3. **`frontend/src/components/search/FilterPanel.tsx`**
|
|
||||||
- Memoized filter handlers using `useMemo` to avoid recreation on every render
|
|
||||||
- Memoized `updateFilter` with `useCallback`
|
|
||||||
- Memoized `visibleConfigs` array
|
|
||||||
- Removed duplicate badge rendering (now uses `FilterChips` only)
|
|
||||||
- Added loading state for filter options ("Loading..." message)
|
|
||||||
- Integrated `YearRangeFilter` component
|
|
||||||
|
|
||||||
4. **`frontend/src/components/search/YearRangeFilter.tsx`** (new file)
|
|
||||||
- Created year range filter component with from/to inputs
|
|
||||||
- Supports compact mode for mobile
|
|
||||||
- Proper validation of year values
|
|
||||||
|
|
||||||
5. **`backend/src/services/GameService.ts`**
|
|
||||||
- Added `LIMIT 100` to play modes query for consistency with other filter queries
|
|
||||||
|
|
||||||
## Additional Improvements (2026-02-15)
|
|
||||||
|
|
||||||
### Error State with Retry for Filter Options
|
|
||||||
|
|
||||||
**`frontend/src/components/search/FilterPanel.tsx`** - Added:
|
|
||||||
- Error state detection from `useFilterOptions` hook
|
|
||||||
- User-friendly error message with alert styling
|
|
||||||
- Retry button to refetch filter options
|
|
||||||
- Graceful fallback - shows filters if data exists even with stale error
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,798 +0,0 @@
|
|||||||
# Persistent Ruffle Installation Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
> **Implementer assignments:** each task names its implementer agent in an
|
|
||||||
> `**Implementer:**` line. When executing with
|
|
||||||
> superpowers:subagent-driven-development, REQUIRED SUB-SKILL:
|
|
||||||
> dcc-superpower-companions:dispatching-tiered-implementers. Under
|
|
||||||
> superpowers:executing-plans these lines are inert; ignore them.
|
|
||||||
|
|
||||||
**Goal:** A Ruffle version installed through Settings → Update survives container recreation.
|
|
||||||
|
|
||||||
**Architecture:** Ruffle installs into `/app/data/ruffle`, which sits on the already-mounted data volume, instead of `/app/frontend/dist/ruffle`, which lives in the container's throwaway writable layer. A fresh volume is seeded from the copy baked into the image, so Flash works immediately and offline. Express serves the persisted directory at `/ruffle`, ahead of the dist handler and with cache headers that let an updated loader actually reach browsers.
|
|
||||||
|
|
||||||
**Tech Stack:** Express + TypeScript, vitest + supertest, Docker Compose.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-08-13-ruffle-persistent-install-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- Backend tests run from the `backend/` directory with `npx vitest run`. Do **not** use `npm test` from the repository root — it enters watch mode and hangs.
|
|
||||||
- Never introduce `any` types or non-null assertions (`!`). Use `??` for defaults, never `||`.
|
|
||||||
- Use `logger` from `../utils/logger`, never `console`.
|
|
||||||
- Run `npx prettier --write <files>` on every changed file before committing.
|
|
||||||
- The environment variable name is exactly `RUFFLE_DATA_PATH`. The production default is exactly `/app/data/ruffle`.
|
|
||||||
- `config.ruffleDataPath` is the install directory itself, not its parent. `path.join(config.ruffleDataPath, 'ruffle')` is always wrong.
|
|
||||||
- Ruffle's content-hashed files are detected by a hex run of **16 or more** characters in the basename (`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`). Everything else — `ruffle.js`, `ruffle.js.map`, `package.json` — is stable-named and must revalidate.
|
|
||||||
- Do not delete `frontend/public/ruffle/`. It is the seed source, and Vite serves it in development.
|
|
||||||
- Do not add a release-note entry to `docs/09-deployment/upgrade-guide.md`. That file gains a version heading only when a release is cut, and inventing a version number here would be wrong.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Persisted install path
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `backend/src/config.ts:118-119`
|
|
||||||
- Modify: `backend/src/services/RuffleService.ts:41-52`
|
|
||||||
- Test: `backend/src/services/RuffleService.test.ts:7,78-85,124-126,138-157`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: nothing.
|
|
||||||
- Produces: `config.ruffleDataPath: string` — the absolute directory Ruffle is installed into. `RuffleService` gains a private `installPath` (the install target) and a private `bundledPath` (the image's baked copy at `<frontendDistPath>/ruffle`), both read by Task 2.
|
|
||||||
|
|
||||||
**Implementer:** dcc-superpower-companions:impl-sonnet-max
|
|
||||||
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the config value**
|
|
||||||
|
|
||||||
In `backend/src/config.ts`, find this block:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// __dirname is backend/dist at runtime, so this resolves to the frontend build
|
|
||||||
// copied next to the backend in the image (/app/frontend/dist).
|
|
||||||
frontendDistPath:
|
|
||||||
process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),
|
|
||||||
```
|
|
||||||
|
|
||||||
Insert directly after it:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Ruffle installs here rather than under the served frontend build. That build
|
|
||||||
// is part of the image, so it lives in the container's writable layer and an
|
|
||||||
// in-app update was discarded whenever the container was recreated.
|
|
||||||
ruffleDataPath:
|
|
||||||
process.env.RUFFLE_DATA_PATH ??
|
|
||||||
(process.env.NODE_ENV === 'production'
|
|
||||||
? '/app/data/ruffle'
|
|
||||||
: path.resolve(__dirname, '../data/ruffle')),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the test fixtures to a separate data directory**
|
|
||||||
|
|
||||||
In `backend/src/services/RuffleService.test.ts`, change the declaration on line 7:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let distDir: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let distDir: string;
|
|
||||||
let dataDir: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the config mock (lines 78-85):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock('../config', () => ({
|
|
||||||
config: {
|
|
||||||
serveFrontend: true,
|
|
||||||
get frontendDistPath() {
|
|
||||||
return distDir;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock('../config', () => ({
|
|
||||||
config: {
|
|
||||||
serveFrontend: true,
|
|
||||||
get frontendDistPath() {
|
|
||||||
return distDir;
|
|
||||||
},
|
|
||||||
get ruffleDataPath() {
|
|
||||||
return nodePath.join(dataDir, 'ruffle');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the `ruffleDir` helper (lines 124-126):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function ruffleDir(): string {
|
|
||||||
return nodePath.join(distDir, 'ruffle');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function ruffleDir(): string {
|
|
||||||
return nodePath.join(dataDir, 'ruffle');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In `beforeEach`, replace the single `distDir` assignment:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-'));
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-dist-'));
|
|
||||||
dataDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-data-'));
|
|
||||||
```
|
|
||||||
|
|
||||||
In `afterEach`, replace:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
nodeFs.rmSync(distDir, { recursive: true, force: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
nodeFs.rmSync(distDir, { recursive: true, force: true });
|
|
||||||
nodeFs.rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
|
|
||||||
|
|
||||||
Expected: FAIL. The suite now asserts against `dataDir/ruffle` while the service still installs into `distDir/ruffle`, so assertions such as `expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build')` throw ENOENT.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Point the service at the persisted path**
|
|
||||||
|
|
||||||
In `backend/src/services/RuffleService.ts`, replace the field declaration and constructor (lines 41-52):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private readonly frontendPublicPath: string;
|
|
||||||
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Install into the directory that is actually served. In a built deployment
|
|
||||||
// that is the frontend's dist output; in development Vite serves public/.
|
|
||||||
// Pointing at public/ in a container made every fresh start re-download
|
|
||||||
// Ruffle even though the image already ships it in dist/.
|
|
||||||
this.frontendPublicPath = config.serveFrontend
|
|
||||||
? path.join(config.frontendDistPath, 'ruffle')
|
|
||||||
: path.resolve(__dirname, '../../../frontend/public/ruffle');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
private readonly installPath: string;
|
|
||||||
private readonly bundledPath: string;
|
|
||||||
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// A built deployment installs onto the mounted data volume and serves it
|
|
||||||
// from there: the frontend build is part of the image, so anything written
|
|
||||||
// into it is discarded when the container is recreated. In development Vite
|
|
||||||
// serves public/, which is the only directory reachable there.
|
|
||||||
this.installPath = config.serveFrontend
|
|
||||||
? config.ruffleDataPath
|
|
||||||
: path.resolve(__dirname, '../../../frontend/public/ruffle');
|
|
||||||
this.bundledPath = path.join(config.frontendDistPath, 'ruffle');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then rename the remaining nine references. Every other occurrence of `this.frontendPublicPath` in the file becomes `this.installPath` — lines 81, 447, 495, 520, 526, 527, 532, 546, and 576. A whole-word replace of `frontendPublicPath` with `installPath` across the file is exact; there are no other matches.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
|
|
||||||
|
|
||||||
Expected: PASS, all existing cases green. They now exercise install, backup, rollback, and the rename/copy fallbacks against the persisted directory.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Typecheck**
|
|
||||||
|
|
||||||
Run from the repository root: `npm run typecheck`
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prettier --write backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
|
|
||||||
git add backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
|
|
||||||
git commit -m "fix(ruffle): install onto the persisted data volume"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Seed a fresh volume from the bundled copy
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `backend/src/services/RuffleService.ts` (add two methods after `verifyInstallation`)
|
|
||||||
- Modify: `backend/src/server.ts:296-314`
|
|
||||||
- Test: `backend/src/services/RuffleService.test.ts` (append a new `describe` block)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `this.installPath` and `this.bundledPath` from Task 1; the existing private `copyDirectory(source: string, target: string): void`.
|
|
||||||
- Produces: `RuffleService.ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'>`, called by `backend/src/server.ts`.
|
|
||||||
|
|
||||||
**Implementer:** dcc-superpower-companions:impl-sonnet-xhigh
|
|
||||||
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 1 = 4
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `backend/src/services/RuffleService.test.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function installBundledRuffle(): void {
|
|
||||||
const bundled = nodePath.join(distDir, 'ruffle');
|
|
||||||
nodeFs.mkdirSync(bundled, { recursive: true });
|
|
||||||
nodeFs.writeFileSync(nodePath.join(bundled, 'ruffle.js'), 'bundled build');
|
|
||||||
nodeFs.writeFileSync(
|
|
||||||
nodePath.join(bundled, 'package.json'),
|
|
||||||
JSON.stringify({ version: '0.2.0-nightly.2026.1.29' })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RuffleService.ensureInstalled', () => {
|
|
||||||
it('leaves an existing installation alone', async () => {
|
|
||||||
installExistingRuffle();
|
|
||||||
installBundledRuffle();
|
|
||||||
|
|
||||||
const result = await new RuffleService().ensureInstalled();
|
|
||||||
|
|
||||||
expect(result).toBe('present');
|
|
||||||
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('old build');
|
|
||||||
expect(axios.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seeds from the bundled copy when nothing is installed', async () => {
|
|
||||||
installBundledRuffle();
|
|
||||||
const service = new RuffleService();
|
|
||||||
|
|
||||||
const result = await service.ensureInstalled();
|
|
||||||
|
|
||||||
expect(result).toBe('seeded');
|
|
||||||
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe(
|
|
||||||
'bundled build'
|
|
||||||
);
|
|
||||||
expect(service.getCurrentVersion()).toBe('0.2.0-nightly.2026.1.29');
|
|
||||||
expect(axios.get).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloads when neither an installation nor a bundled copy exists', async () => {
|
|
||||||
const result = await new RuffleService().ensureInstalled();
|
|
||||||
|
|
||||||
expect(result).toBe('downloaded');
|
|
||||||
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build');
|
|
||||||
expect(axios.get).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts -t ensureInstalled`
|
|
||||||
|
|
||||||
Expected: FAIL with `ensureInstalled is not a function`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the methods**
|
|
||||||
|
|
||||||
In `backend/src/services/RuffleService.ts`, add both methods directly after `verifyInstallation()`, inside the class:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Copy the Ruffle shipped inside the image into the install directory. A fresh
|
|
||||||
* data volume otherwise has no player at all until a download finishes — and
|
|
||||||
* never gets one on a host that cannot reach GitHub.
|
|
||||||
*/
|
|
||||||
private seedFromBundle(): boolean {
|
|
||||||
if (this.bundledPath === this.installPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(path.join(this.bundledPath, 'ruffle.js'))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.copyDirectory(this.bundledPath, this.installPath);
|
|
||||||
logger.info(
|
|
||||||
`✅ Ruffle seeded from the bundled copy (version: ${this.getCurrentVersion() ?? 'unknown'})`
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.warn('[RuffleService] Could not seed Ruffle from the bundled copy:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a Ruffle available, preferring what is already installed, then the copy
|
|
||||||
* baked into the image, and only then a download.
|
|
||||||
*/
|
|
||||||
async ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'> {
|
|
||||||
if (this.verifyInstallation()) {
|
|
||||||
logger.info(`✅ Ruffle verified (version: ${this.getCurrentVersion() ?? 'unknown'})`);
|
|
||||||
return 'present';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.seedFromBundle()) {
|
|
||||||
return 'seeded';
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('🎮 Ruffle not found, downloading latest version...');
|
|
||||||
await this.updateRuffle();
|
|
||||||
logger.info('✅ Ruffle installation complete');
|
|
||||||
return 'downloaded';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the tests to verify they pass**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
|
|
||||||
|
|
||||||
Expected: PASS, including the three new cases and every pre-existing one.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Call it from startup**
|
|
||||||
|
|
||||||
In `backend/src/server.ts`, replace lines 296-314:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Ruffle installs in the background: it downloads from GitHub, and awaiting it
|
|
||||||
// here kept the port closed for minutes on slow storage — indefinitely if the
|
|
||||||
// download stalled. Flash games need it, everything else does not.
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const ruffleService = new RuffleService();
|
|
||||||
if (!ruffleService.verifyInstallation()) {
|
|
||||||
logger.info('🎮 Ruffle not found, downloading latest version...');
|
|
||||||
await ruffleService.updateRuffle();
|
|
||||||
logger.info('✅ Ruffle installation complete');
|
|
||||||
} else {
|
|
||||||
const version = ruffleService.getCurrentVersion();
|
|
||||||
logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to install Ruffle:', error);
|
|
||||||
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Ruffle resolves in the background: seeding copies ~20 MB and the download
|
|
||||||
// fallback reaches GitHub, and awaiting either here kept the port closed for
|
|
||||||
// minutes on slow storage. Flash games need it, everything else does not.
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await new RuffleService().ensureInstalled();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to install Ruffle:', error);
|
|
||||||
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Typecheck and run the full backend suite**
|
|
||||||
|
|
||||||
Run from the repository root: `npm run typecheck`
|
|
||||||
|
|
||||||
Then run from `backend/`: `npx vitest run`
|
|
||||||
|
|
||||||
Expected: no type errors, no new test failures.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prettier --write backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
|
|
||||||
git add backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
|
|
||||||
git commit -m "feat(ruffle): seed a fresh data volume from the bundled copy"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Serve the persisted directory with correct cache headers
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `backend/src/middleware/frontendServing.ts:20-55`
|
|
||||||
- Modify: `backend/src/middleware/startupOrdering.test.ts:12-17`
|
|
||||||
- Create: `backend/src/middleware/ruffleServing.test.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `config.ruffleDataPath` from Task 1.
|
|
||||||
- Produces: nothing consumed by later tasks. `registerFrontendServing(app, distPath?)` keeps its existing signature; the Ruffle mount reads `config.ruffleDataPath` directly.
|
|
||||||
|
|
||||||
**Implementer:** dcc-superpower-companions:impl-sonnet-max
|
|
||||||
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `backend/src/middleware/ruffleServing.test.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
import express, { Express } from 'express';
|
|
||||||
import request from 'supertest';
|
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
vi.mock('../utils/logger', () => ({
|
|
||||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
let ruffleDir: string;
|
|
||||||
|
|
||||||
vi.mock('../config', () => ({
|
|
||||||
config: {
|
|
||||||
serveFrontend: true,
|
|
||||||
get ruffleDataPath() {
|
|
||||||
return ruffleDir;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { registerFrontendServing } from './frontendServing';
|
|
||||||
|
|
||||||
const HASHED_CHUNK = 'core.ruffle.4ca82b563e9711217164.js';
|
|
||||||
|
|
||||||
let distDir: string;
|
|
||||||
let app: Express;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-dist-'));
|
|
||||||
ruffleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-ruffle-'));
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(distDir, 'index.html'), '<!doctype html><div id="root"></div>');
|
|
||||||
fs.mkdirSync(path.join(distDir, 'ruffle'));
|
|
||||||
fs.writeFileSync(path.join(distDir, 'ruffle', 'ruffle.js'), 'bundled loader');
|
|
||||||
fs.writeFileSync(path.join(distDir, 'ruffle', 'only-in-bundle.js'), 'bundled extra');
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(ruffleDir, 'ruffle.js'), 'updated loader');
|
|
||||||
fs.writeFileSync(path.join(ruffleDir, HASHED_CHUNK), 'updated chunk');
|
|
||||||
|
|
||||||
app = express();
|
|
||||||
registerFrontendServing(app, distDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(distDir, { recursive: true, force: true });
|
|
||||||
fs.rmSync(ruffleDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Ruffle serving', () => {
|
|
||||||
it('serves the persisted copy in preference to the bundled one', async () => {
|
|
||||||
const response = await request(app).get('/ruffle/ruffle.js');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.text).toBe('updated loader');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the bundled copy for a file the persisted copy lacks', async () => {
|
|
||||||
const response = await request(app).get('/ruffle/only-in-bundle.js');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.text).toBe('bundled extra');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes the stable-named loader revalidate', async () => {
|
|
||||||
const response = await request(app).get('/ruffle/ruffle.js');
|
|
||||||
|
|
||||||
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes a content-hashed chunk immutable', async () => {
|
|
||||||
const response = await request(app).get(`/ruffle/${HASHED_CHUNK}`);
|
|
||||||
|
|
||||||
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes the bundled fallback loader revalidate too', async () => {
|
|
||||||
const response = await request(app).get('/ruffle/only-in-bundle.js');
|
|
||||||
|
|
||||||
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
|
|
||||||
|
|
||||||
Expected: FAIL. `/ruffle/ruffle.js` returns `bundled loader` because no persisted mount exists, and its `Cache-Control` is `public, max-age=31536000, immutable`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the mount and the cache policy**
|
|
||||||
|
|
||||||
In `backend/src/middleware/frontendServing.ts`, add this import alongside the existing ones:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Response } from 'express';
|
|
||||||
```
|
|
||||||
|
|
||||||
Add these two declarations directly above `export function registerFrontendServing`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Ruffle content-hashes its chunks (`core.ruffle.4ca82b563e9711217164.js`,
|
|
||||||
* `f2a570ccf4468b20d95a.wasm`) but not its entry points. Detecting the hash
|
|
||||||
* rather than listing the stable names keeps this correct when a release adds a
|
|
||||||
* file.
|
|
||||||
*/
|
|
||||||
const HASHED_RUFFLE_ASSET = /[0-9a-f]{16,}/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `ruffle.js` keeps its name across versions, so serving it immutable pinned
|
|
||||||
* every returning browser to the loader it first saw — an update reached the
|
|
||||||
* disk and nothing else.
|
|
||||||
*/
|
|
||||||
function setRuffleCacheControl(res: Response, filePath: string): void {
|
|
||||||
res.setHeader(
|
|
||||||
'Cache-Control',
|
|
||||||
HASHED_RUFFLE_ASSET.test(path.basename(filePath))
|
|
||||||
? 'public, max-age=31536000, immutable'
|
|
||||||
: 'no-cache, no-store, must-revalidate'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Inside `registerFrontendServing`, insert this immediately after the `index.html` existence guard and before the existing `app.use(express.static(distPath, ...))` call:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Ahead of the dist handler so an updated Ruffle shadows the one baked into
|
|
||||||
// the image. `fallthrough` lets the baked copy answer for anything the
|
|
||||||
// persisted directory is missing, including before the first seed finishes.
|
|
||||||
app.use(
|
|
||||||
'/ruffle',
|
|
||||||
express.static(config.ruffleDataPath, {
|
|
||||||
index: false,
|
|
||||||
fallthrough: true,
|
|
||||||
setHeaders: setRuffleCacheControl,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Then replace the `setHeaders` option on the existing dist handler:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
setHeaders: (res, filePath) => {
|
|
||||||
// Hashed assets are immutable; index.html must always be revalidated.
|
|
||||||
if (filePath.endsWith('index.html')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
setHeaders: (res, filePath) => {
|
|
||||||
// Hashed assets are immutable; index.html must always be revalidated.
|
|
||||||
if (filePath.endsWith('index.html')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (filePath.startsWith(bundledRufflePath + path.sep)) {
|
|
||||||
setRuffleCacheControl(res, filePath);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
and declare `bundledRufflePath` at the top of the function body, directly below the existing `const indexHtml = path.join(distPath, 'index.html');`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const bundledRufflePath = path.join(distPath, 'ruffle');
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
|
|
||||||
|
|
||||||
Expected: PASS, all five cases.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Repair the sibling middleware test's config mock**
|
|
||||||
|
|
||||||
`backend/src/middleware/startupOrdering.test.ts` mocks `../config` without `ruffleDataPath`, and `express.static(undefined)` throws `TypeError: root path required`. Replace its mock (lines 12-17):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock('../config', () => ({
|
|
||||||
config: {
|
|
||||||
serveFrontend: true,
|
|
||||||
frontendDistPath: '/unused-in-tests',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock('../config', () => ({
|
|
||||||
config: {
|
|
||||||
serveFrontend: true,
|
|
||||||
frontendDistPath: '/unused-in-tests',
|
|
||||||
ruffleDataPath: '/unused-in-tests/ruffle',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run the full backend suite and typecheck**
|
|
||||||
|
|
||||||
Run from `backend/`: `npx vitest run`
|
|
||||||
|
|
||||||
Then run from the repository root: `npm run typecheck`
|
|
||||||
|
|
||||||
Expected: no failures and no type errors. `startupOrdering.test.ts` in particular must stay green — a missing-directory static mount with `fallthrough: true` calls `next()` instead of throwing.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prettier --write backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
|
|
||||||
git add backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
|
|
||||||
git commit -m "fix(ruffle): serve the persisted install ahead of the bundled one"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Deployment configuration and documentation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `docker-compose.dev.yml:28-40`
|
|
||||||
- Modify: `docker-compose.yml:29-33`
|
|
||||||
- Modify: `docs/09-deployment/environment-variables.md:113-116`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: the `RUFFLE_DATA_PATH` variable name and the `/app/data/ruffle` default from Task 1.
|
|
||||||
- Produces: nothing.
|
|
||||||
|
|
||||||
**Implementer:** dcc-superpower-companions:impl-sonnet-medium
|
|
||||||
**Evaluation:** files 1 - spec 0 - coupling 1 - risk 0 = 2
|
|
||||||
|
|
||||||
- [ ] **Step 1: Set the variable in the dev compose file**
|
|
||||||
|
|
||||||
`docker-compose.dev.yml` runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed default resolves to a relative path inside the container and misses the `backend-db:/app/data` volume. In `docker-compose.dev.yml`, find:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Matches the read-only mount above. Set explicitly because the backend's
|
|
||||||
# fallback only points here when NODE_ENV=production.
|
|
||||||
- FLASHPOINT_PATH=/data/flashpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
Insert directly after it:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Same reason as FLASHPOINT_PATH above: this container runs
|
|
||||||
# NODE_ENV=development, so the production default would put Ruffle outside
|
|
||||||
# the data volume and lose every update on recreate.
|
|
||||||
- RUFFLE_DATA_PATH=/app/data/ruffle
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update both volume comments**
|
|
||||||
|
|
||||||
In `docker-compose.yml`, find:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
|
||||||
- ${DATA_PATH:-./data}:/app/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
|
||||||
# Holds user.db and the installed Ruffle. Without this mount an in-app
|
|
||||||
# Ruffle update is lost the next time the container is recreated.
|
|
||||||
- ${DATA_PATH:-./data}:/app/data
|
|
||||||
```
|
|
||||||
|
|
||||||
In `docker-compose.dev.yml`, find:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
|
||||||
- backend-db:/app/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
|
||||||
# Holds user.db and the installed Ruffle. Without this mount an in-app
|
|
||||||
# Ruffle update is lost the next time the container is recreated.
|
|
||||||
- backend-db:/app/data
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Document the variable**
|
|
||||||
|
|
||||||
In `docs/09-deployment/environment-variables.md`, find this table:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
|
|
||||||
| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend |
|
|
||||||
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
|
|
||||||
```
|
|
||||||
|
|
||||||
Add one row to it:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify both compose files still parse**
|
|
||||||
|
|
||||||
Run from the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml config --quiet
|
|
||||||
docker compose -f docker-compose.dev.yml config --quiet
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: both exit 0 and print nothing. Each file requires `FLASHPOINT_HOST_PATH` and `JWT_SECRET`; if the repository has no `.env`, supply throwaway values inline:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.yml config --quiet
|
|
||||||
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.dev.yml config --quiet
|
|
||||||
```
|
|
||||||
|
|
||||||
If the Docker CLI is unavailable in this environment, say so rather than skipping silently, and parse both files directly instead:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node -e "const y=require('yaml');const fs=require('fs');for(const f of ['docker-compose.yml','docker-compose.dev.yml']){y.parse(fs.readFileSync(f,'utf8'));console.log(f,'ok')}"
|
|
||||||
```
|
|
||||||
|
|
||||||
The `yaml` package is already present in the repository's `node_modules` as a transitive dependency. This proves the YAML parses; it does not check Compose semantics, so report which of the two checks you ran.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prettier --write docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
|
|
||||||
git add docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
|
|
||||||
git commit -m "docs(ruffle): document the persisted install path"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After Task 4, confirm the whole change from the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run typecheck
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then from `backend/`: `npx vitest run`
|
|
||||||
|
|
||||||
Expected: typecheck clean, build succeeds, backend suite green.
|
|
||||||
|
|
||||||
Manual confirmation in a container, which is what the reported bug needs:
|
|
||||||
|
|
||||||
1. `docker compose -f docker-compose.dev.yml up -d --build`
|
|
||||||
2. Settings → Update → update Ruffle, and note the version.
|
|
||||||
3. `docker compose -f docker-compose.dev.yml up -d --force-recreate`
|
|
||||||
4. Settings → Update still reports the updated version, and a hard-refreshed
|
|
||||||
browser loads it.
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
# Downloaded games: page, filter, and settings
|
|
||||||
|
|
||||||
Date: 2026-08-11
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
1. A "Downloaded" sidebar entry and page listing only downloaded games.
|
|
||||||
2. A downloaded filter in the filter panel on the existing browse pages.
|
|
||||||
3. An admin setting to show or hide the Downloaded page.
|
|
||||||
4. An admin setting that lets guests see the Downloaded page, offered only when
|
|
||||||
the page itself is enabled.
|
|
||||||
5. An admin setting that turns the downloaded filter on by default.
|
|
||||||
|
|
||||||
## Definition of "downloaded"
|
|
||||||
|
|
||||||
A game is downloaded when it has at least one `game_data` row with
|
|
||||||
`presentOnDisk = 1`.
|
|
||||||
|
|
||||||
Games with no `game_data` rows at all — legacy titles served from htdocs — can
|
|
||||||
never be downloaded under this definition and never appear on the page. This
|
|
||||||
matches the existing per-game badge, which reads the same flag through the
|
|
||||||
`MAX(presentOnDisk)` post-processing in `GameService.searchGames`.
|
|
||||||
|
|
||||||
## Keeping the flag truthful
|
|
||||||
|
|
||||||
`presentOnDisk` is currently written only by the Flashpoint Launcher and by the
|
|
||||||
explicit Download button (`DownloadManager` → `GameDatabaseUpdater.markAsDownloaded`).
|
|
||||||
Three gaps must close before the flag can drive a whole page.
|
|
||||||
|
|
||||||
**Play-path downloads never set it.** `GameZipServer.downloadAndMountInBackground`
|
|
||||||
writes the ZIP and returns without touching the database. On success it will call
|
|
||||||
`GameDatabaseUpdater.markAsDownloaded`. The `game_data.id` is already loaded by
|
|
||||||
`GameDataService.getGameDataEntry`; thread it through the `mountZip` params rather
|
|
||||||
than re-querying by `gameId` + `dateAdded`. Mark the row even when `sha256` is
|
|
||||||
absent, since `GameDataDownloader` only verifies a checksum when one exists and the
|
|
||||||
file is otherwise a complete, mountable download.
|
|
||||||
|
|
||||||
**Local-copy mode erases the flag.** With `ENABLE_LOCAL_DB_COPY=true`, queries and
|
|
||||||
our writes hit `config.localDbPath`, and `DatabaseService.syncAndReload` copies the
|
|
||||||
source over that file whenever the Launcher's database changes. A new
|
|
||||||
`DownloadedReconciler` service restores truth from disk:
|
|
||||||
|
|
||||||
- Scan `config.flashpointGamesPath` for files matching `{uuid}-{timestamp}.zip`,
|
|
||||||
the deterministic name produced by `GameDataDownloader.getFilename`.
|
|
||||||
- Resolve each to its `game_data` row by `gameId` plus the timestamp derived from
|
|
||||||
`dateAdded`, and bulk-set `presentOnDisk = 1` in a single transaction, skipping
|
|
||||||
rows already flagged.
|
|
||||||
- Update `game.activeDataOnDisk` where the marked row is the game's active data.
|
|
||||||
- Run once at startup after `DatabaseService` initialization, and again after every
|
|
||||||
`syncAndReload`. Both runs are fire-and-forget with a `.catch()`; an unreadable
|
|
||||||
games directory logs a warning and yields zero marks.
|
|
||||||
|
|
||||||
The reconciler only ever marks. It never clears the flag for a missing ZIP, because
|
|
||||||
the Launcher can store game data outside `flashpointGamesPath` and we would be
|
|
||||||
overwriting its state with a false negative.
|
|
||||||
|
|
||||||
This scan also backfills every game downloaded through the Play path before this
|
|
||||||
feature ships, which is otherwise the largest population of invisible downloads.
|
|
||||||
|
|
||||||
**Our own writes trigger a full reload.** In non-copy mode the write lands in the
|
|
||||||
file `DatabaseService` watches, so the watcher fires, reopens the connection, and
|
|
||||||
clears the search, flash-SWF, and filter-options caches — per download. Add
|
|
||||||
`DatabaseService.noteSelfWrite()`, which re-stats the source and advances
|
|
||||||
`lastModifiedTime` so `syncAndReload` short-circuits, and call it from
|
|
||||||
`markAsDownloaded` and the reconciler. The caller then clears only the search and
|
|
||||||
filter-options caches, so a freshly downloaded game appears immediately.
|
|
||||||
|
|
||||||
A Launcher write landing in the same millisecond as ours could be swallowed by this
|
|
||||||
check. The consequence is a delayed reload, not data loss, and the next Launcher
|
|
||||||
write recovers it.
|
|
||||||
|
|
||||||
## Backend filter
|
|
||||||
|
|
||||||
`downloaded?: boolean` joins the POST `/api/games` body schema, `GameSearchQuery`,
|
|
||||||
and the filter-options query schema. `true` restricts to downloaded games;
|
|
||||||
`false` and `undefined` both mean "no restriction", and the frontend omits the
|
|
||||||
field when the filter is off.
|
|
||||||
|
|
||||||
In `GameService.searchGames` the condition is:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
AND EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = g.id AND gd.presentOnDisk = 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
`buildFilterOptionsConditions` gets the same condition so dropdown values and the
|
|
||||||
year range narrow to downloaded games; qualify the outer column with its table name
|
|
||||||
there, since those queries have no alias and `game_data` has its own `id` column.
|
|
||||||
|
|
||||||
Both `GameSearchCache.generateCacheKey` and `GameService.getFilterOptionsCacheKey`
|
|
||||||
include `downloaded`, normalized with `??` so `undefined` and `false` stay distinct.
|
|
||||||
Add `'downloaded'` to the `filters` array in the search route's `logActivity`
|
|
||||||
callback so the recorded filter count stays accurate.
|
|
||||||
|
|
||||||
## Frontend filter
|
|
||||||
|
|
||||||
`GameFilters.downloaded?: boolean` carries the value to the API.
|
|
||||||
`FilterUrlParams.downloaded?: '0' | '1'` carries it in the URL under the unused
|
|
||||||
single-character key `w`, guarded with `!== undefined` rather than truthiness so an
|
|
||||||
explicit off survives `toUrlSafeFormat`.
|
|
||||||
|
|
||||||
The value is tri-state: `w.1` on, `w.0` off, absent means "use the admin default".
|
|
||||||
`GameBrowseLayout` resolves it once and passes a plain boolean down.
|
|
||||||
|
|
||||||
Because absent means "default", removal cannot clear the parameter when the default
|
|
||||||
is on — the filter would immediately reapply. When the admin default is on,
|
|
||||||
`handleRemoveChip` and `handleClearAllFilters` write `w.0` instead of dropping the
|
|
||||||
key; when it is off, they clear it as every other filter does. `CATEGORY_TO_ABBR`
|
|
||||||
gains `Downloaded: 'W'` and `categoryToParamKeys` gains the matching entry so the
|
|
||||||
chip participates in the existing filter hierarchy.
|
|
||||||
|
|
||||||
A bookmarked URL with no `w` changes meaning if an admin later flips the default.
|
|
||||||
That is inherent to the tri-state and accepted.
|
|
||||||
|
|
||||||
`FilterPanel` renders a "Downloaded only" switch beside the year range in both the
|
|
||||||
desktop and mobile rows, and an active filter chip. A new `showDownloadedFilter`
|
|
||||||
prop hides the switch on the Downloaded page, where the filter is the page.
|
|
||||||
|
|
||||||
## Downloaded page
|
|
||||||
|
|
||||||
Route `/downloaded` renders `DownloadedView`, a thin wrapper over
|
|
||||||
`GameBrowseLayout` with the filter pinned on and no library restriction, so results
|
|
||||||
span arcade and theatre. `GameBrowseLayout`'s `library` prop becomes optional and it
|
|
||||||
gains `forceDownloaded`.
|
|
||||||
|
|
||||||
The page passes `sectionKey={null}`, so game links resolve to the existing
|
|
||||||
`/games/:id` detail route. No new per-section detail or play routes.
|
|
||||||
|
|
||||||
## Settings
|
|
||||||
|
|
||||||
Migration `004_downloaded_settings.sql` seeds three public boolean settings in the
|
|
||||||
`features` category, following the existing `enable_*` naming:
|
|
||||||
|
|
||||||
| Key | Default | Effect |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `features.enable_downloaded_page` | 1 | Sidebar entry and `/downloaded` route |
|
|
||||||
| `features.enable_downloaded_page_for_guests` | 0 | Guests may see the page |
|
|
||||||
| `features.enable_downloaded_filter_default` | 0 | Browse pages start filtered |
|
|
||||||
|
|
||||||
`useFeatureFlags` exposes `enableDownloadedPage` with the usual admin bypass, so an
|
|
||||||
admin cannot lock themselves out. The other two are read as `=== true`: they default
|
|
||||||
off, and the hook's `?? true` fallback would otherwise invert them during the
|
|
||||||
public-settings loading window and whenever the key is missing.
|
|
||||||
|
|
||||||
`ProtectedRoute` gains `'enableDownloadedPage'` in its `requireFeature` union and a
|
|
||||||
new `requireFeatureForGuests` prop, checked only when the viewer is a guest, so a
|
|
||||||
guest deep-linking to `/downloaded` with the guest flag off is redirected to
|
|
||||||
`/unauthorized`. A flag check alone would let them through, since guests satisfy
|
|
||||||
`requireAuth`.
|
|
||||||
|
|
||||||
These flags are presentation, not access control: `POST /api/games` is open to
|
|
||||||
guests and accepts `downloaded` regardless, and the page shows no data a guest
|
|
||||||
cannot already reach by filtering Browse. No backend enforcement is added.
|
|
||||||
|
|
||||||
`FeaturesSettingsTab` gains the three switches, with the guest row rendered only
|
|
||||||
while the page flag is on. Its stored value persists while hidden and resurfaces if
|
|
||||||
the page is re-enabled.
|
|
||||||
|
|
||||||
## Sidebar
|
|
||||||
|
|
||||||
The Downloaded entry sits in the Library section with Favorites. That block is
|
|
||||||
currently hidden wholesale for guests, so its items never check guest status
|
|
||||||
themselves. Relaxing the block requires giving each item its own predicate:
|
|
||||||
Favorites and both playlist entries become explicitly `!isGuest`, Downloaded
|
|
||||||
becomes `enableDownloadedPage && (!isGuest || enableDownloadedPageForGuests)`, and
|
|
||||||
the section renders when any item survives. The existing
|
|
||||||
`isViewingSharedPlaylistWithoutGuestAccess` case is unaffected.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `GameService.searchGames` returns only flagged games with `downloaded: true`, and
|
|
||||||
is unaffected when the field is absent.
|
|
||||||
- Filter options narrow when `downloaded` is set, and cache keys distinguish
|
|
||||||
`undefined`, `false`, and `true`.
|
|
||||||
- `DownloadedReconciler` maps ZIP filenames to the right `game_data` rows, ignores
|
|
||||||
unrelated files, and marks nothing when the directory is unreadable.
|
|
||||||
- `filterUrlCompression` round-trips `w.0` and `w.1`, and an explicit off survives
|
|
||||||
`buildFilterSearchParams`.
|
|
||||||
- Chip removal writes an explicit off when the admin default is on.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Update `docs/06-api-reference/games-api.md` (search and filter-options parameters),
|
|
||||||
`docs/06-api-reference/settings-api.md` (three new settings), and
|
|
||||||
`docs/10-features/02-game-browsing-filtering.md` (the filter and the page).
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
# Web app update check on the Update page
|
|
||||||
|
|
||||||
Date: 2026-08-12
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
An admin opening Settings → Update sees whether a newer release of Flashpoint
|
|
||||||
Web exists, what changed in it, and how to upgrade.
|
|
||||||
|
|
||||||
The card notifies only. It never pulls an image, restarts a container, or writes
|
|
||||||
anything: self-updating from inside the container needs the Docker socket
|
|
||||||
mounted, which hands the container root-equivalent control of the host.
|
|
||||||
|
|
||||||
## What counts as an update
|
|
||||||
|
|
||||||
The running version comes from `APP_VERSION`, an environment variable baked into
|
|
||||||
the image from the `VERSION` build arg that CI already passes (see
|
|
||||||
`.github/workflows/docker-build-push.yml`). This mirrors `VITE_APP_VERSION`,
|
|
||||||
which the frontend build consumes for the same value.
|
|
||||||
|
|
||||||
A version counts as a release build only when it is three clean numbers, with an
|
|
||||||
optional leading `v` — `1.0.38` or `v1.0.38`. Anything else is an **unreleased
|
|
||||||
build**: the variable unset, `dev` from a local `docker build`, `latest` from a
|
|
||||||
`workflow_dispatch` run, a release candidate like `1.1.0-rc1`, or a
|
|
||||||
`git describe` string like `1.0.38-5-gabc123`. An unreleased build never claims
|
|
||||||
to be behind, because its commits may already include everything in the latest
|
|
||||||
release.
|
|
||||||
|
|
||||||
The comparison target is GitHub's
|
|
||||||
`/repos/darkraise/flashpoint-web/releases/latest`, which excludes prereleases by
|
|
||||||
definition. No prerelease filtering is needed, and release candidates never
|
|
||||||
surface.
|
|
||||||
|
|
||||||
Verified against the live endpoint on 2026-08-12: it answers 200 with
|
|
||||||
`tag_name: "v1.0.38"`, `prerelease: false`, `published_at`, `html_url`, and a
|
|
||||||
markdown `body`. The tag carries a leading `v` that `APP_VERSION` does not, so
|
|
||||||
both sides are stripped before comparing — the tag always has one, and a manual
|
|
||||||
`docker build --build-arg VERSION=v1.0.38` would otherwise be misread as
|
|
||||||
unreleased.
|
|
||||||
|
|
||||||
## The two version sources must not contradict each other
|
|
||||||
|
|
||||||
The frontend already resolves its own version at build time
|
|
||||||
(`frontend/vite.config.ts`, `frontend/src/lib/version.ts`) and shows it on the
|
|
||||||
General tab as "Web App Version". This design adds a second, server-side source,
|
|
||||||
and the two sit one tab apart.
|
|
||||||
|
|
||||||
In Docker they agree: both build stages receive the same `VERSION` build arg. In
|
|
||||||
a source checkout they can differ — the frontend falls back to `git describe`,
|
|
||||||
while the backend has no `APP_VERSION` at all — so the General tab could read
|
|
||||||
`1.0.38` while the card reads "unreleased build". The card's copy therefore
|
|
||||||
attributes the state to the server rather than to the operator: "The server did
|
|
||||||
not report a released version, so update checks are off."
|
|
||||||
|
|
||||||
`APP_VERSION` is documented in `docs/09-deployment/environment-variables.md` as
|
|
||||||
an optional operator-settable variable, so a non-Docker deployment can opt into
|
|
||||||
update checks.
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
### `compareVersions(a, b)` — `backend/src/utils/version.ts`
|
|
||||||
|
|
||||||
Returns a negative number, zero, or a positive number by comparing major, minor,
|
|
||||||
and patch numerically, after stripping an optional leading `v` from each side.
|
|
||||||
Returns `null` when either input is not exactly three numeric parts, which is
|
|
||||||
what routes an unreleased build away from the comparison. Numeric comparison
|
|
||||||
matters: string ordering puts `1.0.9` above `1.0.38`.
|
|
||||||
|
|
||||||
A `null` result from a malformed *latest* tag — a hand-cut release tagged `v2.0`
|
|
||||||
— yields `updateAvailable: false`. Reporting nothing is preferable to reporting
|
|
||||||
a wrong direction.
|
|
||||||
|
|
||||||
### `AppUpdateService` — `backend/src/services/AppUpdateService.ts`
|
|
||||||
|
|
||||||
One public method, `getUpdateInfo(force = false)`, returning:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface AppUpdateInfo {
|
|
||||||
currentVersion: string | null; // null when APP_VERSION is unset
|
|
||||||
latestVersion: string | null; // null when GitHub has never been reached
|
|
||||||
updateAvailable: boolean;
|
|
||||||
isUnreleasedBuild: boolean;
|
|
||||||
publishedAt: string | null;
|
|
||||||
releaseUrl: string | null;
|
|
||||||
changelog: string | null;
|
|
||||||
checkedAt: string | null; // when the held result was fetched
|
|
||||||
lastCheckFailed: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The result is cached in memory for one hour and shared by every admin. The cache
|
|
||||||
holds the last *successful* result; a failed check returns that result with
|
|
||||||
`lastCheckFailed: true` rather than discarding it.
|
|
||||||
|
|
||||||
**Concurrency.** The in-flight fetch promise is stored and returned to
|
|
||||||
concurrent callers, so a cold cache hit by several requests at once produces one
|
|
||||||
GitHub call, not several.
|
|
||||||
|
|
||||||
**Forced refresh.** `force` bypasses the one-hour cache but not a 60-second
|
|
||||||
floor: a forced check within 60 seconds of the last fetch returns the held
|
|
||||||
result. `rateLimitStandard` is 100 requests per 60-second window
|
|
||||||
(`backend/src/config.ts`, `RATE_LIMIT_MAX_REQUESTS`), which is no protection for
|
|
||||||
a 60-per-hour unauthenticated GitHub quota that this feature shares with the
|
|
||||||
star-count proxy and the Ruffle update check. The floor is what keeps the quota
|
|
||||||
intact.
|
|
||||||
|
|
||||||
`isUnreleasedBuild: true` still fetches and reports `latestVersion` and the
|
|
||||||
changelog for reference, but forces `updateAvailable: false`.
|
|
||||||
|
|
||||||
### Failure handling
|
|
||||||
|
|
||||||
The route always answers 200. A total failure — no cached result and GitHub
|
|
||||||
unreachable — returns `latestVersion: null` with `lastCheckFailed: true`, and
|
|
||||||
the card renders its failed state.
|
|
||||||
|
|
||||||
It does **not** throw `AppError(502)`, because the axios interceptor in
|
|
||||||
`frontend/src/lib/api/client.ts` fires a global "Server error occurred" toast for
|
|
||||||
any status at or above 500. A card that renders its own inline error would
|
|
||||||
double up with that toast, which is what the Ruffle card does today.
|
|
||||||
|
|
||||||
`backend/src/routes/github.ts` is the precedent for serving a stale result, but
|
|
||||||
it is a weaker version of this design and should not be copied line for line: it
|
|
||||||
throws a plain `Error`, has no request timeout, and its stale path covers only a
|
|
||||||
non-OK response, so a network failure or hang bypasses the cache entirely. Here
|
|
||||||
the `try` wraps the whole fetch, and the request carries an explicit timeout.
|
|
||||||
|
|
||||||
### `GET /api/updates/app`
|
|
||||||
|
|
||||||
Added to `backend/src/routes/updates.ts` behind `authenticate` and
|
|
||||||
`requirePermission('settings.update')` from `../middleware/rbac`. That matches
|
|
||||||
the tab's own gate in `UpdateSettingsTab.tsx`; note that two GETs in that file
|
|
||||||
use `settings.read`, so "the same as its neighbours" is not exact.
|
|
||||||
|
|
||||||
The handler returns the bare `AppUpdateInfo` object — `res.json(info)` — as
|
|
||||||
every endpoint in `updates.ts` does and as `updatesApi` expects. The
|
|
||||||
`{success, data}` envelope in `github.ts` is that router's convention, not this
|
|
||||||
one.
|
|
||||||
|
|
||||||
`?refresh=true` sets `force`, read as `req.query.refresh === 'true'`. Any other
|
|
||||||
value, array, or object is falsy, so no schema or bounds check is warranted; the
|
|
||||||
60-second floor is the real control. `rateLimitStandard` already applies to the
|
|
||||||
whole router.
|
|
||||||
|
|
||||||
### `config.appVersion`
|
|
||||||
|
|
||||||
`process.env.APP_VERSION?.trim() || null`, added to `backend/src/config.ts`
|
|
||||||
beside the Flashpoint version fields. `||` is deliberate here and must not be
|
|
||||||
"corrected" to `??`: an empty or whitespace-only variable has to collapse to
|
|
||||||
`null`, which `??` would not do.
|
|
||||||
|
|
||||||
### Dockerfile
|
|
||||||
|
|
||||||
The final stage already declares `ARG VERSION=dev` for the image label. It gains
|
|
||||||
`ENV APP_VERSION=$VERSION`, since an `ARG` alone does not survive into the
|
|
||||||
running container.
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
`AppUpdateCard` — `frontend/src/components/settings/AppUpdateCard.tsx` — renders
|
|
||||||
first in `UpdateSettingsTab`, above the metadata and Ruffle cards, admin-only
|
|
||||||
like both of them.
|
|
||||||
|
|
||||||
A `useQuery` on mount performs the check. The Check button cannot be a
|
|
||||||
`refetch()`, which reuses the original query function and so cannot add
|
|
||||||
`refresh=true`; it is a `useMutation` calling the endpoint with the flag, writing
|
|
||||||
its result back through `queryClient.setQueryData`. This is the shape the Ruffle
|
|
||||||
card already uses.
|
|
||||||
|
|
||||||
The card reuses the vocabulary of `RuffleManagementCard`: a collapsible
|
|
||||||
changelog rendered with `react-markdown` and `remark-gfm`, `FormattedDate` for
|
|
||||||
the release date, the same button placement and toast behaviour. No sanitizer is
|
|
||||||
added — without `rehype-raw`, react-markdown escapes raw HTML, and the source is
|
|
||||||
the project's own CI-generated release notes.
|
|
||||||
|
|
||||||
Five states:
|
|
||||||
|
|
||||||
| State | Condition | Shows |
|
|
||||||
| ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Update available | `updateAvailable` | Both versions, release date, changelog expanded, link to the release, upgrade commands |
|
|
||||||
| Up to date | versions match | Current version and when it was checked |
|
|
||||||
| Unreleased build | `isUnreleasedBuild` | "The server did not report a released version, so update checks are off", plus the latest release for reference |
|
|
||||||
| Stale | `lastCheckFailed` with a `latestVersion` | The held result, labelled as a check that failed at `checkedAt` |
|
|
||||||
| Check failed | `lastCheckFailed`, no `latestVersion` | A muted error and a retry button |
|
|
||||||
|
|
||||||
The upgrade command is `docker compose pull && docker compose up -d`, matching
|
|
||||||
the deployment `docker-compose.yml` documents. That file pins
|
|
||||||
`image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}`, so the card adds one
|
|
||||||
line: an operator who set `IMAGE_TAG` to a fixed version must raise it first, or
|
|
||||||
the pull is a no-op.
|
|
||||||
|
|
||||||
The release body generated by CI opens with its own "## Docker Image" section
|
|
||||||
carrying a `docker pull` line, so the changelog repeats part of this. The card
|
|
||||||
still states the commands itself, because the compose commands are the actual
|
|
||||||
upgrade and the changelog is collapsible.
|
|
||||||
|
|
||||||
The API call goes through `updatesApi` in `frontend/src/lib/api/updates.ts`, with
|
|
||||||
`AppUpdateInfo` declared there and re-exported from `lib/api/index.ts`, as the
|
|
||||||
other update types are.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Backend unit tests in the repo's existing style, with no test for the card —
|
|
||||||
`components/settings` has no test coverage today and this change does not
|
|
||||||
introduce the pattern.
|
|
||||||
|
|
||||||
- `compareVersions`: equal, newer, older, multi-digit ordering (`1.0.38` above
|
|
||||||
`1.0.9`), a leading `v` on either or both sides, and malformed inputs (`dev`,
|
|
||||||
`latest`, `1.0.38-5-gabc123`, `1.0`, empty).
|
|
||||||
- `AppUpdateService` with a mocked fetch: update available, up to date,
|
|
||||||
unreleased build, malformed latest tag, cache hit inside the hour, forced
|
|
||||||
refresh honoured after the 60-second floor and ignored inside it, one GitHub
|
|
||||||
call for concurrent cold-cache requests, a held result returned with
|
|
||||||
`lastCheckFailed` after a failure, and a 200 with `latestVersion: null` when a
|
|
||||||
failure has no cache to fall back on.
|
|
||||||
|
|
||||||
## Decisions taken
|
|
||||||
|
|
||||||
- **Notify only.** Self-update requires the Docker socket; a restart-only button
|
|
||||||
cannot pull, so it would only ever help someone who had already pulled.
|
|
||||||
- **The repository is hardcoded** to `darkraise/flashpoint-web`, as it already is
|
|
||||||
in the star-count proxy. A fork wanting its own release feed would need this
|
|
||||||
made configurable; nothing here is designed to prevent that later.
|
|
||||||
- **Docker Compose is the documented deployment**, so it is the only upgrade
|
|
||||||
command shown.
|
|
||||||
- **No notification badge** elsewhere in the UI, and no scheduled background
|
|
||||||
check. The cached on-open check covers the stated goal; a `JobScheduler` job
|
|
||||||
would be the way to add a badge later.
|
|
||||||
|
|
||||||
## Known limitation, not addressed here
|
|
||||||
|
|
||||||
A `workflow_dispatch` run of the build workflow tags the image `:latest` and
|
|
||||||
passes `VERSION=latest`, so that image reports an unreleased build. The card
|
|
||||||
makes a pre-existing CI wart visible; fixing it means changing what the manual
|
|
||||||
trigger publishes, which is out of scope for this feature.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# Persistent Ruffle installation
|
|
||||||
|
|
||||||
Date: 2026-08-13
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
A Ruffle version installed through Settings → Update survives container
|
|
||||||
recreation. Today it does not: the update lands in the container's writable
|
|
||||||
layer, and the next `docker compose up -d` after an image pull or a config
|
|
||||||
change throws it away.
|
|
||||||
|
|
||||||
## Why it reverts
|
|
||||||
|
|
||||||
`RuffleService` installs into `config.frontendDistPath/ruffle` — `/app/frontend/dist/ruffle`
|
|
||||||
inside the container. The only mounted volumes are `/data/flashpoint`,
|
|
||||||
`/app/data`, and `/app/logs`, so that path lives entirely in the overlay
|
|
||||||
writable layer.
|
|
||||||
|
|
||||||
`frontend/public/ruffle/` is committed to the repository at
|
|
||||||
`0.2.0-nightly.2026.1.29`. Vite copies `public/` into `frontend/dist/` at build
|
|
||||||
time and the Dockerfile bakes that directory into the image. When a recreate
|
|
||||||
discards the writable layer, the baked copy is what remains.
|
|
||||||
|
|
||||||
Startup only downloads when `verifyInstallation()` is false
|
|
||||||
(`backend/src/server.ts:302`). The baked copy satisfies that check, so the
|
|
||||||
server logs the stale version and keeps it. The scheduled `ruffle-update` job
|
|
||||||
would eventually re-apply the update, but only if that job is enabled.
|
|
||||||
|
|
||||||
A plain `docker restart` preserves the writable layer and does not trigger this.
|
|
||||||
The recreate paths do: `docker compose up -d` after an image pull or any change
|
|
||||||
to the resolved service config, `down` then `up`, `--force-recreate`, and the
|
|
||||||
update actions in Portainer and Unraid.
|
|
||||||
|
|
||||||
## Install location
|
|
||||||
|
|
||||||
A new `config.ruffleDataPath` names the install directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
process.env.RUFFLE_DATA_PATH ??
|
|
||||||
(NODE_ENV === 'production' ? '/app/data/ruffle' : <repo>/backend/data/ruffle)
|
|
||||||
```
|
|
||||||
|
|
||||||
`RuffleService` swaps only its `serveFrontend` branch to this path. The other
|
|
||||||
branch keeps writing to `frontend/public/ruffle`, because in a source checkout
|
|
||||||
Vite serves `public/` and an install anywhere else would not be reachable.
|
|
||||||
|
|
||||||
`docker-compose.dev.yml` sets `RUFFLE_DATA_PATH=/app/data/ruffle` explicitly. It
|
|
||||||
runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed
|
|
||||||
default would miss its `backend-db:/app/data` volume. The file already sets
|
|
||||||
`FLASHPOINT_PATH` for exactly this reason, and the new variable follows that
|
|
||||||
precedent. Production compose needs no change: `NODE_ENV=production` already
|
|
||||||
resolves to the right path.
|
|
||||||
|
|
||||||
The entrypoint chowns `/app/data` to the app user on every start, so a non-default
|
|
||||||
`PUID` can write the new directory without further work.
|
|
||||||
|
|
||||||
## Seeding a fresh install
|
|
||||||
|
|
||||||
`RuffleService.ensureInstalled()` replaces the inline block at
|
|
||||||
`backend/src/server.ts:299-314` and resolves in three steps:
|
|
||||||
|
|
||||||
1. `ruffle.js` exists at the data path — log the version and stop.
|
|
||||||
2. Otherwise the image's bundled `frontend/dist/ruffle` exists — copy it into
|
|
||||||
the data path via the existing private `copyDirectory`.
|
|
||||||
3. Otherwise — download the latest release from GitHub, which is the current
|
|
||||||
behaviour.
|
|
||||||
|
|
||||||
Seeding from the bundle rather than downloading means Flash works the moment a
|
|
||||||
fresh container is up, and works at all on a host that cannot reach GitHub. The
|
|
||||||
copy is roughly 20 MB and happens once per persisted volume, not once per
|
|
||||||
container.
|
|
||||||
|
|
||||||
The call stays fire-and-forget in the background. Awaiting it would hold the
|
|
||||||
port closed for the duration of a download, which is the regression the current
|
|
||||||
comment at `server.ts:296-298` records.
|
|
||||||
|
|
||||||
## Serving
|
|
||||||
|
|
||||||
`registerFrontendServing` mounts the data path at `/ruffle` **before**
|
|
||||||
`express.static(distPath)`, so the persisted copy shadows the baked one. The
|
|
||||||
mount uses `fallthrough: true`: if the persisted tree is missing a file, the
|
|
||||||
baked `dist/ruffle` still answers rather than 404ing into a dead player.
|
|
||||||
|
|
||||||
The frontend needs no change. `RufflePlayer.tsx` already loads
|
|
||||||
`/ruffle/ruffle.js` and sets `publicPath: '/ruffle/'`, both of which the new
|
|
||||||
mount serves.
|
|
||||||
|
|
||||||
## Cache headers
|
|
||||||
|
|
||||||
The dist handler currently serves everything with `maxAge: '1y', immutable: true`,
|
|
||||||
including `ruffle/ruffle.js`, which is not content-hashed. A browser that has
|
|
||||||
loaded the page once will therefore keep the old loader for a year, so
|
|
||||||
persistence alone would not make an update visible to returning users.
|
|
||||||
|
|
||||||
A shared helper decides the policy from the basename: `immutable, max-age=1y`
|
|
||||||
when it contains a hex run of 16 or more characters, otherwise
|
|
||||||
`no-cache, no-store, must-revalidate`. Ruffle's chunks carry such a run
|
|
||||||
(`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`) while its
|
|
||||||
stable entry points do not (`ruffle.js`, `ruffle.js.map`, `package.json`).
|
|
||||||
Detecting the hash rather than listing the stable names keeps the rule correct
|
|
||||||
when a future Ruffle release adds a file.
|
|
||||||
|
|
||||||
The helper applies to both the new `/ruffle` mount and to `ruffle/` paths under
|
|
||||||
the dist handler, so the fallback cannot reintroduce a stale loader.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
`RuffleService.test.ts` mocks `../config`; that mock gains `ruffleDataPath` and
|
|
||||||
the existing `ruffleDir()` helper points at it. The update tests are otherwise
|
|
||||||
unchanged — they exercise the rename and copy fallbacks, which the new path does
|
|
||||||
not alter.
|
|
||||||
|
|
||||||
New cases cover `ensureInstalled()`:
|
|
||||||
|
|
||||||
- an existing install is left alone and no download is attempted;
|
|
||||||
- an empty data path with a bundled copy present is seeded from the bundle, and
|
|
||||||
no download is attempted;
|
|
||||||
- an empty data path with no bundle falls through to the download.
|
|
||||||
|
|
||||||
A new `frontendServing` test asserts the `/ruffle` mount is registered ahead of
|
|
||||||
the dist handler, that `ruffle.js` comes back revalidating, and that a hashed
|
|
||||||
chunk comes back immutable.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
`config.userDbPath` and `config.tempDownloadPath` use the same
|
|
||||||
`NODE_ENV === 'production'` keying as the new variable. In the dev compose
|
|
||||||
container that evaluates to `development`, so `user.db` lands at
|
|
||||||
`/app/backend/user.db` and the `backend-db:/app/data` volume goes unused — dev
|
|
||||||
container app data is not persisted today. Real, but unrelated to Ruffle, and
|
|
||||||
touching database paths carries risk this change does not need.
|
|
||||||
+9
-67
@@ -1,4 +1,4 @@
|
|||||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useEffect, lazy, Suspense } from 'react';
|
import { useEffect, lazy, Suspense } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { MainLayout } from './components/layout/MainLayout';
|
import { MainLayout } from './components/layout/MainLayout';
|
||||||
@@ -41,12 +41,6 @@ const HomeView = lazy(() => import('./views/HomeView').then((m) => ({ default: m
|
|||||||
const BrowseView = lazy(() =>
|
const BrowseView = lazy(() =>
|
||||||
import('./views/BrowseView').then((m) => ({ default: m.BrowseView }))
|
import('./views/BrowseView').then((m) => ({ default: m.BrowseView }))
|
||||||
);
|
);
|
||||||
const FlashGamesView = lazy(() =>
|
|
||||||
import('./views/FlashGamesView').then((m) => ({ default: m.FlashGamesView }))
|
|
||||||
);
|
|
||||||
const HTML5GamesView = lazy(() =>
|
|
||||||
import('./views/HTML5GamesView').then((m) => ({ default: m.HTML5GamesView }))
|
|
||||||
);
|
|
||||||
const AnimationsView = lazy(() =>
|
const AnimationsView = lazy(() =>
|
||||||
import('./views/AnimationsView').then((m) => ({ default: m.AnimationsView }))
|
import('./views/AnimationsView').then((m) => ({ default: m.AnimationsView }))
|
||||||
);
|
);
|
||||||
@@ -95,6 +89,12 @@ const ActivitiesView = lazy(() =>
|
|||||||
import('./views/ActivitiesView').then((m) => ({ default: m.ActivitiesView }))
|
import('./views/ActivitiesView').then((m) => ({ default: m.ActivitiesView }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The old /flash and /html5 sections are now part of /browse */
|
||||||
|
function LegacySectionRedirect() {
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
return <Navigate to={pathname.replace(/^\/(flash|html5)/, '/browse') + search} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore();
|
const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore();
|
||||||
const isServerStarting = useServerStatusStore((state) => state.isStarting);
|
const isServerStarting = useServerStatusStore((state) => state.isStarting);
|
||||||
@@ -289,6 +289,8 @@ function App() {
|
|||||||
|
|
||||||
{/* ==================== STANDALONE ==================== */}
|
{/* ==================== STANDALONE ==================== */}
|
||||||
<Route path="/maintenance" element={<MaintenancePage />} />
|
<Route path="/maintenance" element={<MaintenancePage />} />
|
||||||
|
<Route path="/flash/*" element={<LegacySectionRedirect />} />
|
||||||
|
<Route path="/html5/*" element={<LegacySectionRedirect />} />
|
||||||
|
|
||||||
{/* ==================== PORTAL LAYOUT ==================== */}
|
{/* ==================== PORTAL LAYOUT ==================== */}
|
||||||
<Route element={<PortalLayout />}>
|
<Route element={<PortalLayout />}>
|
||||||
@@ -304,26 +306,6 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Public/guest accessible routes */}
|
{/* Public/guest accessible routes */}
|
||||||
<Route
|
|
||||||
path="/flash"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireAuth={false}>
|
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
|
||||||
<FlashGamesView />
|
|
||||||
</Suspense>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/html5"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireAuth={false}>
|
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
|
||||||
<HTML5GamesView />
|
|
||||||
</Suspense>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/browse"
|
path="/browse"
|
||||||
element={
|
element={
|
||||||
@@ -346,46 +328,6 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section-based game routes - must come before /games/:id */}
|
{/* Section-based game routes - must come before /games/:id */}
|
||||||
<Route
|
|
||||||
path="/flash/:id"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
|
||||||
<GamePage />
|
|
||||||
</Suspense>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/flash/:id/play"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requirePermission="games.play">
|
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
|
||||||
<GamePage autoPlay />
|
|
||||||
</Suspense>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/html5/:id"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
|
||||||
<GamePage />
|
|
||||||
</Suspense>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/html5/:id/play"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requirePermission="games.play">
|
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
|
||||||
<GamePage autoPlay />
|
|
||||||
</Suspense>
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/animations/:id"
|
path="/animations/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ interface GameBrowseLayoutProps {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
headerContent?: ReactNode;
|
headerContent?: ReactNode;
|
||||||
breadcrumbContext?: BreadcrumbContext;
|
breadcrumbContext?: BreadcrumbContext;
|
||||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
/** Section key for URL building ('animations', 'browse') */
|
||||||
sectionKey?: string | null;
|
sectionKey?: string | null;
|
||||||
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
|
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
|
||||||
forceDownloaded?: boolean;
|
forceDownloaded?: boolean;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface GameCardProps {
|
|||||||
isFavoritePage?: boolean;
|
isFavoritePage?: boolean;
|
||||||
shareToken?: string | null;
|
shareToken?: string | null;
|
||||||
breadcrumbContext?: BreadcrumbContext;
|
breadcrumbContext?: BreadcrumbContext;
|
||||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
/** Section key for URL building ('animations', 'browse') */
|
||||||
sectionKey?: string | null;
|
sectionKey?: string | null;
|
||||||
/** Rating aggregate data for this game */
|
/** Rating aggregate data for this game */
|
||||||
ratingData?: BatchRatingAggregate;
|
ratingData?: BatchRatingAggregate;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface GameGridProps {
|
|||||||
isFavoritePage?: boolean;
|
isFavoritePage?: boolean;
|
||||||
shareToken?: string | null;
|
shareToken?: string | null;
|
||||||
breadcrumbContext?: BreadcrumbContext;
|
breadcrumbContext?: BreadcrumbContext;
|
||||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
/** Section key for URL building ('animations', 'browse') */
|
||||||
sectionKey?: string | null;
|
sectionKey?: string | null;
|
||||||
/** Batch rating data keyed by game ID */
|
/** Batch rating data keyed by game ID */
|
||||||
ratingDataMap?: Record<string, BatchRatingAggregate>;
|
ratingDataMap?: Record<string, BatchRatingAggregate>;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface GameListProps {
|
|||||||
isFavoritePage?: boolean;
|
isFavoritePage?: boolean;
|
||||||
shareToken?: string | null;
|
shareToken?: string | null;
|
||||||
breadcrumbContext?: BreadcrumbContext;
|
breadcrumbContext?: BreadcrumbContext;
|
||||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
/** Section key for URL building ('animations', 'browse') */
|
||||||
sectionKey?: string | null;
|
sectionKey?: string | null;
|
||||||
/** Batch rating data keyed by game ID */
|
/** Batch rating data keyed by game ID */
|
||||||
ratingDataMap?: Record<string, BatchRatingAggregate>;
|
ratingDataMap?: Record<string, BatchRatingAggregate>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface GameListItemProps {
|
|||||||
isFavoritePage?: boolean;
|
isFavoritePage?: boolean;
|
||||||
shareToken?: string | null;
|
shareToken?: string | null;
|
||||||
breadcrumbContext?: BreadcrumbContext;
|
breadcrumbContext?: BreadcrumbContext;
|
||||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
/** Section key for URL building ('animations', 'browse') */
|
||||||
sectionKey?: string | null;
|
sectionKey?: string | null;
|
||||||
/** Rating aggregate data for this game */
|
/** Rating aggregate data for this game */
|
||||||
ratingData?: BatchRatingAggregate;
|
ratingData?: BatchRatingAggregate;
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export interface SectionIcon {
|
|||||||
* Section configuration for game routes
|
* Section configuration for game routes
|
||||||
*/
|
*/
|
||||||
export interface SectionConfig {
|
export interface SectionConfig {
|
||||||
/** URL prefix for game routes (e.g., '/flash') */
|
/** URL prefix for game routes (e.g., '/browse') */
|
||||||
prefix: string;
|
prefix: string;
|
||||||
/** Sidebar item path (e.g., '/flash') */
|
/** Sidebar item path (e.g., '/browse') */
|
||||||
sidebarPath: string;
|
sidebarPath: string;
|
||||||
/** Label for breadcrumbs */
|
/** Label for breadcrumbs */
|
||||||
breadcrumbLabel: string;
|
breadcrumbLabel: string;
|
||||||
@@ -28,20 +28,6 @@ export interface SectionConfig {
|
|||||||
* Section configurations keyed by section identifier
|
* Section configurations keyed by section identifier
|
||||||
*/
|
*/
|
||||||
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
|
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
|
||||||
flash: {
|
|
||||||
prefix: '/flash',
|
|
||||||
sidebarPath: '/flash',
|
|
||||||
breadcrumbLabel: 'Flash Games',
|
|
||||||
breadcrumbHref: '/flash',
|
|
||||||
icon: { type: 'image', value: '/images/Flash.png' },
|
|
||||||
},
|
|
||||||
html5: {
|
|
||||||
prefix: '/html5',
|
|
||||||
sidebarPath: '/html5',
|
|
||||||
breadcrumbLabel: 'HTML5 Games',
|
|
||||||
breadcrumbHref: '/html5',
|
|
||||||
icon: { type: 'image', value: '/images/HTML5.png' },
|
|
||||||
},
|
|
||||||
animations: {
|
animations: {
|
||||||
prefix: '/animations',
|
prefix: '/animations',
|
||||||
sidebarPath: '/animations',
|
sidebarPath: '/animations',
|
||||||
@@ -65,8 +51,6 @@ export const SECTION_CONFIGS: Record<string, SectionConfig> = {
|
|||||||
* When breadcrumb context exists, it takes priority over prefix matching.
|
* When breadcrumb context exists, it takes priority over prefix matching.
|
||||||
*/
|
*/
|
||||||
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
|
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
|
||||||
'/flash': ['/flash/'],
|
|
||||||
'/html5': ['/html5/'],
|
|
||||||
'/animations': ['/animations/'],
|
'/animations': ['/animations/'],
|
||||||
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
|
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
|
||||||
'/playlists': ['/playlists/'],
|
'/playlists': ['/playlists/'],
|
||||||
@@ -76,7 +60,7 @@ export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get section configuration from a URL pathname
|
* Get section configuration from a URL pathname
|
||||||
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
|
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
|
||||||
* @returns Section config if pathname matches a section route, null otherwise
|
* @returns Section config if pathname matches a section route, null otherwise
|
||||||
*/
|
*/
|
||||||
export function getSectionFromPath(pathname: string): SectionConfig | null {
|
export function getSectionFromPath(pathname: string): SectionConfig | null {
|
||||||
@@ -90,8 +74,8 @@ export function getSectionFromPath(pathname: string): SectionConfig | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get section key from a URL pathname
|
* Get section key from a URL pathname
|
||||||
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
|
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
|
||||||
* @returns Section key ('flash', 'html5', etc.) or null
|
* @returns Section key ('browse', 'animations') or null
|
||||||
*/
|
*/
|
||||||
export function getSectionKeyFromPath(pathname: string): string | null {
|
export function getSectionKeyFromPath(pathname: string): string | null {
|
||||||
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
|
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
|
||||||
@@ -105,9 +89,9 @@ export function getSectionKeyFromPath(pathname: string): string | null {
|
|||||||
/**
|
/**
|
||||||
* Build a section-based game URL
|
* Build a section-based game URL
|
||||||
* @param gameId - Game UUID
|
* @param gameId - Game UUID
|
||||||
* @param sectionKey - Section key ('flash', 'html5', 'animations', 'browse') or null
|
* @param sectionKey - Section key ('animations', 'browse') or null
|
||||||
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
|
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
|
||||||
* @returns URL string (e.g., '/flash/abc-123' or '/flash/abc-123/play')
|
* @returns URL string (e.g., '/browse/abc-123' or '/browse/abc-123/play')
|
||||||
*/
|
*/
|
||||||
export function buildSectionGameUrl(
|
export function buildSectionGameUrl(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
@@ -127,7 +111,7 @@ export function buildSectionGameUrl(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive breadcrumb context from a URL pathname
|
* Derive breadcrumb context from a URL pathname
|
||||||
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
|
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
|
||||||
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
|
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
|
||||||
*/
|
*/
|
||||||
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {
|
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export const handlers = [
|
|||||||
app: {
|
app: {
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
allow_registration: true,
|
allow_registration: true,
|
||||||
homeRecentHours: 24,
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
flashpointEdition: 'infinity',
|
flashpointEdition: 'infinity',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface AppSettings {
|
|||||||
maintenanceMessage: string;
|
maintenanceMessage: string;
|
||||||
defaultTheme?: string;
|
defaultTheme?: string;
|
||||||
defaultPrimaryColor?: string;
|
defaultPrimaryColor?: string;
|
||||||
homeRecentHours?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureSettings {
|
export interface FeatureSettings {
|
||||||
@@ -71,10 +70,7 @@ export interface Domain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicSettings {
|
export interface PublicSettings {
|
||||||
app: Pick<
|
app: Pick<AppSettings, 'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage'>;
|
||||||
AppSettings,
|
|
||||||
'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'
|
|
||||||
>;
|
|
||||||
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
|
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
|
||||||
features?: Partial<FeatureSettings>;
|
features?: Partial<FeatureSettings>;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
|
|
||||||
|
|
||||||
export function FlashGamesView() {
|
|
||||||
return (
|
|
||||||
<GameBrowseLayout
|
|
||||||
title="Flash Games"
|
|
||||||
library="arcade"
|
|
||||||
platform="Flash"
|
|
||||||
breadcrumbContext={{ label: 'Flash Games', href: '/flash' }}
|
|
||||||
sectionKey="flash"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
|
|
||||||
|
|
||||||
export function HTML5GamesView() {
|
|
||||||
return (
|
|
||||||
<GameBrowseLayout
|
|
||||||
title="HTML5 Games"
|
|
||||||
library="arcade"
|
|
||||||
platform="HTML5"
|
|
||||||
breadcrumbContext={{ label: 'HTML5 Games', href: '/html5' }}
|
|
||||||
sectionKey="html5"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user