Merge pull request 'Release prep: legal-safe setup, hosting requirements, in-app stream proxy, Gitea CI, cleanup' (#2) from release-prep into rebrand-and-docs
Reviewed-on: #2 Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #2.
This commit is contained in:
+6
-3
@@ -10,8 +10,13 @@ JWT_SECRET=replace-with-a-secure-random-string
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Container Images
|
# Container Images
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tag for the locally built images
|
# Built from source by default. To pull published images instead, set the
|
||||||
|
# registry path they live under (ending in /), then:
|
||||||
|
# docker compose pull && docker compose up -d
|
||||||
|
# IMAGE_PREFIX=registry.example.com/owner/
|
||||||
# IMAGE_TAG=latest
|
# IMAGE_TAG=latest
|
||||||
|
# "Latest release" API URL to check for updates (GitHub or Gitea); off when unset
|
||||||
|
# APP_UPDATE_URL=https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Port (host mapping)
|
# Port (host mapping)
|
||||||
@@ -100,14 +105,12 @@ 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`)
|
||||||
# and enable the profile. See docs/10-features/13-streaming.md.
|
# and enable the profile. See docs/10-features/13-streaming.md.
|
||||||
# COMPOSE_PROFILES=streaming
|
# COMPOSE_PROFILES=streaming
|
||||||
# STREAMING_API_TOKEN=
|
# STREAMING_API_TOKEN=
|
||||||
# STREAM_PORT=8081
|
|
||||||
# MAX_SESSIONS=4
|
# MAX_SESSIONS=4
|
||||||
# Sign in users an authenticating reverse proxy (e.g. Authentik forward auth)
|
# Sign in users an authenticating reverse proxy (e.g. Authentik forward auth)
|
||||||
# names in a header. Off unless TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES are
|
# names in a header. Off unless TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES are
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Builds the app, session manager and streamer images and pushes them to this
|
||||||
|
# Gitea instance's container registry, under the repository's owner.
|
||||||
|
#
|
||||||
|
# Needs repository secrets REGISTRY_USERNAME and REGISTRY_TOKEN: a user, and an
|
||||||
|
# access token of theirs with package write access.
|
||||||
|
name: Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*.*.*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- image: dotcom-arcade
|
||||||
|
context: .
|
||||||
|
- image: dotcom-arcade-session-manager
|
||||||
|
context: streaming/session-manager
|
||||||
|
- image: dotcom-arcade-streamer
|
||||||
|
context: streaming/streamer
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The registry lives on the same host as this Gitea instance
|
||||||
|
- name: Locate the registry
|
||||||
|
id: registry
|
||||||
|
run: |
|
||||||
|
host=${GITHUB_SERVER_URL#*://}
|
||||||
|
echo "host=${host%/}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.registry.outputs.host }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
type=ref,event=branch
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta.outputs.version }}
|
||||||
|
provenance: false
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
# GitHub Actions Workflows
|
|
||||||
|
|
||||||
This directory contains automated workflows for the Flashpoint Web project.
|
|
||||||
|
|
||||||
## Workflows
|
|
||||||
|
|
||||||
### 1. Docker Build and Push (`docker-build-push.yml`)
|
|
||||||
|
|
||||||
Builds and pushes the single Docker image to the container registry.
|
|
||||||
|
|
||||||
#### Triggers
|
|
||||||
|
|
||||||
- **Version tags** (`v*.*.*`): Builds and pushes the image with version tags and `latest` tag
|
|
||||||
- **Manual dispatch**: Can be triggered manually from GitHub Actions tab
|
|
||||||
|
|
||||||
#### Image Built
|
|
||||||
|
|
||||||
- `flashpoint-web` - Single image serving both the REST API (including game
|
|
||||||
content) and the React web UI
|
|
||||||
|
|
||||||
#### Container Registry
|
|
||||||
|
|
||||||
The workflow pushes images to **Docker Hub**.
|
|
||||||
|
|
||||||
#### Setup Instructions
|
|
||||||
|
|
||||||
##### Docker Hub (Required)
|
|
||||||
|
|
||||||
1. **Create Docker Hub account** at https://hub.docker.com
|
|
||||||
|
|
||||||
2. **Create access token**:
|
|
||||||
- Go to Account Settings → Security → Access Tokens
|
|
||||||
- Click "New Access Token"
|
|
||||||
- Name: "GitHub Actions"
|
|
||||||
- Permissions: Read & Write
|
|
||||||
- Copy the token (you won't see it again!)
|
|
||||||
|
|
||||||
3. **Add GitHub Secrets**:
|
|
||||||
- Go to your repository → Settings → Secrets and variables → Actions
|
|
||||||
- Click "New repository secret"
|
|
||||||
- Add two secrets:
|
|
||||||
- `DOCKERHUB_USERNAME`: Your Docker Hub username
|
|
||||||
- `DOCKERHUB_TOKEN`: The access token from step 2
|
|
||||||
|
|
||||||
4. **Image will be pushed to**:
|
|
||||||
```
|
|
||||||
docker.io/darkraise/flashpoint-web:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Image Tags
|
|
||||||
|
|
||||||
The workflow automatically creates multiple tags when you push a version tag:
|
|
||||||
|
|
||||||
| Tag Type | Example | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `latest` | `latest` | Always created for every version tag |
|
|
||||||
| Full version | `1.2.3` | Git tag `v1.2.3` → `1.2.3` |
|
|
||||||
| Major.Minor | `1.2` | Git tag `v1.2.3` → `1.2` |
|
|
||||||
| Major | `1` | Git tag `v1.2.3` → `1` |
|
|
||||||
|
|
||||||
#### Creating a Release
|
|
||||||
|
|
||||||
To create a versioned release:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ensure you're on master branch
|
|
||||||
git checkout master
|
|
||||||
|
|
||||||
# Tag the commit
|
|
||||||
git tag v1.0.0
|
|
||||||
|
|
||||||
# Push the tag (this triggers the workflow)
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create images tagged as:
|
|
||||||
- `1.0.0`
|
|
||||||
- `1.0`
|
|
||||||
- `1`
|
|
||||||
- `latest`
|
|
||||||
|
|
||||||
#### Platform Support
|
|
||||||
|
|
||||||
Images are built for:
|
|
||||||
- `linux/amd64` (x86_64) - Standard servers and desktops
|
|
||||||
|
|
||||||
**Note:** ARM64 support has been disabled to significantly reduce build times. If you need ARM64 images, you can build them locally or enable multi-platform builds by adding `linux/arm64` to the platforms in the workflow file.
|
|
||||||
|
|
||||||
#### Build Caching
|
|
||||||
|
|
||||||
The workflow uses GitHub Actions cache to speed up builds:
|
|
||||||
- Layer cache stored between runs
|
|
||||||
- Significantly faster builds after the first run
|
|
||||||
- Cache automatically invalidated when dependencies change
|
|
||||||
|
|
||||||
#### Usage Examples
|
|
||||||
|
|
||||||
##### Pull and Run the Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pull the latest image
|
|
||||||
docker pull darkraise/flashpoint-web:latest
|
|
||||||
|
|
||||||
# Or use a specific version
|
|
||||||
docker pull darkraise/flashpoint-web:1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Use in Docker Compose
|
|
||||||
|
|
||||||
Update your `docker-compose.yml` to use the pre-built image:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
flashpoint-web:
|
|
||||||
image: darkraise/flashpoint-web:latest
|
|
||||||
# Remove build section
|
|
||||||
ports:
|
|
||||||
- "80:3100"
|
|
||||||
# ... rest of config
|
|
||||||
```
|
|
||||||
|
|
||||||
Then simply run:
|
|
||||||
```bash
|
|
||||||
docker compose pull # Pull the latest image
|
|
||||||
docker compose up -d # Start the service
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Making Images Public
|
|
||||||
|
|
||||||
By default, Docker Hub images are private. To make them public:
|
|
||||||
|
|
||||||
1. Go to https://hub.docker.com/repositories
|
|
||||||
2. Click on the repository (e.g., `flashpoint-web`)
|
|
||||||
3. Click "Settings"
|
|
||||||
4. Change visibility to "Public"
|
|
||||||
|
|
||||||
#### Troubleshooting
|
|
||||||
|
|
||||||
##### Build Fails
|
|
||||||
|
|
||||||
Check the Actions tab for detailed error logs:
|
|
||||||
```
|
|
||||||
https://github.com/<username>/<repo>/actions
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Docker Hub Not Pushing
|
|
||||||
|
|
||||||
Verify secrets are set correctly:
|
|
||||||
- Repository → Settings → Secrets and variables → Actions
|
|
||||||
- Check `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` exist
|
|
||||||
|
|
||||||
##### Image Not Found
|
|
||||||
|
|
||||||
For private images, authenticate first:
|
|
||||||
```bash
|
|
||||||
docker login docker.io
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Performance
|
|
||||||
|
|
||||||
Typical build times (optimized for amd64 only):
|
|
||||||
- First build: 3-5 minutes
|
|
||||||
- Subsequent builds (with cache): 1-2 minutes
|
|
||||||
- Frontend and backend build stages run within a single image build
|
|
||||||
|
|
||||||
**Optimizations applied:**
|
|
||||||
- Single platform (amd64) instead of multi-platform
|
|
||||||
- npm ci with `--prefer-offline` and `--no-audit` flags
|
|
||||||
- Disabled provenance and SBOM generation
|
|
||||||
- Latest BuildKit image
|
|
||||||
- GitHub Actions cache for Docker layers
|
|
||||||
|
|
||||||
#### Security
|
|
||||||
|
|
||||||
- Secrets are never exposed in logs
|
|
||||||
- Images are scanned for vulnerabilities (optional - can add)
|
|
||||||
- Multi-stage builds minimize attack surface
|
|
||||||
- Non-root users in all containers
|
|
||||||
- Read-only root filesystems where possible
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Performance Budget (`performance-budget.yml`)
|
|
||||||
|
|
||||||
Frontend performance monitoring workflow.
|
|
||||||
|
|
||||||
See the workflow file for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding New Workflows
|
|
||||||
|
|
||||||
To add a new workflow:
|
|
||||||
|
|
||||||
1. Create a new YAML file in `.github/workflows/`
|
|
||||||
2. Use the GitHub Actions syntax
|
|
||||||
3. Test locally with [act](https://github.com/nektos/act) (optional)
|
|
||||||
4. Push to trigger the workflow
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
|
||||||
- [Docker Build Push Action](https://github.com/docker/build-push-action)
|
|
||||||
- [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
name: Build and Push Docker Images
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY_DOCKERHUB: docker.io
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.meta.outputs.version }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=moby/buildkit:latest
|
|
||||||
network=host
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY_DOCKERHUB }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels)
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web
|
|
||||||
tags: |
|
|
||||||
# Tag with full version (v1.0.0 -> 1.0.0)
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
# Tag with major.minor (v1.0.0 -> 1.0)
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
# Tag with major version (v1.0.0 -> 1)
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
# Latest tag
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha,scope=flashpoint-web
|
|
||||||
cache-to: type=gha,scope=flashpoint-web,mode=max
|
|
||||||
platforms: linux/amd64
|
|
||||||
build-args: |
|
|
||||||
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
|
||||||
VCS_REF=${{ github.sha }}
|
|
||||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
|
||||||
provenance: false
|
|
||||||
sbom: false
|
|
||||||
|
|
||||||
summary:
|
|
||||||
needs: build-and-push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Build Summary
|
|
||||||
run: |
|
|
||||||
echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "### Image Built:" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- \`flashpoint-web\` (API + UI)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "### Pull Command:" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:latest" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
needs: build-and-push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Generate release notes
|
|
||||||
id: release-notes
|
|
||||||
run: |
|
|
||||||
# Get the previous tag
|
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
echo "## Docker Image" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "Pull the image from Docker Hub:" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
|
||||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:${{ steps.version.outputs.version }}" >> release_notes.md
|
|
||||||
echo "\`\`\`" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
|
|
||||||
if [ -n "$PREV_TAG" ]; then
|
|
||||||
echo "## Changes since $PREV_TAG" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
git log --pretty=format:"- %s (%h)" $PREV_TAG..HEAD >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.version.outputs.version }}
|
|
||||||
name: Release ${{ steps.version.outputs.version }}
|
|
||||||
body_path: release_notes.md
|
|
||||||
draft: false
|
|
||||||
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -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`
|
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ Flash Player stream from your server.
|
|||||||
> Flashpoint Archive. It's built on
|
> Flashpoint Archive. It's built on
|
||||||
> [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise.
|
> [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise.
|
||||||
|
|
||||||
|
## Before you start: hosting requirements
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> **Dotcom Arcade needs a lot of disk, and streaming needs a lot of CPU.** Check
|
||||||
|
> these before you download anything.
|
||||||
|
|
||||||
|
| Resource | Needed |
|
||||||
|
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Storage** | **More than 2.3 TB** for Flashpoint Ultimate 14, and growing with each release. 4.6 TB if you keep the downloaded archives to seed them. A spare desktop drive won't do. |
|
||||||
|
| **Time** | A day or more to download and extract, depending on your connection and disks. |
|
||||||
|
| **Server** | Linux with Docker. 2 CPU cores and 4 GB of RAM serve the site and in-browser games to a household. |
|
||||||
|
| **Streaming** | Optional, and heavy: every player gets their own container running the game and encoding its video on the CPU. Plan on roughly 1 to 2 CPU cores and 1 GB of RAM per player streaming at once, and a few Mbps of upload each. |
|
||||||
|
| **HTTPS** | Required for streaming, since browsers only allow its video decoder on secure pages. Recommended anyway. |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Plays almost everything.**
|
- **Plays almost everything.**
|
||||||
@@ -54,27 +69,50 @@ Flash Player stream from your server.
|
|||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
You need a Linux server with Docker, and enough disk for Flashpoint Ultimate:
|
Check the [hosting requirements](#before-you-start-hosting-requirements) first.
|
||||||
about 2.3 TB for version 14, or 4.6 TB if you keep the downloaded archives to
|
|
||||||
seed. Expect the download and extraction to take a day or more.
|
|
||||||
|
|
||||||
### 1. Get Flashpoint Ultimate
|
### 1. Get Flashpoint Ultimate
|
||||||
|
|
||||||
[`scripts/setup-flashpoint.sh`](scripts/setup-flashpoint.sh) downloads the
|
Download two files from Flashpoint's
|
||||||
torrent with `aria2c` and then extracts each archive in turn. It checks free
|
[downloads page](https://flashpointarchive.org/downloads):
|
||||||
space before each one and deletes it afterwards, so the disk never needs room
|
|
||||||
for both. It also pulls the database, settings and playlists out of
|
- the **Flashpoint Ultimate** `.torrent` file;
|
||||||
`Base_1.zip`, which otherwise only holds Windows software. Re-run it to resume
|
- **Flashpoint Infinity for Linux** (`.7z`), whose software streaming needs.
|
||||||
after an interruption.
|
|
||||||
|
[`scripts/setup-flashpoint.sh`](scripts/setup-flashpoint.sh) then does the rest:
|
||||||
|
|
||||||
|
- It checks that you have the disk space, and stops before downloading anything
|
||||||
|
if you don't.
|
||||||
|
- It downloads the torrent with `aria2c`.
|
||||||
|
- It extracts each archive in turn and deletes it afterwards, so the disk never
|
||||||
|
needs room for both.
|
||||||
|
- It pulls the database, settings and playlists out of `Base_1.zip`, which
|
||||||
|
otherwise only holds Windows software.
|
||||||
|
|
||||||
|
Re-run it to resume after an interruption.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt install aria2 7zip tmux
|
sudo apt install aria2 7zip tmux
|
||||||
tmux new -s flashpoint
|
tmux new -s flashpoint
|
||||||
scripts/setup-flashpoint.sh --with-infinity /srv/flashpoint
|
scripts/setup-flashpoint.sh --torrent FlashpointUltimate14.torrent \
|
||||||
|
--infinity fp14.0.3_lin_20251201.7z --seed-ratio 1.0 /srv/flashpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
`--with-infinity` adds Flashpoint Infinity for Linux's software, which streaming
|
#### Please seed
|
||||||
needs. `--keep-archives` keeps the archives so you can keep seeding.
|
|
||||||
|
Flashpoint Ultimate is shared over BitTorrent, and stays available only as long
|
||||||
|
as people seed it. If you can, give back at least what you downloaded:
|
||||||
|
|
||||||
|
- **Seed before extracting.** `--seed-ratio 1.0` keeps seeding after the
|
||||||
|
download until you've uploaded as much as you downloaded. `--seed-hours 24`
|
||||||
|
seeds for a day. With both, seeding stops at whichever comes first. This needs
|
||||||
|
no extra disk space, but extraction waits until seeding stops.
|
||||||
|
- **Seed for the long term.** `--keep-archives` keeps the archives after
|
||||||
|
extracting (about 4.6 TB in total), so a torrent client such as qBittorrent or
|
||||||
|
Transmission can go on seeding the downloads directory.
|
||||||
|
- **Let peers reach you.** The download listens on ports 6881-6999, TCP and UDP.
|
||||||
|
Forwarding them on your router and allowing them through your firewall lets
|
||||||
|
more people download from you.
|
||||||
|
|
||||||
### 2. Configure
|
### 2. Configure
|
||||||
|
|
||||||
@@ -105,7 +143,7 @@ Under **Settings → App** you can set the site name and tagline.
|
|||||||
|
|
||||||
Streaming plays the games browsers can't. To turn it on:
|
Streaming plays the games browsers can't. To turn it on:
|
||||||
|
|
||||||
1. Run the setup script with `--with-infinity`.
|
1. Run the setup script with `--infinity`.
|
||||||
2. Add to `.env`:
|
2. Add to `.env`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -114,13 +152,12 @@ Streaming plays the games browsers can't. To turn it on:
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Rebuild with `docker compose up -d --build`.
|
3. Rebuild with `docker compose up -d --build`.
|
||||||
4. Route `/stream/` on your site to the session manager (port 8081). Streams
|
4. Serve the site over HTTPS. The app proxies streams itself, so your reverse
|
||||||
need HTTPS. See the [streaming guide](docs/10-features/13-streaming.md) for
|
proxy only needs to pass WebSocket upgrades and allow long-lived connections.
|
||||||
an nginx example.
|
See the [streaming guide](docs/10-features/13-streaming.md).
|
||||||
|
|
||||||
The session manager starts containers through the Docker socket, which amounts
|
The session manager starts containers through the Docker socket, which amounts
|
||||||
to root on the host. Keep it off the public internet. Only `/stream/` needs to
|
to root on the host. It publishes no ports: only the app talks to it.
|
||||||
be reachable, and only through your proxy.
|
|
||||||
|
|
||||||
### Signing in through a reverse proxy
|
### Signing in through a reverse proxy
|
||||||
|
|
||||||
@@ -156,6 +193,15 @@ and development. Useful starting points:
|
|||||||
- [Selkies](https://github.com/selkies-project/selkies), which streams the
|
- [Selkies](https://github.com/selkies-project/selkies), which streams the
|
||||||
games.
|
games.
|
||||||
|
|
||||||
|
## Legal
|
||||||
|
|
||||||
|
Flashpoint Archive is a preservation project whose legal standing has never been
|
||||||
|
settled. It removes games when their rights holders ask. Dotcom Arcade contains
|
||||||
|
no games and links to no game downloads. You get Flashpoint from Flashpoint's
|
||||||
|
own site, and you are responsible for complying with copyright law where you
|
||||||
|
live. Rights holders who want a game removed should contact
|
||||||
|
[Flashpoint Archive](https://flashpointarchive.org/).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-3.0, like flashpoint-web. See [LICENSE](LICENSE).
|
GPL-3.0, like flashpoint-web. See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -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,12 +167,12 @@ 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,
|
||||||
streamingApiToken: process.env.STREAMING_API_TOKEN || null,
|
streamingApiToken: process.env.STREAMING_API_TOKEN || null,
|
||||||
|
// Where /stream/ is proxied to: the session manager's stream port
|
||||||
|
streamingStreamUrl: process.env.STREAMING_STREAM_URL || null,
|
||||||
|
|
||||||
// Sign users in from a header set by an authenticating reverse proxy, such as
|
// Sign users in from a header set by an authenticating reverse proxy, such as
|
||||||
// Authentik forward auth. Off unless the header name and the proxy addresses
|
// Authentik forward auth. Off unless the header name and the proxy addresses
|
||||||
@@ -208,6 +208,8 @@ export const config = {
|
|||||||
// marks the build as unreleased. `||` is deliberate: a whitespace-only value
|
// marks the build as unreleased. `||` is deliberate: a whitespace-only value
|
||||||
// must collapse to null, which `??` would not do.
|
// must collapse to null, which `??` would not do.
|
||||||
appVersion: process.env.APP_VERSION?.trim() || null,
|
appVersion: process.env.APP_VERSION?.trim() || null,
|
||||||
|
// A GitHub- or Gitea-style "latest release" API URL; update checks are off without it
|
||||||
|
appUpdateUrl: process.env.APP_UPDATE_URL?.trim() || null,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
|
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import http, { type Server } from 'http';
|
||||||
|
import type { AddressInfo } from 'net';
|
||||||
|
import express from 'express';
|
||||||
|
import { mountStreamProxy } from './streamProxy';
|
||||||
|
|
||||||
|
vi.mock('../utils/logger', () => ({
|
||||||
|
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
let upstream: Server;
|
||||||
|
let app: Server;
|
||||||
|
let base: string;
|
||||||
|
|
||||||
|
function listen(server: Server): Promise<number> {
|
||||||
|
return new Promise((resolve) =>
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// A stand-in session manager: echoes the path, and accepts WebSocket upgrades
|
||||||
|
upstream = http.createServer((req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end(`upstream saw ${req.method} ${req.url}`);
|
||||||
|
});
|
||||||
|
upstream.on('upgrade', (req, socket) => {
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'
|
||||||
|
);
|
||||||
|
socket.write(`ws ${req.url}`);
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
const upstreamPort = await listen(upstream);
|
||||||
|
|
||||||
|
const expressApp = express();
|
||||||
|
const attach = mountStreamProxy(expressApp, `http://127.0.0.1:${upstreamPort}`);
|
||||||
|
expressApp.get('/other', (_req, res) => {
|
||||||
|
res.send('app');
|
||||||
|
});
|
||||||
|
app = http.createServer(expressApp);
|
||||||
|
attach(app);
|
||||||
|
base = `http://127.0.0.1:${await listen(app)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
app.close();
|
||||||
|
upstream.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stream proxy', () => {
|
||||||
|
it('forwards /stream/ requests with their full path', async () => {
|
||||||
|
const res = await fetch(`${base}/stream/abc/_fp/start`, { method: 'POST' });
|
||||||
|
expect(await res.text()).toBe('upstream saw POST /stream/abc/_fp/start');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves other routes to the app', async () => {
|
||||||
|
expect(await (await fetch(`${base}/other`)).text()).toBe('app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards WebSocket upgrades', async () => {
|
||||||
|
const received = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req = http.request(`${base}/stream/abc/websocket`, {
|
||||||
|
headers: { Connection: 'Upgrade', Upgrade: 'websocket' },
|
||||||
|
});
|
||||||
|
req.on('upgrade', (res, socket, head) => {
|
||||||
|
expect(res.statusCode).toBe(101);
|
||||||
|
let data = head.toString();
|
||||||
|
socket.on('data', (chunk) => (data += chunk.toString()));
|
||||||
|
socket.on('end', () => resolve(data));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
expect(received).toBe('ws /stream/abc/websocket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 502 when the session manager is down', async () => {
|
||||||
|
const down = express();
|
||||||
|
mountStreamProxy(down, 'http://127.0.0.1:9');
|
||||||
|
const server = http.createServer(down);
|
||||||
|
const port = await listen(server);
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/stream/abc/`);
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ServerResponse, type IncomingMessage, type Server } from 'http';
|
||||||
|
import type { Socket } from 'net';
|
||||||
|
import type { Express } from 'express';
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxies /stream/ to the session manager, WebSockets included, so a reverse
|
||||||
|
* proxy in front of the app needs no route of its own for streams. Returns a
|
||||||
|
* function that wires WebSocket upgrades into the HTTP server once it exists.
|
||||||
|
*/
|
||||||
|
export function mountStreamProxy(app: Express, target: string): (server: Server) => void {
|
||||||
|
const proxy = createProxyMiddleware('/stream/', {
|
||||||
|
target,
|
||||||
|
ws: true,
|
||||||
|
logLevel: 'silent',
|
||||||
|
// Save sync is a long-lived event stream; don't let the server's idle timeout cut it
|
||||||
|
onProxyReq: (_proxyReq, req) => {
|
||||||
|
req.socket.setTimeout(0);
|
||||||
|
},
|
||||||
|
onError: (error, req, res) => {
|
||||||
|
logger.warn('[Streaming] Stream proxy error', { url: req.url, error: error.message });
|
||||||
|
// A failed WebSocket upgrade hands over the raw socket instead of a response
|
||||||
|
if (!(res instanceof ServerResponse)) {
|
||||||
|
(res as Socket).destroy();
|
||||||
|
} else if (!res.headersSent) {
|
||||||
|
res.writeHead(502).end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.use(proxy);
|
||||||
|
|
||||||
|
return (server) => {
|
||||||
|
server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith('/stream/')) return;
|
||||||
|
socket.setTimeout(0);
|
||||||
|
proxy.upgrade?.(req as Parameters<NonNullable<typeof proxy.upgrade>>[0], socket, head);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ import { zipManager } from './game/zip-manager';
|
|||||||
import { gameZipServer } from './game/gamezipserver';
|
import { gameZipServer } from './game/gamezipserver';
|
||||||
import gameProxyRouter from './routes/game-proxy';
|
import gameProxyRouter from './routes/game-proxy';
|
||||||
import gameZipRouter from './routes/game-zip';
|
import gameZipRouter from './routes/game-zip';
|
||||||
|
import { mountStreamProxy } from './routes/streamProxy';
|
||||||
|
import { StreamingService } from './services/StreamingService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute SHA-256 CSP hashes for inline <script> blocks (those without a src
|
* Compute SHA-256 CSP hashes for inline <script> blocks (those without a src
|
||||||
@@ -112,6 +114,13 @@ async function startServer() {
|
|||||||
app.use('/game-proxy', gameCors, startupGate, gameProxyRouter);
|
app.use('/game-proxy', gameCors, startupGate, gameProxyRouter);
|
||||||
app.use('/game-zip', gameCors, startupGate, gameZipRouter);
|
app.use('/game-zip', gameCors, startupGate, gameZipRouter);
|
||||||
|
|
||||||
|
// Streams are proxied before helmet and compression, which would break the
|
||||||
|
// streamer's page and its event stream
|
||||||
|
const attachStreamProxy =
|
||||||
|
StreamingService.isEnabled() && config.streamingStreamUrl
|
||||||
|
? mountStreamProxy(app, config.streamingStreamUrl)
|
||||||
|
: null;
|
||||||
|
|
||||||
// When the backend also serves the SPA, allow the app's own resources by
|
// When the backend also serves the SPA, allow the app's own resources by
|
||||||
// hash/keyword rather than the blanket 'unsafe-inline'/'unsafe-eval':
|
// hash/keyword rather than the blanket 'unsafe-inline'/'unsafe-eval':
|
||||||
// - scriptSrc: 'self' for hashed bundles, sha256 hashes for the inline theme
|
// - scriptSrc: 'self' for hashed bundles, sha256 hashes for the inline theme
|
||||||
@@ -230,6 +239,7 @@ async function startServer() {
|
|||||||
logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`);
|
logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`);
|
||||||
logger.info(`🌍 Environment: ${config.nodeEnv}`);
|
logger.info(`🌍 Environment: ${config.nodeEnv}`);
|
||||||
});
|
});
|
||||||
|
attachStreamProxy?.(server);
|
||||||
|
|
||||||
// Prevent connection exhaustion - critical for handling game file requests
|
// Prevent connection exhaustion - critical for handling game file requests
|
||||||
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
|
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ vi.mock('../utils/logger', () => ({
|
|||||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const configMock = vi.hoisted(() => ({ config: { appVersion: '1.0.38' as string | null } }));
|
const RELEASE_URL = 'https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest';
|
||||||
|
const configMock = vi.hoisted(() => ({
|
||||||
|
config: {
|
||||||
|
appVersion: '1.0.38' as string | null,
|
||||||
|
appUpdateUrl: 'https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest' as
|
||||||
|
| string
|
||||||
|
| null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock('../config', () => configMock);
|
vi.mock('../config', () => configMock);
|
||||||
|
|
||||||
vi.mock('axios', () => ({
|
vi.mock('axios', () => ({
|
||||||
@@ -20,7 +28,7 @@ function release(tagName: string) {
|
|||||||
data: {
|
data: {
|
||||||
tag_name: tagName,
|
tag_name: tagName,
|
||||||
published_at: '2026-08-12T07:11:45Z',
|
published_at: '2026-08-12T07:11:45Z',
|
||||||
html_url: `https://github.com/darkraise/flashpoint-web/releases/tag/${tagName}`,
|
html_url: `https://git.example.com/owner/dotcom-arcade/releases/tag/${tagName}`,
|
||||||
body: '## Changes since v1.0.37\n\n- feat: something',
|
body: '## Changes since v1.0.37\n\n- feat: something',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -30,6 +38,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
AppUpdateService.clearCache();
|
AppUpdateService.clearCache();
|
||||||
configMock.config.appVersion = '1.0.38';
|
configMock.config.appVersion = '1.0.38';
|
||||||
|
configMock.config.appUpdateUrl = RELEASE_URL;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AppUpdateService.getUpdateInfo', () => {
|
describe('AppUpdateService.getUpdateInfo', () => {
|
||||||
@@ -105,10 +114,20 @@ describe('AppUpdateService.getUpdateInfo', () => {
|
|||||||
await AppUpdateService.getUpdateInfo();
|
await AppUpdateService.getUpdateInfo();
|
||||||
|
|
||||||
expect(axios.get).toHaveBeenCalledWith(
|
expect(axios.get).toHaveBeenCalledWith(
|
||||||
'https://api.github.com/repos/darkraise/flashpoint-web/releases/latest',
|
RELEASE_URL,
|
||||||
expect.objectContaining({ timeout: 10000 })
|
expect.objectContaining({ timeout: 10000 })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks nothing when no release source is configured', async () => {
|
||||||
|
configMock.config.appUpdateUrl = null;
|
||||||
|
|
||||||
|
const info = await AppUpdateService.getUpdateInfo(true);
|
||||||
|
|
||||||
|
expect(axios.get).not.toHaveBeenCalled();
|
||||||
|
expect(info.enabled).toBe(false);
|
||||||
|
expect(info.updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AppUpdateService caching', () => {
|
describe('AppUpdateService caching', () => {
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { config } from '../config';
|
|||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { compareVersions, isReleaseVersion, normalizeVersion } from '../utils/version';
|
import { compareVersions, isReleaseVersion, normalizeVersion } from '../utils/version';
|
||||||
|
|
||||||
const LATEST_RELEASE_URL = 'https://api.github.com/repos/darkraise/flashpoint-web/releases/latest';
|
|
||||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
const FORCED_REFRESH_FLOOR_MS = 60 * 1000;
|
const FORCED_REFRESH_FLOOR_MS = 60 * 1000;
|
||||||
const REQUEST_TIMEOUT_MS = 10000;
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
export interface AppUpdateInfo {
|
export interface AppUpdateInfo {
|
||||||
|
/** False when no release source is configured (APP_UPDATE_URL) */
|
||||||
|
enabled: boolean;
|
||||||
currentVersion: string | null;
|
currentVersion: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
@@ -28,7 +29,8 @@ interface CachedRelease {
|
|||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitHubRelease {
|
/** The fields GitHub's and Gitea's release APIs have in common */
|
||||||
|
interface Release {
|
||||||
tag_name?: unknown;
|
tag_name?: unknown;
|
||||||
published_at?: unknown;
|
published_at?: unknown;
|
||||||
html_url?: unknown;
|
html_url?: unknown;
|
||||||
@@ -50,13 +52,16 @@ export class AppUpdateService {
|
|||||||
private static lastFailedAt: number | null = null;
|
private static lastFailedAt: number | null = null;
|
||||||
|
|
||||||
static async getUpdateInfo(force = false): Promise<AppUpdateInfo> {
|
static async getUpdateInfo(force = false): Promise<AppUpdateInfo> {
|
||||||
|
if (config.appUpdateUrl === null) {
|
||||||
|
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;
|
||||||
@@ -69,7 +74,7 @@ export class AppUpdateService {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.lastCheckFailed = true;
|
this.lastCheckFailed = true;
|
||||||
this.lastFailedAt = Date.now();
|
this.lastFailedAt = Date.now();
|
||||||
logger.warn('[AppUpdate] Could not reach GitHub for the latest release', {
|
logger.warn('[AppUpdate] Could not fetch the latest release', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -96,19 +101,19 @@ export class AppUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async requestLatestRelease(): Promise<CachedRelease> {
|
private static async requestLatestRelease(): Promise<CachedRelease> {
|
||||||
const response = await axios.get(LATEST_RELEASE_URL, {
|
const response = await axios.get(config.appUpdateUrl ?? '', {
|
||||||
timeout: REQUEST_TIMEOUT_MS,
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/vnd.github.v3+json',
|
Accept: 'application/json',
|
||||||
'User-Agent': 'Flashpoint-Web',
|
'User-Agent': 'Dotcom-Arcade',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const release = (response.data ?? {}) as GitHubRelease;
|
const release = (response.data ?? {}) as Release;
|
||||||
const tagName = asString(release.tag_name);
|
const tagName = asString(release.tag_name);
|
||||||
|
|
||||||
if (tagName === null) {
|
if (tagName === null) {
|
||||||
throw new Error('GitHub release response carried no tag_name');
|
throw new Error('Release response carried no tag_name');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -131,6 +136,7 @@ export class AppUpdateService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
enabled: config.appUpdateUrl !== null,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion: latest?.version ?? null,
|
latestVersion: latest?.version ?? null,
|
||||||
updateAvailable: comparison !== null && comparison < 0,
|
updateAvailable: comparison !== null && comparison < 0,
|
||||||
|
|||||||
+11
-9
@@ -8,6 +8,8 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# 1. Copy .env.example to .env and set the required variables
|
# 1. Copy .env.example to .env and set the required variables
|
||||||
# 2. docker compose up -d --build
|
# 2. docker compose up -d --build
|
||||||
|
# (or, with IMAGE_PREFIX set to a registry's images:
|
||||||
|
# docker compose pull && docker compose up -d)
|
||||||
# 3. For streaming, set STREAMING_API_TOKEN and COMPOSE_PROFILES=streaming in
|
# 3. For streaming, set STREAMING_API_TOKEN and COMPOSE_PROFILES=streaming in
|
||||||
# .env, then run `docker compose up -d --build` again
|
# .env, then run `docker compose up -d --build` again
|
||||||
#
|
#
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
image: dotcom-arcade:${IMAGE_TAG:-latest}
|
image: ${IMAGE_PREFIX:-}dotcom-arcade:${IMAGE_TAG:-latest}
|
||||||
container_name: dotcom-arcade
|
container_name: dotcom-arcade
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -54,10 +56,12 @@ 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
|
||||||
|
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
|
||||||
- ENABLE_CGI=${ENABLE_CGI:-false}
|
- ENABLE_CGI=${ENABLE_CGI:-false}
|
||||||
# Streamed play for games that can't run in a browser (off unless the token is set)
|
# Streamed play for games that can't run in a browser (off unless the token is set)
|
||||||
- STREAMING_API_URL=${STREAMING_API_URL:-http://session-manager:8080}
|
- STREAMING_API_URL=${STREAMING_API_URL:-http://session-manager:8080}
|
||||||
|
- STREAMING_STREAM_URL=${STREAMING_STREAM_URL:-http://session-manager:8081}
|
||||||
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
|
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
|
||||||
# Sign in users named by an authenticating reverse proxy (off unless both are set)
|
# Sign in users named by an authenticating reverse proxy (off unless both are set)
|
||||||
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
|
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
|
||||||
@@ -96,16 +100,14 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- arcade
|
- arcade
|
||||||
|
|
||||||
# Starts a streamer container per player and proxies its stream at /stream/
|
# Starts a streamer container per player. The app proxies /stream/ to it, so
|
||||||
|
# it publishes no ports.
|
||||||
session-manager:
|
session-manager:
|
||||||
profiles: [streaming]
|
profiles: [streaming]
|
||||||
build: streaming/session-manager
|
build: streaming/session-manager
|
||||||
image: dotcom-arcade-session-manager:${IMAGE_TAG:-latest}
|
image: ${IMAGE_PREFIX:-}dotcom-arcade-session-manager:${IMAGE_TAG:-latest}
|
||||||
container_name: dotcom-arcade-session-manager
|
container_name: dotcom-arcade-session-manager
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
# Route /stream/ on the site to this port (see streaming/README.md)
|
|
||||||
- "${STREAM_PORT:-8081}:8081"
|
|
||||||
volumes:
|
volumes:
|
||||||
# Starts and removes streamer containers. Docker socket access is
|
# Starts and removes streamer containers. Docker socket access is
|
||||||
# effectively root on the host.
|
# effectively root on the host.
|
||||||
@@ -115,7 +117,7 @@ services:
|
|||||||
- FLASHPOINT_HOST_PATH
|
- FLASHPOINT_HOST_PATH
|
||||||
# The session manager refuses to start without it
|
# The session manager refuses to start without it
|
||||||
- API_TOKEN=${STREAMING_API_TOKEN:-}
|
- API_TOKEN=${STREAMING_API_TOKEN:-}
|
||||||
- STREAMER_IMAGE=dotcom-arcade-streamer:${IMAGE_TAG:-latest}
|
- STREAMER_IMAGE=${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
|
||||||
- STREAMER_NETWORK=dotcom-arcade-streams
|
- STREAMER_NETWORK=dotcom-arcade-streams
|
||||||
- MAX_SESSIONS=${MAX_SESSIONS:-4}
|
- MAX_SESSIONS=${MAX_SESSIONS:-4}
|
||||||
- STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash}
|
- STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash}
|
||||||
@@ -129,7 +131,7 @@ services:
|
|||||||
streamer:
|
streamer:
|
||||||
profiles: [streaming]
|
profiles: [streaming]
|
||||||
build: streaming/streamer
|
build: streaming/streamer
|
||||||
image: dotcom-arcade-streamer:${IMAGE_TAG:-latest}
|
image: ${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
|
||||||
scale: 0
|
scale: 0
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ walks through a first install; this page covers the details.
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Docker:** Engine 24+ with the Compose plugin.
|
- **Docker:** Engine 24+ with the Compose plugin.
|
||||||
- **Memory:** 4 GB of RAM. With streaming, allow about 1 GB more per concurrent
|
- **Storage:** more than 2.3 TB for Flashpoint Ultimate 14, or 4.6 TB to keep
|
||||||
stream, since each streamer container gets 1 GB of shared memory.
|
the downloaded archives.
|
||||||
|
- **CPU and memory:** 2 cores and 4 GB of RAM for the site. Streaming adds
|
||||||
|
roughly 1 to 2 CPU cores and 1 GB of RAM per player streaming at once, since
|
||||||
|
each player's container runs the game and encodes its video on the CPU.
|
||||||
- **A Flashpoint install** at `FLASHPOINT_HOST_PATH`, with
|
- **A Flashpoint install** at `FLASHPOINT_HOST_PATH`, with
|
||||||
`Data/flashpoint.sqlite`, `Data/Games/`, `Data/Images/`, `Data/Playlists/`
|
`Data/flashpoint.sqlite`, `Data/Games/`, `Data/Images/`, `Data/Playlists/`
|
||||||
and `Legacy/htdocs/`. `scripts/setup-flashpoint.sh` produces one from the
|
and `Legacy/htdocs/`. `scripts/setup-flashpoint.sh` produces one from the
|
||||||
@@ -18,11 +21,33 @@ walks through a first install; this page covers the details.
|
|||||||
| Service | Image | Profile | Purpose |
|
| Service | Image | Profile | Purpose |
|
||||||
| ----------------- | ------------------------------ | ----------- | -------------------------------------------------------- |
|
| ----------------- | ------------------------------ | ----------- | -------------------------------------------------------- |
|
||||||
| `app` | `dotcom-arcade` | (always) | UI, API and game content on port 3100 |
|
| `app` | `dotcom-arcade` | (always) | UI, API and game content on port 3100 |
|
||||||
| `session-manager` | `dotcom-arcade-session-manager` | `streaming` | Starts streamer containers; serves `/stream/` on port 8081 |
|
| `session-manager` | `dotcom-arcade-session-manager` | `streaming` | Starts streamer containers and serves their streams, through the app |
|
||||||
| `streamer` | `dotcom-arcade-streamer` | `streaming` | Built only; the session manager runs one per player |
|
| `streamer` | `dotcom-arcade-streamer` | `streaming` | Built only; the session manager runs one per player |
|
||||||
|
|
||||||
All three are built from this repository by `docker compose up -d --build`.
|
All three are built from this repository by `docker compose up -d --build`.
|
||||||
|
|
||||||
|
### Prebuilt images
|
||||||
|
|
||||||
|
To pull published images instead of building, set `IMAGE_PREFIX` in `.env` to
|
||||||
|
the registry path they live under, ending in `/`, and optionally `IMAGE_TAG`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
IMAGE_PREFIX=registry.example.com/owner/
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `docker compose pull && docker compose up -d`.
|
||||||
|
|
||||||
|
### Publishing images
|
||||||
|
|
||||||
|
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
|
||||||
|
pushes them to the Gitea instance's container registry, under the repository's
|
||||||
|
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
|
||||||
|
`1.2` and `latest`. It needs two repository secrets:
|
||||||
|
|
||||||
|
- `REGISTRY_USERNAME`: a user;
|
||||||
|
- `REGISTRY_TOKEN`: an access token of theirs with package write access.
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
||||||
| Host | Container | Purpose |
|
| Host | Container | Purpose |
|
||||||
@@ -37,14 +62,14 @@ socket, and passes `FLASHPOINT_HOST_PATH` on to the streamer containers.
|
|||||||
## Ports and networks
|
## Ports and networks
|
||||||
|
|
||||||
- **App:** listens on `WEB_PORT` (default 80) on the host.
|
- **App:** listens on `WEB_PORT` (default 80) on the host.
|
||||||
- **Session manager:** serves `/stream/` on `STREAM_PORT` (default 8081).
|
- **Session manager:** publishes no ports. The app proxies `/stream/` to it.
|
||||||
- **`dotcom-arcade` network:** the app and the session manager share it.
|
- **`dotcom-arcade` network:** the app and the session manager share it.
|
||||||
- **`dotcom-arcade-streams` network:** holds the streamer containers. It's
|
- **`dotcom-arcade-streams` network:** holds the streamer containers. It's
|
||||||
internal, so they have no internet access and only the session manager can
|
internal, so they have no internet access and only the session manager can
|
||||||
reach them.
|
reach them.
|
||||||
|
|
||||||
Put a reverse proxy with HTTPS in front of the app. Streaming needs HTTPS, and
|
Put a reverse proxy with HTTPS in front of the app. Streaming needs HTTPS, and
|
||||||
the proxy must route `/stream/` to the session manager (see
|
the proxy must pass WebSocket upgrades (see
|
||||||
[Streaming](../10-features/13-streaming.md#reverse-proxy)).
|
[Streaming](../10-features/13-streaming.md#reverse-proxy)).
|
||||||
|
|
||||||
## Health checks
|
## Health checks
|
||||||
@@ -72,7 +97,7 @@ docker compose logs -f session-manager
|
|||||||
`docker compose exec app ls /data/flashpoint/Data`.
|
`docker compose exec app ls /data/flashpoint/Data`.
|
||||||
- **Missing playlists or settings:** `Data/Playlists` or `preferences.json` is
|
- **Missing playlists or settings:** `Data/Playlists` or `preferences.json` is
|
||||||
missing. Re-run `scripts/setup-flashpoint.sh`, which takes them from
|
missing. Re-run `scripts/setup-flashpoint.sh`, which takes them from
|
||||||
`Base_1.zip`, or add `--with-infinity`.
|
`Base_1.zip`, or pass `--infinity`.
|
||||||
- **Permission errors on `./data` or `./logs`:** set `PUID`/`PGID` to the owner
|
- **Permission errors on `./data` or `./logs`:** set `PUID`/`PGID` to the owner
|
||||||
of those folders.
|
of those folders.
|
||||||
|
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|
||||||
@@ -116,6 +115,14 @@ and API share one origin.
|
|||||||
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
|
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
|
||||||
| `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 |
|
| `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 |
|
||||||
|
|
||||||
|
**Images and updates:**
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ---------------- | -------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| `IMAGE_PREFIX` | - | Registry path to pull images from, ending in `/`. Unset builds them from source |
|
||||||
|
| `IMAGE_TAG` | `latest` | Image tag to build or pull |
|
||||||
|
| `APP_UPDATE_URL` | - | "Latest release" API URL (GitHub or Gitea) that Settings checks for new versions; off when unset |
|
||||||
|
|
||||||
**Streaming:**
|
**Streaming:**
|
||||||
|
|
||||||
Games that can't run in a browser, such as Java applets and Shockwave, can be
|
Games that can't run in a browser, such as Java applets and Shockwave, can be
|
||||||
@@ -125,6 +132,7 @@ streamed by the session manager under `streaming/`. It runs under the
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --------------------- | ----------------------------- | --------------------------------------------------------------- |
|
| --------------------- | ----------------------------- | --------------------------------------------------------------- |
|
||||||
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
|
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
|
||||||
|
| `STREAMING_STREAM_URL` | `http://session-manager:8081` | Where the app proxies `/stream/` to |
|
||||||
| `STREAMING_API_TOKEN` | - | Token shared with the session manager. Streaming is off without it |
|
| `STREAMING_API_TOKEN` | - | Token shared with the session manager. Streaming is off without it |
|
||||||
|
|
||||||
**Trusted Header Login:**
|
**Trusted Header Login:**
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ they need the Flashpoint Launcher.
|
|||||||
for Flash games.
|
for Flash games.
|
||||||
- **The session manager** (`streaming/session-manager`) starts one streamer
|
- **The session manager** (`streaming/session-manager`) starts one streamer
|
||||||
container per session through the Docker socket, and proxies the browser's
|
container per session through the Docker socket, and proxies the browser's
|
||||||
connection to it at `/stream/<id>/`.
|
connection to it at `/stream/<id>/`. The app forwards `/stream/` to it.
|
||||||
- **The streamer** (`streaming/streamer`) launches the game the way
|
- **The streamer** (`streaming/streamer`) launches the game the way
|
||||||
Flashpoint's Linux launcher does, and streams the screen and sound with
|
Flashpoint's Linux launcher does, and streams the screen and sound with
|
||||||
[Selkies](https://github.com/selkies-project/selkies). The display is sized to
|
[Selkies](https://github.com/selkies-project/selkies). The display is sized to
|
||||||
@@ -37,13 +37,16 @@ and [streaming/streamer/README.md](../../streaming/streamer/README.md).
|
|||||||
|
|
||||||
- **Infinity for Linux's software.** The streamer runs Flashpoint's Linux
|
- **Infinity for Linux's software.** The streamer runs Flashpoint's Linux
|
||||||
builds of the game server, Java, Wine and the Flash and Shockwave players.
|
builds of the game server, Java, Wine and the Flash and Shockwave players.
|
||||||
Ultimate's archives only include the Windows builds. Run
|
Ultimate's archives only include the Windows builds. Download Flashpoint
|
||||||
`scripts/setup-flashpoint.sh --with-infinity`, which adds them without
|
Infinity for Linux from Flashpoint's downloads page and pass it to
|
||||||
|
`scripts/setup-flashpoint.sh --infinity`, which adds the software without
|
||||||
replacing anything.
|
replacing anything.
|
||||||
- **HTTPS.** Browsers only allow the video decoder Selkies uses on secure
|
- **HTTPS.** Browsers only allow the video decoder Selkies uses on secure
|
||||||
pages.
|
pages.
|
||||||
- **A reverse proxy route** for `/stream/`, including WebSocket upgrades (see
|
- **A server with CPU to spare.** Every player streaming at once has a
|
||||||
below).
|
container running the game and encoding its video on the CPU. Plan on roughly
|
||||||
|
1 to 2 CPU cores and 1 GB of RAM each, and a few Mbps of upload. Set
|
||||||
|
`MAX_SESSIONS` so the server isn't overloaded.
|
||||||
|
|
||||||
## Enabling it
|
## Enabling it
|
||||||
|
|
||||||
@@ -63,18 +66,24 @@ sets up.
|
|||||||
| --------------------- | ----------------------------- | ----------------------------------------------------------------- |
|
| --------------------- | ----------------------------- | ----------------------------------------------------------------- |
|
||||||
| `STREAMING_API_TOKEN` | - | Shared secret between the app and the session manager; required |
|
| `STREAMING_API_TOKEN` | - | Shared secret between the app and the session manager; required |
|
||||||
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
|
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
|
||||||
| `STREAM_PORT` | `8081` | Host port the session manager serves `/stream/` on |
|
| `STREAMING_STREAM_URL` | `http://session-manager:8081` | Where the app proxies `/stream/` to |
|
||||||
| `MAX_SESSIONS` | `4` | Most streams running at once |
|
| `MAX_SESSIONS` | `4` | Most streams running at once |
|
||||||
| `STREAM_PLATFORMS` | `Java,Shockwave,Flash` | Platforms offered for streaming |
|
| `STREAM_PLATFORMS` | `Java,Shockwave,Flash` | Platforms offered for streaming |
|
||||||
|
|
||||||
## Reverse proxy
|
## Reverse proxy
|
||||||
|
|
||||||
Route `/stream/` to the session manager and everything else to the app. With
|
The app proxies `/stream/` to the session manager itself, so no extra route is
|
||||||
nginx:
|
needed. A reverse proxy in front of the app must still:
|
||||||
|
|
||||||
|
- pass WebSocket upgrades;
|
||||||
|
- allow long-lived connections, at least as long as a play session;
|
||||||
|
- not buffer responses, which would hold back the save-sync event stream.
|
||||||
|
|
||||||
|
With nginx:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location /stream/ {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
@@ -84,16 +93,16 @@ location /stream/ {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If another proxy sits in front of this one, it needs WebSocket support and
|
If another proxy sits in front of this one, it needs the same. A stream that
|
||||||
long timeouts as well. A stream that connects and then immediately fails with
|
connects and then immediately fails with HTTP 400 usually means a proxy in the
|
||||||
HTTP 400 usually means a proxy in the chain doesn't pass the WebSocket upgrade.
|
chain doesn't pass the WebSocket upgrade.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
The session manager holds the Docker socket, which is effectively root on the
|
The session manager holds the Docker socket, which is effectively root on the
|
||||||
host. Expose only its `/stream/` port, and only through your proxy. Streamer
|
host. It publishes no ports; only the app reaches it, on the compose network.
|
||||||
containers run on an internal network with no internet access, reachable only
|
Streamer containers run on an internal network with no internet access,
|
||||||
from the session manager.
|
reachable only from the session manager.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -110,4 +119,4 @@ from the session manager.
|
|||||||
|
|
||||||
`200` means the game can be streamed; `422` means it can't.
|
`200` means the game can be streamed; `422` means it can't.
|
||||||
- **The stream stays black or disconnects at once.** Check that the page is on
|
- **The stream stays black or disconnects at once.** Check that the page is on
|
||||||
HTTPS, and that `/stream/` passes WebSocket upgrades.
|
HTTPS, and that your proxy passes WebSocket upgrades.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export function AppUpdateCard() {
|
|||||||
setShowChangelog(true);
|
setShowChangelog(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Without a release source (APP_UPDATE_URL) there is nothing to check
|
||||||
|
if (info?.enabled === false) return null;
|
||||||
|
|
||||||
const checkFailedEntirely =
|
const checkFailedEntirely =
|
||||||
isLoadingError || (info?.lastCheckFailed === true && info.latestVersion === null);
|
isLoadingError || (info?.lastCheckFailed === true && info.latestVersion === null);
|
||||||
const currentVersionMatchesLatest = info
|
const currentVersionMatchesLatest = info
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface AssetDownloadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppUpdateInfo {
|
export interface AppUpdateInfo {
|
||||||
|
/** False when the server has no release source configured */
|
||||||
|
enabled: boolean;
|
||||||
currentVersion: string | null;
|
currentVersion: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+127
-37
@@ -1,44 +1,66 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Downloads Flashpoint Ultimate and extracts it into a directory Dotcom Arcade
|
# Extracts Flashpoint Ultimate into a directory Dotcom Arcade can serve, and
|
||||||
# can serve. Safe to re-run: it resumes the download and skips archives it has
|
# can download it first from a torrent you got from Flashpoint. Safe to re-run:
|
||||||
# already extracted.
|
# it resumes the download and skips archives it has already extracted.
|
||||||
#
|
#
|
||||||
# scripts/setup-flashpoint.sh [options] /path/to/flashpoint
|
# scripts/setup-flashpoint.sh [options] /path/to/flashpoint
|
||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# --downloads DIR where the torrent downloads to (default: DEST/../downloads)
|
# --torrent FILE download Flashpoint Ultimate with aria2c, from the
|
||||||
# --keep-archives keep each archive after extracting it, to keep seeding;
|
# .torrent file on https://flashpointarchive.org/downloads
|
||||||
# needs room for both (about 4.6 TB for Ultimate 14)
|
# --downloads DIR where the archives are or will be downloaded to
|
||||||
# --skip-download use archives already in the downloads directory
|
# (default: DEST/../downloads)
|
||||||
# --with-infinity add Flashpoint Infinity for Linux's software, which
|
# --infinity FILE add the software streaming needs (FPSoftware/, Server/)
|
||||||
# streaming needs (FPSoftware/, Server/)
|
# from Flashpoint Infinity for Linux (.7z, same page)
|
||||||
|
# --seed-ratio N after downloading, keep seeding until N times the
|
||||||
|
# download has been uploaded (e.g. 1.0), then extract
|
||||||
|
# --seed-hours N after downloading, keep seeding for up to N hours, then
|
||||||
|
# extract; with --seed-ratio, whichever comes first
|
||||||
|
# --keep-archives keep each archive after extracting it, e.g. to go on
|
||||||
|
# seeding with a torrent client; needs room for both copies
|
||||||
|
# --yes don't ask for confirmation
|
||||||
#
|
#
|
||||||
# Requires aria2c (unless --skip-download) and 7-Zip (7z, 7zz or 7za).
|
# Seeding helps keep Flashpoint's torrent alive for everyone. Seeding before
|
||||||
|
# extracting needs no extra disk space.
|
||||||
|
#
|
||||||
|
# Without --torrent, it uses the Ultimate archives already in the downloads
|
||||||
|
# directory. Requires 7-Zip (7z, 7zz or 7za), and aria2c with --torrent.
|
||||||
|
#
|
||||||
|
# Flashpoint Ultimate is more than 2 TB. This script checks for the space
|
||||||
|
# before it starts and refuses to run without it.
|
||||||
|
#
|
||||||
|
# Flashpoint Archive's legal standing is unsettled. You are responsible for
|
||||||
|
# complying with copyright law where you live. This script contains no
|
||||||
|
# download links: it only downloads from a torrent you supply.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ULTIMATE_VERSION=14
|
# Free space to leave on the disk after each archive, and headroom for the
|
||||||
TORRENT_URL="https://download.unstable.life/FlashpointUltimate${ULTIMATE_VERSION}.torrent"
|
# largest archive being extracted while its zip still exists
|
||||||
INFINITY_URL="https://download.unstable.life/upload/fp14.0.3_lin_20251201.7z"
|
|
||||||
INFINITY_SHA256=f393a98c5c35e229a744c102b0cb53270b1b4f1b3ebd40d604f98323444a4b1f
|
|
||||||
# Free space to leave on the disk after each archive
|
|
||||||
MARGIN=$((20 * 1024 ** 3))
|
MARGIN=$((20 * 1024 ** 3))
|
||||||
|
LARGEST_ARCHIVE=$((100 * 1024 ** 3))
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '6,16p' "$0" | sed 's/^# \{0,1\}//'
|
sed -n '6,34p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
DEST=
|
DEST=
|
||||||
DOWNLOADS=
|
DOWNLOADS=
|
||||||
|
TORRENT=
|
||||||
|
INFINITY=
|
||||||
KEEP=false
|
KEEP=false
|
||||||
DOWNLOAD=true
|
YES=false
|
||||||
INFINITY=false
|
SEED_RATIO=
|
||||||
|
SEED_HOURS=
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case $1 in
|
case $1 in
|
||||||
|
--torrent) TORRENT=${2:?}; shift ;;
|
||||||
--downloads) DOWNLOADS=${2:?}; shift ;;
|
--downloads) DOWNLOADS=${2:?}; shift ;;
|
||||||
|
--infinity) INFINITY=${2:?}; shift ;;
|
||||||
|
--seed-ratio) SEED_RATIO=${2:?}; shift ;;
|
||||||
|
--seed-hours) SEED_HOURS=${2:?}; shift ;;
|
||||||
--keep-archives) KEEP=true ;;
|
--keep-archives) KEEP=true ;;
|
||||||
--skip-download) DOWNLOAD=false ;;
|
--yes) YES=true ;;
|
||||||
--with-infinity) INFINITY=true ;;
|
|
||||||
-h | --help) usage ;;
|
-h | --help) usage ;;
|
||||||
-*) echo "Unknown option: $1" >&2; usage ;;
|
-*) echo "Unknown option: $1" >&2; usage ;;
|
||||||
*) DEST=$1 ;;
|
*) DEST=$1 ;;
|
||||||
@@ -46,6 +68,16 @@ while (($#)); do
|
|||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
[[ -n $DEST ]] || usage
|
[[ -n $DEST ]] || usage
|
||||||
|
# aria2c reads a ratio of 0 as "seed forever", which would never get to extracting
|
||||||
|
[[ -z $SEED_RATIO || ($SEED_RATIO =~ ^[0-9]+(\.[0-9]+)?$ && $SEED_RATIO =~ [1-9]) ]] ||
|
||||||
|
{ echo "--seed-ratio takes a number above 0, e.g. 1.0" >&2; exit 1; }
|
||||||
|
[[ -z $SEED_HOURS || $SEED_HOURS =~ ^[0-9]+$ ]] || { echo "--seed-hours takes a whole number of hours" >&2; exit 1; }
|
||||||
|
if [[ -z $TORRENT && -n $SEED_RATIO$SEED_HOURS ]]; then
|
||||||
|
echo "Seeding needs --torrent" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ -z $TORRENT || -f $TORRENT ]] || { echo "No torrent file at $TORRENT" >&2; exit 1; }
|
||||||
|
[[ -z $INFINITY || -f $INFINITY ]] || { echo "No Infinity archive at $INFINITY" >&2; exit 1; }
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$DEST"
|
||||||
DEST=$(cd "$DEST" && pwd)
|
DEST=$(cd "$DEST" && pwd)
|
||||||
DOWNLOADS=${DOWNLOADS:-$(dirname "$DEST")/downloads}
|
DOWNLOADS=${DOWNLOADS:-$(dirname "$DEST")/downloads}
|
||||||
@@ -53,8 +85,63 @@ mkdir -p "$DOWNLOADS"
|
|||||||
|
|
||||||
SEVENZIP=$(command -v 7zz || command -v 7z || command -v 7za || true)
|
SEVENZIP=$(command -v 7zz || command -v 7z || command -v 7za || true)
|
||||||
[[ -n $SEVENZIP ]] || { echo "7-Zip is required (7z, 7zz or 7za)" >&2; exit 1; }
|
[[ -n $SEVENZIP ]] || { echo "7-Zip is required (7z, 7zz or 7za)" >&2; exit 1; }
|
||||||
|
[[ -z $TORRENT ]] || command -v aria2c >/dev/null || { echo "aria2c is required for --torrent" >&2; exit 1; }
|
||||||
|
|
||||||
gib() { echo "$(($1 / 1024 ** 3))G"; }
|
gib() { echo "$(($1 / 1024 ** 3))G"; }
|
||||||
|
free_bytes() { df --output=avail -B1 "$1" | tail -1; }
|
||||||
|
same_disk() { [[ $(stat -c %d "$1") == $(stat -c %d "$2") ]]; }
|
||||||
|
|
||||||
|
DONE=$DOWNLOADS/.extracted
|
||||||
|
touch "$DONE"
|
||||||
|
# Once extraction has started, extracted archives are deleted; downloading
|
||||||
|
# again would fetch them all over
|
||||||
|
if [[ -n $TORRENT && -s $DONE ]]; then
|
||||||
|
echo "Extraction already started; continuing it without downloading."
|
||||||
|
TORRENT=
|
||||||
|
fi
|
||||||
|
|
||||||
|
# What's left to extract, measured from the archives on disk
|
||||||
|
remaining=0
|
||||||
|
while IFS= read -r -d '' archive; do
|
||||||
|
grep -qxF "$(basename "$archive")" "$DONE" || remaining=$((remaining + $(stat -c %s "$archive")))
|
||||||
|
done < <(find "$DOWNLOADS" -maxdepth 2 -name '*.zip' -print0)
|
||||||
|
|
||||||
|
# The download's size, from the torrent
|
||||||
|
download=0
|
||||||
|
if [[ -n $TORRENT ]]; then
|
||||||
|
download=$(aria2c -S "$TORRENT" | awk -F'[()]' '/Total Length/ { gsub(/,/, "", $2); print $2; exit }')
|
||||||
|
[[ $download =~ ^[0-9]+$ ]] || { echo "Couldn't read the download's size from $TORRENT" >&2; exit 1; }
|
||||||
|
remaining=$download
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extracted files take about as much room as the archives. Unless they're
|
||||||
|
# kept, each archive is deleted once extracted, so on a shared disk only the
|
||||||
|
# largest one is ever stored twice.
|
||||||
|
if $KEEP; then extra=$remaining; else extra=$((remaining < LARGEST_ARCHIVE ? remaining : LARGEST_ARCHIVE)); fi
|
||||||
|
if same_disk "$DOWNLOADS" "$DEST"; then
|
||||||
|
checks=("$DEST:$((download + extra + MARGIN))")
|
||||||
|
else
|
||||||
|
checks=("$DOWNLOADS:$((download + MARGIN))" "$DEST:$((remaining + MARGIN))")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((remaining > 0)); then
|
||||||
|
echo "Flashpoint Ultimate is very large: about $(gib "$remaining") of game data."
|
||||||
|
short=false
|
||||||
|
for check in "${checks[@]}"; do
|
||||||
|
dir=${check%:*} need=${check##*:} avail=$(free_bytes "${check%:*}")
|
||||||
|
echo " $dir needs about $(gib "$need"), has $(gib "$avail") free"
|
||||||
|
((avail >= need)) || short=true
|
||||||
|
done
|
||||||
|
if $short; then
|
||||||
|
echo "Not enough free space. Free up room or choose another disk; see the README's hosting requirements." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! $YES; then
|
||||||
|
[[ -t 0 ]] || { echo "Run with --yes to continue without a prompt." >&2; exit 1; }
|
||||||
|
read -r -p "This takes a day or more. Continue? [y/N] " answer
|
||||||
|
[[ $answer == [yY]* ]] || exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Extracts ARCHIVE into DEST, or only the entries listed in LIST if given.
|
# Extracts ARCHIVE into DEST, or only the entries listed in LIST if given.
|
||||||
# Archives may wrap everything in one top-level folder (e.g. "Flashpoint
|
# Archives may wrap everything in one top-level folder (e.g. "Flashpoint
|
||||||
@@ -86,19 +173,25 @@ extract() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 1. Download the packed archives
|
# 1. Download the packed archives
|
||||||
if $DOWNLOAD; then
|
if [[ -n $TORRENT ]]; then
|
||||||
command -v aria2c >/dev/null || { echo "aria2c is required to download" >&2; exit 1; }
|
# aria2c stops seeding at whichever limit it reaches first; with neither, it
|
||||||
echo "==> Downloading Flashpoint Ultimate ${ULTIMATE_VERSION} into $DOWNLOADS"
|
# stops as soon as the download completes
|
||||||
aria2c --dir="$DOWNLOADS" --seed-time=0 --check-integrity=true \
|
seed=(--seed-time=0)
|
||||||
--file-allocation=falloc --bt-max-peers=100 --follow-torrent=mem "$TORRENT_URL"
|
if [[ -n $SEED_RATIO || -n $SEED_HOURS ]]; then
|
||||||
|
seed=(--seed-ratio="${SEED_RATIO:-0.0}")
|
||||||
|
[[ -z $SEED_HOURS ]] || seed+=(--seed-time="$((SEED_HOURS * 60))")
|
||||||
|
echo "==> Downloading into $DOWNLOADS, then seeding (${SEED_RATIO:+ratio $SEED_RATIO}${SEED_RATIO:+${SEED_HOURS:+, }}${SEED_HOURS:+up to $SEED_HOURS hours})"
|
||||||
|
else
|
||||||
|
echo "==> Downloading into $DOWNLOADS"
|
||||||
|
fi
|
||||||
|
aria2c --dir="$DOWNLOADS" "${seed[@]}" --check-integrity=true \
|
||||||
|
--file-allocation=falloc --bt-max-peers=100 "$TORRENT"
|
||||||
fi
|
fi
|
||||||
# fp-index.json stays behind once the archives are extracted and deleted
|
# fp-index.json stays behind once the archives are extracted and deleted
|
||||||
PACKED=$(find "$DOWNLOADS" -maxdepth 2 \( -name GameData_1.zip -o -name fp-index.json \) -printf '%h\n' | head -1)
|
PACKED=$(find "$DOWNLOADS" -maxdepth 2 \( -name GameData_1.zip -o -name fp-index.json \) -printf '%h\n' | head -1)
|
||||||
[[ -n $PACKED ]] || { echo "No Ultimate archives found under $DOWNLOADS" >&2; exit 1; }
|
[[ -n $PACKED ]] || { echo "No Ultimate archives found under $DOWNLOADS" >&2; exit 1; }
|
||||||
|
|
||||||
# 2. Extract the game data, images and htdocs, one archive at a time
|
# 2. Extract the game data, images and htdocs, one archive at a time
|
||||||
DONE=$DOWNLOADS/.extracted
|
|
||||||
touch "$DONE"
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for archive in "$PACKED"/Images_*.zip "$PACKED"/Htdocs_*.zip "$PACKED"/GameData_*.zip "$PACKED"/Extras_*.zip; do
|
for archive in "$PACKED"/Images_*.zip "$PACKED"/Htdocs_*.zip "$PACKED"/GameData_*.zip "$PACKED"/Extras_*.zip; do
|
||||||
name=$(basename "$archive")
|
name=$(basename "$archive")
|
||||||
@@ -128,22 +221,19 @@ if [[ ! -f $DEST/preferences.json && -f $DEST/.preferences.defaults.json ]]; the
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Optionally add Infinity for Linux's software, without replacing anything
|
# 4. Optionally add Infinity for Linux's software, without replacing anything
|
||||||
if $INFINITY; then
|
if [[ -n $INFINITY ]]; then
|
||||||
infinity=$DOWNLOADS/$(basename "$INFINITY_URL")
|
echo "==> $(basename "$INFINITY")"
|
||||||
if [[ ! -f $infinity ]]; then
|
|
||||||
echo "==> Downloading Flashpoint Infinity for Linux"
|
|
||||||
curl -fL --retry 3 -o "$infinity.part" "$INFINITY_URL"
|
|
||||||
mv "$infinity.part" "$infinity"
|
|
||||||
fi
|
|
||||||
echo "$INFINITY_SHA256 $infinity" | sha256sum -c --quiet
|
|
||||||
staging=$DEST/.extracting
|
staging=$DEST/.extracting
|
||||||
rm -rf "$staging"
|
rm -rf "$staging"
|
||||||
"$SEVENZIP" x -y -bso0 -bsp1 -o"$staging" "$infinity"
|
"$SEVENZIP" x -y -bso0 -bsp1 -o"$staging" "$INFINITY"
|
||||||
root=$(dirname "$(find "$staging" -maxdepth 3 -name version.txt | head -1)")
|
root=$(dirname "$(find "$staging" -maxdepth 3 -name version.txt | head -1)")
|
||||||
cp -an "$root/." "$DEST/"
|
cp -an "$root/." "$DEST/"
|
||||||
rm -rf "$staging"
|
rm -rf "$staging"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -f $DEST/Data/flashpoint.sqlite ]] ||
|
[[ -f $DEST/Data/flashpoint.sqlite ]] ||
|
||||||
echo "Warning: no Data/flashpoint.sqlite. Re-run with --with-infinity to take Infinity's." >&2
|
echo "Warning: no Data/flashpoint.sqlite. Re-run with --infinity to take Infinity's." >&2
|
||||||
echo "Done. Set FLASHPOINT_HOST_PATH=$DEST in .env."
|
echo "Done. Set FLASHPOINT_HOST_PATH=$DEST in .env."
|
||||||
|
if [[ -n $TORRENT && -z $SEED_RATIO$SEED_HOURS ]]; then
|
||||||
|
echo "Consider seeding next time (--seed-ratio 1.0, or --seed-hours 24) to help keep Flashpoint's torrent alive."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ application path of `LocalGames\<name>\<game>.exe`.
|
|||||||
A Linux Flashpoint install. The streamer uses the Infinity build's
|
A Linux Flashpoint install. The streamer uses the Infinity build's
|
||||||
`Server/FlashpointGameServer`, `FPSoftware/Java` and `FPSoftware/Wine`, which
|
`Server/FlashpointGameServer`, `FPSoftware/Java` and `FPSoftware/Wine`, which
|
||||||
Ultimate's archives only include for Windows.
|
Ultimate's archives only include for Windows.
|
||||||
`scripts/setup-flashpoint.sh --with-infinity` adds them without overwriting
|
`scripts/setup-flashpoint.sh --infinity` adds them without overwriting
|
||||||
anything.
|
anything.
|
||||||
|
|
||||||
## Running one by hand
|
## Running one by hand
|
||||||
|
|||||||
Reference in New Issue
Block a user