Compare commits

...
Author SHA1 Message Date
branderson 97168ea0e5 ci: publish only a latest image tag, built from main
Drops version tag builds and the main tag. Images report the short
commit SHA as their version.
2026-10-15 17:34:14 -07:00
branderson a2d899be6f Merge pull request 'Mirror main to GitHub and publish images to GHCR' (#14) from github-mirror into main
Images / app (push) Successful in 5m35s
Images / session-manager (push) Successful in 1m37s
Images / streamer (push) Successful in 3m32s
Reviewed-on: #14
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 17:00:58 -07:00
branderson 2d1d8cce46 feat: mirror main to GitHub and publish images to GHCR
mirror/github-mirror.sh publishes main and its tags to a read-only
GitHub mirror, adding a notice under the README title, with a systemd
timer to run it hourly. A GitHub Actions workflow builds the images on
the mirror and pushes them to ghcr.io. The docs point prebuilt images
there, and CONTRIBUTING no longer invites issues and pull requests.

upstream-status.sh now fetches without tags, so upstream's version tags
stay out of local clones.
2026-10-15 16:48:04 -07:00
branderson 3c3bb5dfbf Merge pull request 'Add upstream sync process and agent notes' (#13) from upstream-sync-process into main
Images / app (push) Successful in 4m55s
Images / session-manager (push) Successful in 1m46s
Images / streamer (push) Successful in 3m44s
Reviewed-on: #13
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 16:36:56 -07:00
branderson 13a7fd9d19 docs: don't pin our migration numbers in sync notes 2026-10-15 16:36:43 -07:00
branderson 4fd43cd17d docs: add upstream sync process and agent notes
Adds scripts/upstream-status.sh to report upstream flashpoint-web changes
since the last merge, docs/08-development/upstream-sync.md describing the
merge process, and .dejima.md for agents working in dejima.
2026-10-15 16:31:33 -07:00
branderson 110e7fcd78 Merge pull request 'Credit Texgriddy and Fredoka; rename the color scheme to Slate' (#12) from credit-texgriddy into main
Images / app (push) Successful in 5m7s
Images / session-manager (push) Successful in 1m44s
Images / streamer (push) Successful in 3m33s
Reviewed-on: #12
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 16:24:17 -07:00
branderson f3d85f5239 docs: credit Texgriddy and Fredoka; rename the color scheme to Slate
The portal's look is inspired by Texgriddy, an ES-DE theme by
Weestuarty, recreated in CSS without any of its files. The README and
the portal doc now say so, and credit the Fredoka font. The color
scheme was named after Texgriddy's "Chilled" scheme; it's now Slate.
2026-10-15 16:16:00 -07:00
branderson 9a9d7e3787 Merge pull request 'Streaming: measure Flash Player's menu bar from the movie's offset' (#11) from flash-menu-offset into main
Images / app (push) Successful in 4m53s
Images / session-manager (push) Successful in 1m41s
Images / streamer (push) Successful in 3m27s
Reviewed-on: #11
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 18:18:01 -07:00
branderson e531228fa9 fix(streaming): keep Flash Player's window in place while it's open
Flash Player moves its window back to the top of the screen once the
movie has loaded, bringing the menu bar back. The streamer now checks
the window twice a second and puts it back, logging the first few
times.
2026-10-14 18:10:11 -07:00
branderson d1cebec142 fix(streaming): measure Flash Player's menu bar from the movie's offset
GTK draws the projector's menu bar in its window, not in a child window,
so the previous lookup found nothing and, under set -e and pipefail,
silently ended the block that moves the window. The movie is the
window's largest child, and its offset is the menu bar's height (56px
on flashbox). The block no longer stops on errors, logs what it measured
and where the window ended up, and GAME_WIDTH/GAME_HEIGHT, unused now,
are dropped.
2026-10-14 18:03:52 -07:00
branderson 3c4d3e7216 Merge pull request 'Streaming: hide Flash Player's menu bar without full screen' (#9) from flash-windowed into main
Images / app (push) Successful in 4m53s
Images / session-manager (push) Successful in 1m46s
Images / streamer (push) Successful in 3m31s
Reviewed-on: #9
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:26:37 -07:00
branderson e697638827 Merge pull request 'Carry Flash saves between Ruffle and the original Flash Player' (#8) from flash-save-sync into main
Images / app (push) Successful in 4m55s
Images / session-manager (push) Successful in 1m41s
Images / streamer (push) Successful in 3m31s
Reviewed-on: #8
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:26:19 -07:00
branderson 1306a88b0e fix(streaming): hide Flash Player's menu bar without full screen
Some games break in Flash Player's full-screen mode: in Bloons darts
make a sound but never fly. Instead of pressing Ctrl+F, the streamer
stretches the projector's window to the display and moves it up by the
menu bar's height, measured from the window's opening height and the
movie's. The session manager now passes the game's exact size as
GAME_WIDTH/GAME_HEIGHT and rounds only the display size to even.
2026-10-14 17:25:27 -07:00
branderson 28d590ddd2 fix(saves): treat a wrapper's [[DYNAMIC]] save path as the same save
A SWF a wrapper loads from memory saves under [[DYNAMIC]]/<n>, and
MochiAds does that in Ruffle but not in the standalone Flash Player, so
Bloons kept its progress at different paths in each. Saves differing
only by that step are now one save: the stream starts with Ruffle's
copy (the dynamic one when both exist) at every path it has for it, and
only saves the stream changed are copied back, to every Ruffle key for
them.
2026-10-14 17:04:50 -07:00
branderson a236d7a1b0 fix(saves): keep Ruffle's copy of a Flash save current in both directions
The stream starts with Ruffle's copy of each save, but Ruffle starts
first, and a game with progress only in the stream would write a fresh
save there that then replaced it. Before Ruffle starts a game it now
gets the stream's saves it doesn't have yet. This replaces the
first-switch rule, which kept a stale stream save over Ruffle progress.
Moving the Flash content type above GamePlayer's early return also
fixes a conditional hook.
2026-10-14 16:45:47 -07:00
branderson 3d93ef7304 feat(saves): carry Flash saves between Ruffle and the original Flash Player
Ruffle keeps SharedObjects in localStorage and the streamed Flash Player
as .sol files, in the same format under different names. Starting the
stream now adds the game's Ruffle saves, translated to Flash Player's
paths, and stream save updates are copied back into Ruffle's storage.
Root-path saves carry over only when this game made them. A game's
first switch keeps the stream's copy of a save both sides already had.
2026-10-14 16:14:59 -07:00
branderson c5b1f750bc Merge pull request 'Streaming: log session lifecycles and why streamers fail' (#7) from session-manager-logging into main
Images / app (push) Successful in 4m52s
Images / session-manager (push) Successful in 1m44s
Images / streamer (push) Successful in 3m37s
Reviewed-on: #7
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 16:02:27 -07:00
branderson 26f920c0da Merge pull request 'Docs: drop the old /stream/ proxy route when upgrading' (#6) from docs-stream-route into main
Images / app (push) Successful in 4m48s
Images / session-manager (push) Successful in 1m46s
Images / streamer (push) Successful in 3m34s
Reviewed-on: #6
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 16:02:05 -07:00
branderson b982e0a541 docs: tell upgraders to drop their /stream/ proxy route
The upgrade guide still said the /stream/ route was unchanged, but the
app now proxies streams itself and the session manager publishes no
port; an old route times out with a 504.
2026-10-14 00:47:25 -07:00
31 changed files with 1061 additions and 170 deletions
+50
View File
@@ -0,0 +1,50 @@
# Dotcom Arcade: notes for agents
Dotcom Arcade is a heavily modified fork of
[flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise
(GPL-3.0), with game streaming added under `streaming/`. Coding standards and
architecture notes are in `CLAUDE.md`; feature and deployment docs are in
`docs/`.
## Upstream
- **Upstream** is https://github.com/darkraise/flashpoint-web, branch `master`.
It's read-only from here: don't push to it or open pull requests there unless
asked.
- **Most of upstream's code is still used:** the backend, the frontend's data
layer, the player, and the account and admin pages. Fixes to these should come
in from upstream rather than being rewritten here.
- **To see what upstream has changed** since it was last merged, and how each
touched file stands here, run `scripts/upstream-status.sh`.
- **To pull changes in**, follow `docs/08-development/upstream-sync.md`. In
short:
- merge upstream's `master` on a branch (don't cherry-pick);
- keep files removed here removed;
- keep the Dotcom Arcade branding;
- keep upstream's migration file names;
- have the pull request merged with a merge commit, never squashed, so the
next sync starts from the right place.
- **Parts that are only ours:**
- `streaming/`;
- `scripts/`;
- `frontend/src/components/portal/`;
- `views/GamePage.tsx`;
- `lib/saves/flashSaves.ts`;
- the discovery backend;
- `.gitea/`;
- migrations upstream doesn't have.
## GitHub mirror
`mirror/` publishes `main` to a read-only GitHub mirror from the operator's
host, never from a session; see `docs/08-development/github-mirror.md`. GitHub
runs `.github/workflows`, the forge `.gitea/workflows`; keep the two image
workflows in step.
## CI
Workflows are in `.gitea/workflows`. Jobs that run docker builds use the
`dejima-privileged` runner label; anything else uses `dejima-confined`. The
privileged job image has no Docker client, so install `docker-ce-cli` first.
Never hardcode the forge's host name: read it at run time, or take it from the
`REGISTRY_HOST` repository variable.
+1 -1
View File
@@ -13,7 +13,7 @@ JWT_SECRET=replace-with-a-secure-random-string
# 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_PREFIX=ghcr.io/branderson/
# 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
+8 -7
View File
@@ -10,7 +10,6 @@ name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
# Jobs that run docker builds use dejima-privileged; anything else should use
@@ -59,6 +58,12 @@ jobs:
echo "host=$host" >> "$GITHUB_OUTPUT"
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
# Shown in Settings, since images carry no version number
- name: Name the version
id: version
shell: bash
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
@@ -71,11 +76,7 @@ jobs:
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
tags: type=raw,value=latest
- uses: docker/build-push-action@v6
with:
@@ -85,5 +86,5 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VERSION=${{ steps.version.outputs.sha }}
provenance: false
+70
View File
@@ -0,0 +1,70 @@
# Builds the app, session manager and streamer images on the GitHub mirror and
# pushes them to GitHub's container registry, under the repository's owner.
# The forge runs .gitea/workflows/images.yml instead; Gitea ignores this
# directory while .gitea/workflows exists.
name: Images
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: app
image: dotcom-arcade
context: .
- name: session-manager
image: dotcom-arcade-session-manager
context: streaming/session-manager
- name: streamer
image: dotcom-arcade-streamer
context: streaming/streamer
steps:
- uses: actions/checkout@v4
- name: Lowercase the owner
id: owner
shell: bash
run: echo "name=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
# Shown in Settings, since images carry no version number
- name: Name the version
id: version
shell: bash
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.owner.outputs.name }}/${{ matrix.image }}
tags: type=raw,value=latest
- 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.version.outputs.sha }}
provenance: false
+15 -105
View File
@@ -1,44 +1,11 @@
# Contributing to Dotcom Arcade
Thanks for your interest in contributing! We welcome bug fixes, features, documentation improvements, and questions.
Dotcom Arcade is developed on a private forge and published to GitHub as a
read-only mirror, so issues and pull requests aren't accepted there. You're
welcome to fork it under the [GPL-3.0](LICENSE).
## What is Dotcom Arcade?
A self-hosted web application for browsing and playing games from [Flashpoint Archive](https://flashpointarchive.org/).
**Tech Stack:** Node.js 20+, Express, React 18, TypeScript, SQLite, TanStack Query, Tailwind CSS
## Table of Contents
- [Where to Start](#where-to-start)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Running Tests](#running-tests)
- [Making Changes](#making-changes)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Code Style](#code-style)
- [Reporting Bugs](#reporting-bugs)
- [License](#license)
## Where to Start
- Browse issues labeled [`good first issue`](../../labels/good%20first%20issue) or [`help wanted`](../../labels/help%20wanted)
- Check the [project documentation](docs/) for architecture and patterns
## Getting Started
```bash
# Fork the repository on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/flashpoint-web.git
cd flashpoint-web
# Add upstream remote
git remote add upstream https://github.com/ORIGINAL_OWNER/flashpoint-web.git
# Keep your fork synced
git fetch upstream
git rebase upstream/master
```
It's built on [flashpoint-web](https://github.com/darkraise/flashpoint-web).
Fixes to code the two still share may belong upstream.
## Development Setup
@@ -47,6 +14,7 @@ git rebase upstream/master
```
backend/ # Express REST API + game content serving (:3100)
frontend/ # React + Vite application (:5173)
streaming/ # Session manager and streamer for games browsers can't run
docs/ # Documentation
```
@@ -69,6 +37,7 @@ The app (UI and API) runs on `http://localhost:80`.
### Option 2: Manual Setup (Recommended for Development)
**Prerequisites:**
- Node.js 20+
- Flashpoint Archive installed locally
@@ -99,80 +68,21 @@ cd backend && npm test -- --coverage
cd frontend && npm test
```
## Making Changes
### Branch Naming
```
feat/add-playlist-export
fix/game-search-pagination
refactor/auth-service
docs/update-api-reference
```
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
feat(games): add advanced search filters
fix(auth): resolve token refresh race condition
docs: update deployment guide
refactor(api): simplify error handling
```
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`
### Workflow
1. Create a branch from `master`
2. Make your changes
3. Run `npm run typecheck` and `npm test`
4. Update documentation if needed
5. Submit a pull request
## Submitting a Pull Request
### Before Submitting
```bash
git fetch upstream
git rebase upstream/master
npm run typecheck
npm run format
npm test
```
### PR Guidelines
- Keep PRs focused and reasonably sized
- Reference related issues (`Fixes #123`)
- Include a clear description of changes
- Update documentation for new features
## Code Style
See [CLAUDE.md](CLAUDE.md) for detailed coding standards. Key points:
- **TypeScript:** No `any` types, use `unknown` or proper interfaces
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before parameterized
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme tokens
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before
parameterized
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme
tokens
- **General:** Validate inputs, handle errors properly, clean up resources
Run `npm run format` before committing.
## Reporting Bugs
Before reporting, search [existing issues](../../issues) to avoid duplicates.
Include:
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Node version, browser)
- Screenshots or logs if applicable
Run `npm run format` before committing. Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org/).
## License
This project is licensed under [GPL-3.0](LICENSE). By contributing, you agree that your contributions will be licensed under the same license.
This project is licensed under [GPL-3.0](LICENSE). By contributing, you agree
that your contributions will be licensed under the same license.
+8
View File
@@ -135,6 +135,9 @@ PGID=1000
docker compose up -d --build
```
To pull prebuilt images instead of building them, see
[prebuilt images](docs/09-deployment/docker-deployment.md#prebuilt-images).
Open `http://<server>/` and create the admin account. On first start the app
indexes the collection, which takes a few minutes on a full Ultimate install.
Under **Settings → App** you can set the site name and tagline.
@@ -192,6 +195,11 @@ and development. Useful starting points:
- [Ruffle](https://ruffle.rs/), for running Flash in modern browsers.
- [Selkies](https://github.com/selkies-project/selkies), which streams the
games.
- [Texgriddy](https://github.com/Weestuarty/texgriddy-es-de) by Weestuarty, an
ES-DE theme that inspired the site's look. The look was recreated from scratch
in CSS; none of Texgriddy's files, images or fonts are used.
- [Fredoka](https://fontsource.org/fonts/fredoka) (SIL Open Font License), the
display font.
## Legal
+2
View File
@@ -15,6 +15,8 @@ the Dotcom Arcade application.
5. **[Testing Guide](./testing-guide.md)** - Testing approach and best practices
6. **[Debugging Guide](./debugging.md)** - Debugging tips and troubleshooting
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
8. **[Upstream Sync](./upstream-sync.md)** - Merging changes from flashpoint-web
9. **[GitHub Mirror](./github-mirror.md)** - Publishing to GitHub and GHCR
## Quick Start
+28
View File
@@ -0,0 +1,28 @@
# GitHub mirror
Dotcom Arcade is developed on a private forge. `mirror/github-mirror.sh`
publishes its `main` and the tags on `main` to
[github.com/branderson/dotcom-arcade](https://github.com/branderson/dotcom-arcade),
hourly, from a host that can push to GitHub. Issues and pull requests are off
there. Actions stays on, since it builds the images that `ghcr.io/branderson/`
serves (see [publishing images](../09-deployment/docker-deployment.md#publishing-images)).
The mirror's `main` is the forge's plus one commit, which adds a read-only notice
under the README's title. Every forge commit is in the mirror unchanged, and
each sync fast-forwards. If the README loses its `# ` title, the sync stops
instead of publishing without the notice.
## Setup
1. Set up the timer as described at the top of
`mirror/dotcom-arcade-github-mirror.service`. Give it a deploy key with write
access to the GitHub repository.
2. Run `github-mirror.sh --dry-run` to build what a sync would publish without
pushing it.
3. Once the first sync has pushed, run `github-mirror.sh --settings` (needs
`gh`). It turns off issues, pull requests, projects, wiki and discussions.
4. The first image build creates its packages as private. In each package's
settings on GitHub, set its visibility to public.
The timer runs a copy of the script, not the checkout's. Reinstall it after
reviewing a change to `mirror/`.
+99
View File
@@ -0,0 +1,99 @@
# Syncing with upstream flashpoint-web
Dotcom Arcade is a heavily modified fork of
[flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise
(branch `master`). Most of upstream's code is still used here: the backend, the
frontend's data layer, the player, and the account and admin pages. Upstream's
fixes and features to those parts should keep coming in, so the two projects
don't drift apart.
## What's whose
Comparing this repository with the last upstream commit merged into it:
- **Unchanged here:** most of the backend, the API client, hooks, stores and
types, the player, the account and admin views, and most docs. Upstream
changes to these merge as they are.
- **Changed here:** files upstream also owns that this project has modified.
The main ones:
- `GameService` (mature filter, release ranges, shuffle, related games);
- `server.ts` and `config.ts` (stream proxy, update check);
- `App.tsx` (portal routes);
- `GamePlayer` (streaming, saves);
- the browse layout and filters;
- the rebrand across names and docs.
Upstream changes to these need merging by hand.
- **Removed here:**
- the Flash and HTML5 sections;
- the GitHub stars endpoint;
- the old homepage pieces;
- the dev compose file;
- upstream's CI workflow and design notes.
These stay removed.
- **Only here:**
- `streaming/`;
- `scripts/`;
- `frontend/src/components/portal/`;
- `views/GamePage.tsx`;
- `lib/saves/flashSaves.ts`;
- `.gitea/`;
- the discovery backend;
- migrations upstream doesn't have.
Upstream never touches these, but a change elsewhere can still need matching
work here.
`scripts/upstream-status.sh` lists upstream's new commits and puts every file
they touch in one of these groups, so this list doesn't need keeping up to date
by hand.
## When
Run `scripts/upstream-status.sh` when upstream publishes a release, and at
least monthly. Sync when it shows new commits.
## How
1. On a branch from `main`, merge upstream:
```sh
scripts/upstream-status.sh
git checkout -b upstream-sync-$(date +%Y-%m-%d) origin/main
git merge refs/remotes/upstream/master
```
2. Resolve conflicts by group:
- **Changed here:** keep this project's behavior and take upstream's fix or
feature with it. Where they truly conflict, prefer this project's
behavior, and note what was left out in the pull request.
- **Removed here:** keep the file deleted
(`git rm <file>`). If upstream's change matters elsewhere, such as a bug
fix in shared logic, port that part to where the code lives now.
- **New upstream:** take it, unless it belongs to something removed here.
New UI usually lands in the old application layout. Decide whether it
belongs in the portal, and if so, follow up with a portal version.
- **Branding:** keep "Dotcom Arcade", our image and container names, and our
update check (`APP_UPDATE_URL`); don't take back upstream's.
3. Migrations: keep upstream's migration files under their own names, even
when a number clashes with ours. The migrations table records full names,
so `009_app_tagline` and an upstream `009_something` both run, in name
order. Renaming an upstream migration would run it a second time on
databases that already applied it. Check that ours and theirs don't depend
on each other's order.
4. Check the result:
- backend and frontend typecheck, tests and build;
- the session manager's tests if `streaming/` was affected;
- `docker compose up -d --build` with and without the `streaming` profile;
- the docs, for anything the merge made inaccurate.
5. Open a pull request listing upstream's commits and how each conflict was
resolved. **Merge it with a merge commit, not squash or rebase.** The merge
records how far upstream has been synced, so the next run of
`scripts/upstream-status.sh` starts from the right place.
## Sending changes upstream
Fixes made here to code upstream still shares, such as backend bugs, may be
worth offering upstream. Do that separately, from a branch based on upstream's
`master`, and only when the project's maintainer decides to.
+15 -7
View File
@@ -28,22 +28,30 @@ 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`:
Images are published to GitHub's container registry. To pull them instead of
building, set `IMAGE_PREFIX` in `.env`, ending in `/`. There's one tag,
`latest`, built from the tip of `main`.
```sh
IMAGE_PREFIX=registry.example.com/owner/
IMAGE_TAG=latest
IMAGE_PREFIX=ghcr.io/branderson/
```
Then run `docker compose pull && docker compose up -d`.
Then run `docker compose pull && docker compose up -d`. Images from another
registry work the same way, with its path as the prefix.
### Publishing images
Two workflows build the same images with the same tags:
- `.github/workflows/images.yml` runs on the
[GitHub mirror](../08-development/github-mirror.md) and pushes to
`ghcr.io/<owner>/` with the workflow's own token. It needs no setup.
- `.gitea/workflows/images.yml` runs on the forge. Gitea ignores
`.github/workflows` while `.gitea/workflows` exists.
`.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`. Its jobs run docker builds, so they need a runner labelled
owner. Each push to `main` publishes `latest`. Its jobs run docker builds, so they need a runner labelled
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
package write access, belonging to the repository's owner, whom the workflow
+6 -1
View File
@@ -33,4 +33,9 @@ What changes:
- **Streaming config.** Streaming is now part of the same compose file. Replace
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
The `/stream/` proxy route is unchanged.
- **No separate `/stream/` route.** The app now proxies streams to the session
manager itself, and the session manager no longer publishes port 8081.
Remove any `location /stream/` block from your reverse proxy, or streams
will time out with a 504. Move its WebSocket and timeout settings to the
location that serves the app (see
[Reverse proxy](../10-features/13-streaming.md#reverse-proxy)).
+7 -1
View File
@@ -30,9 +30,15 @@ tokens are applied to `<body>` while the portal is mounted, which takes
precedence over the app theme on `<html>` and also reaches dialogs and menus
rendered into the body.
One scheme ships, **Chilled** (slate background, sage boxes). Adding a scheme
One scheme ships, **Slate** (slate background, sage boxes). Adding a scheme
means adding an entry to `PORTAL_SCHEMES`; no component changes are needed.
The portal's look is inspired by
[Texgriddy](https://github.com/Weestuarty/texgriddy-es-de), an ES-DE theme by
Weestuarty: the framed page, glossy tiles with a metallic ring, outlined info
boxes, capsule buttons and the slate and sage palette. It was recreated from
scratch in CSS; none of Texgriddy's files, images or fonts are used.
Display text uses Fredoka (OFL, bundled via `@fontsource-variable/fredoka`);
body text uses Verdana.
+39
View File
@@ -22,6 +22,45 @@ The app works it out by watching instead: whatever changes while a game is
playing belongs to that game, and it remembers that for next time. The app's own
storage is never included.
## Ruffle and the original Flash Player
A Flash game's saves carry over when switching between Ruffle and the original
Flash Player (streamed). Both store SharedObjects in the same `.sol` format,
under different names:
- Ruffle keeps them in localStorage, keyed by the site's host and the path it
loaded the SWF from.
- Flash Player keeps them as files under `Flash_Player/#SharedObjects/` in the
streamed game's saves, keyed by the SWF's own host and path.
Starting the original Flash Player adds the game's Ruffle saves to the files the
stream starts with. While it plays, its saves are copied back into Ruffle's
storage as they change. Saves made with a `localPath` of `/` are shared by
every game in Ruffle, so only those seen changing while this game ran are
carried over.
Ruffle's copy of a save is kept up to date in both directions, so it's the one
the stream starts with:
- Before Ruffle starts a game, it's given any of the stream's saves it doesn't
have yet, such as progress made only in the stream. The game then loads that
progress instead of starting a fresh save, which would otherwise replace the
stream's progress on the next switch. Saves Ruffle already has are left alone.
- While the stream plays, the saves it changes are copied into Ruffle.
Some games save under a different path in each player. A SWF that a wrapper
(such as MochiAds) loads from memory saves under its loader's path plus
`[[DYNAMIC]]/<n>`, and a wrapper may do that in Ruffle but not in the
standalone Flash Player. Bloons, for example, keeps its progress under
`bloons.swf/[[DYNAMIC]]/1/` in Ruffle and under `bloons.swf/` in Flash Player.
Saves that differ only by that step are treated as one save:
- When the stream starts, a save is written to every path the stream has for
it. If Ruffle has both a dynamic and a plain copy, the dynamic one is used.
- A save the stream changes is copied to every key Ruffle has for it.
A stream save replaced this way is still in the account's backups.
## Sync
Before a game starts, the app compares the browser's save with the account's
+2
View File
@@ -125,6 +125,8 @@ for Flashpoint Ultimate.
- [Testing Guide](08-development/testing-guide.md) - Testing approach
- [Debugging](08-development/debugging.md) - Debugging tips
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
- [Upstream Sync](08-development/upstream-sync.md) - Merging changes from flashpoint-web
- [GitHub Mirror](08-development/github-mirror.md) - Publishing to GitHub and GHCR
### 09. Deployment
@@ -17,14 +17,15 @@ const flashGame = {
describe('GamePlayer player choice', () => {
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
render(<GamePlayer {...flashGame} streamable />);
expect(screen.getByText('ruffle')).toBeInTheDocument();
// Ruffle starts once any streamed saves have been handed to it
expect(await screen.findByText('ruffle')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
expect(screen.getByText('stream')).toBeInTheDocument();
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
expect(screen.getByText('ruffle')).toBeInTheDocument();
expect(await screen.findByText('ruffle')).toBeInTheDocument();
});
it('offers no choice when the game cannot be streamed', () => {
+42 -18
View File
@@ -14,6 +14,7 @@ import { RufflePlayer } from './RufflePlayer';
import { StreamPlayer } from './StreamPlayer';
import { SavesDialog } from './SavesDialog';
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
import { addStreamSavesToRuffle } from '@/lib/saves/flashSaves';
import { useAuthStore } from '@/store/auth';
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
import { PlatformIcon } from '@/components/ui/platform-icon';
@@ -137,6 +138,41 @@ function GamePlayerInternal({
}
}, [contentUrl, platform]);
// Determine Flash content type: direct SWF vs HTML wrapper
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
const flashContentType = useMemo(() => {
if (platform !== 'Flash' || !contentUrl) return null;
const urlPath = contentUrl.toLowerCase();
const isSwfUrl = urlPath.includes('.swf');
const isHtmlWrapper =
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
if (isSwfUrl && !isHtmlWrapper) {
return 'swf' as const;
}
return 'html-wrapper' as const;
}, [platform, contentUrl]);
// Before Ruffle starts, it gets any saves the streamed Flash Player made that
// it doesn't have yet. Otherwise a game played only streamed would start from
// scratch here, and that fresh save would replace its progress on the next switch.
const ruffleSwf = gameId && flashContentType === 'swf' && contentUrl ? contentUrl : null;
const [ruffleSavesEpoch, setRuffleSavesEpoch] = useState<number | null>(null);
useEffect(() => {
if (!saveSync.ready || !ruffleSwf || !gameId) return;
let cancelled = false;
addStreamSavesToRuffle(gameId, ruffleSwf)
.catch((error) => logger.error('[GamePlayer] Failed to copy stream saves to Ruffle:', error))
.finally(() => {
if (!cancelled) setRuffleSavesEpoch(saveSync.epoch);
});
return () => {
cancelled = true;
};
}, [saveSync.ready, saveSync.epoch, ruffleSwf, gameId]);
const ruffleSavesReady = !ruffleSwf || ruffleSavesEpoch === saveSync.epoch;
const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
@@ -178,22 +214,6 @@ function GamePlayerInternal({
const isAspectVideo = height === 'aspect-video';
// Determine Flash content type: direct SWF vs HTML wrapper
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
const flashContentType = useMemo(() => {
if (platform !== 'Flash' || !contentUrl) return null;
const urlPath = contentUrl.toLowerCase();
const isSwfUrl = urlPath.includes('.swf');
const isHtmlWrapper =
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
if (isSwfUrl && !isHtmlWrapper) {
return 'swf' as const;
}
return 'html-wrapper' as const;
}, [platform, contentUrl]);
return (
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
{showControls && (isFullscreen || !renderControls) ? (
@@ -327,7 +347,7 @@ function GamePlayerInternal({
}
}
>
{!saveSync.ready ? (
{!saveSync.ready || (!isStreamed && !ruffleSavesReady) ? (
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>Loading your save...</span>
@@ -336,7 +356,11 @@ function GamePlayerInternal({
<Fragment key={saveSync.epoch}>
{/* Other platforms - stream from the server */}
{isStreamed && gameId ? (
<StreamPlayer gameId={gameId} title={title} />
<StreamPlayer
gameId={gameId}
title={title}
swfUrl={flashContentType === 'swf' ? contentUrl : undefined}
/>
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
<RufflePlayer
swfUrl={contentUrl}
@@ -2,12 +2,15 @@ import { useEffect, useState } from 'react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { gamesApi } from '@/lib/api/games';
import { logger } from '@/lib/logger';
import { flashSaveContext, mirrorToRuffle, withRuffleSaves } from '@/lib/saves/flashSaves';
import { loadSaves, storeSaves } from '@/lib/streamSaves';
import { getErrorMessage } from '@/types/api-error';
export interface StreamPlayerProps {
gameId: string;
title: string;
/** For a Flash game Ruffle can also play: its SWF, so saves carry between the two */
swfUrl?: string;
}
type StreamState =
@@ -19,16 +22,18 @@ type StreamState =
* Plays a game that can't run in a browser by streaming it from a server-side
* container. Saves are kept in this browser and synced with the container.
*/
export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
export function StreamPlayer({ gameId, title, swfUrl }: StreamPlayerProps) {
const [state, setState] = useState<StreamState>({ status: 'starting' });
useEffect(() => {
let cancelled = false;
let events: EventSource | undefined;
const flash = swfUrl ? flashSaveContext(swfUrl, window.location.hostname) : null;
(async () => {
const session = await gamesApi.createStreamSession(gameId);
const files = await loadSaves(gameId);
const stored = await loadSaves(gameId);
const files = flash ? await withRuffleSaves(gameId, stored, flash) : stored;
const response = await fetch(`${session.path}_fp/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -40,10 +45,14 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
if (cancelled) return;
events = new EventSource(`${session.path}_fp/saves`);
let previous = files;
events.onmessage = (event) => {
storeSaves(gameId, JSON.parse(event.data).files).catch((error) =>
const { files: latest } = JSON.parse(event.data);
storeSaves(gameId, latest).catch((error) =>
logger.error('[StreamPlayer] Failed to store saves:', error)
);
if (flash) mirrorToRuffle(latest, previous, flash);
previous = latest;
};
events.addEventListener('end', () => events?.close());
setState({ status: 'running', path: session.path });
@@ -56,7 +65,7 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
cancelled = true;
events?.close();
};
}, [gameId]);
}, [gameId, swfUrl]);
if (state.status === 'error') {
return (
@@ -25,7 +25,7 @@ export function PortalLayout() {
const searchRef = useRef<HTMLInputElement>(null);
const [rolling, setRolling] = useState(false);
useEffect(() => applyPortalScheme('chilled'), []);
useEffect(() => applyPortalScheme('slate'), []);
useMatureSync();
const playRandom = async () => {
+2 -2
View File
@@ -8,8 +8,8 @@ export interface PortalScheme {
}
export const PORTAL_SCHEMES = {
chilled: {
label: 'Chilled',
slate: {
label: 'Slate',
tokens: {
'--p-bg': '#4c6878',
'--p-bg-mix': '#2f4553',
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
addStreamSavesToRuffle,
flashSaveContext,
flashToRuffle,
mirrorToRuffle,
ruffleToFlash,
withRuffleSaves,
} from './flashSaves';
const syncState = vi.hoisted(() => ({ keys: [] as string[] }));
const streamSaves = vi.hoisted(() => ({ files: {} as Record<string, string> }));
vi.mock('@/lib/streamSaves', () => ({ loadSaves: async () => streamSaves.files }));
vi.mock('./gameSaves', () => ({
loadSyncState: async (gameId: string) => ({
gameId,
keys: syncState.keys,
databases: [],
syncedHash: null,
savedAt: null,
}),
}));
// Written by Ruffle for SharedObject.getLocal('pathsave') and getLocal('rootsave', '/')
// in a SWF loaded from /game-proxy/http://savetest.example/dir/test.swf
const PATH_KEY = 'arcade.example.com/game-proxy/http://savetest.example/dir/test.swf/pathsave';
const ROOT_KEY = 'arcade.example.com//rootsave';
const PATH_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcGF0aHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
const ROOT_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcm9vdHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
const PATH_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/dir/test.swf/pathsave.sol';
const ROOT_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/rootsave.sol';
const context = flashSaveContext(
'/game-proxy/http://savetest.example/dir/test.swf',
'arcade.example.com'
);
if (!context) throw new Error('expected a context');
beforeEach(() => {
localStorage.clear();
syncState.keys = [];
streamSaves.files = {};
});
describe('flash save names', () => {
it('reads the naming context from a proxied SWF URL only', () => {
expect(context).toEqual({
siteHost: 'arcade.example.com',
proxyRoot: '/game-proxy/http://savetest.example',
swfHost: 'savetest.example',
});
expect(flashSaveContext('/game-zip/abc/test.swf', 'arcade.example.com')).toBeNull();
});
it("maps Ruffle's saves to Flash Player files, root saves only when the game made them", () => {
const entries = { [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL, 'other-game': 'not a save' };
const folder = { 'Flash_Player/#SharedObjects/ABCD1234/other.example/x.sol': 'AL8x' };
expect(ruffleToFlash(entries, context, new Set(), folder)).toEqual({
[PATH_FILE]: PATH_SOL,
});
expect(ruffleToFlash(entries, context, new Set([ROOT_KEY]), folder)).toEqual({
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
});
});
it("maps Flash Player files back to Ruffle's keys, ignoring other sites and files", () => {
const files = {
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
'Flash_Player/#SharedObjects/ABCD1234/elsewhere.example/x.sol': PATH_SOL,
'Prefs/data.txt': 'eA==',
};
expect(flashToRuffle(files, context)).toEqual({ [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL });
});
});
describe('carrying saves between players', () => {
it("starts the stream with Ruffle's saves, in the stream's own folder", async () => {
localStorage.setItem(PATH_KEY, PATH_SOL);
const files = await withRuffleSaves('game', { 'Prefs/x': 'eA==' }, context);
expect(files).toEqual({
'Prefs/x': 'eA==',
'Flash_Player/#SharedObjects/DOTCOMAR/savetest.example/dir/test.swf/pathsave.sol': PATH_SOL,
});
});
it("replaces the stream's copy of a save with Ruffle's, in the stream's folder", async () => {
localStorage.setItem(PATH_KEY, PATH_SOL);
const streamCopy = { [PATH_FILE]: 'AL8stalecopy', [ROOT_FILE]: ROOT_SOL };
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
});
});
it("copies the saves the stream changed into Ruffle's storage, and only those", () => {
localStorage.setItem(ROOT_KEY, 'AL8ruffleprogress');
mirrorToRuffle(
{ [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL },
{ [ROOT_FILE]: ROOT_SOL },
context
);
expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL);
expect(localStorage.getItem(ROOT_KEY)).toBe('AL8ruffleprogress');
});
it('gives Ruffle the stream-only saves before it starts, keeping its own', async () => {
window.history.replaceState(null, '', '/browse/game');
const host = window.location.hostname;
streamSaves.files = { [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL };
localStorage.setItem(`${host}//rootsave`, 'AL8ruffleprogress');
await addStreamSavesToRuffle('game', '/game-proxy/http://savetest.example/dir/test.swf');
expect(
localStorage.getItem(`${host}/game-proxy/http://savetest.example/dir/test.swf/pathsave`)
).toBe(PATH_SOL);
expect(localStorage.getItem(`${host}//rootsave`)).toBe('AL8ruffleprogress');
});
});
// Bloons runs inside a MochiAds wrapper: in Ruffle the game saves under
// [[DYNAMIC]]/1/, in the standalone Flash Player under the plain path
describe('saves a wrapper keeps under [[DYNAMIC]] in one player only', () => {
const bloons = flashSaveContext(
'/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf',
'arcade.example.com'
);
if (!bloons) throw new Error('expected a context');
const base =
'arcade.example.com/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf';
const files =
'Flash_Player/#SharedObjects/AQUKE2KN/games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf';
// A fresh profile, and one with levels cleared (shortened)
const FRESH =
'AL8AAABEVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQAAAAAABmxldmVsMQIABWNsZWFyAAAKbGV2ZWxiZXN0MQAAAAAAAAAAAAA=';
const PROGRESS = 'AL8AAAJJVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQ';
it("gives Flash Player's plain path Ruffle's dynamic progress", () => {
const ruffle = {
[`${base}/bloonsProfile`]: FRESH,
[`${base}/[[DYNAMIC]]/1/bloonsProfile`]: PROGRESS,
};
const stream = { [`${files}/bloonsProfile.sol`]: FRESH };
expect(ruffleToFlash(ruffle, bloons, new Set(), stream)).toEqual({
[`${files}/bloonsProfile.sol`]: PROGRESS,
[`${files}/[[DYNAMIC]]/1/bloonsProfile.sol`]: PROGRESS,
});
});
it("brings progress made in Flash Player back to Ruffle's dynamic path", () => {
localStorage.setItem(`${base}/bloonsProfile`, FRESH);
localStorage.setItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`, FRESH);
mirrorToRuffle(
{ [`${files}/bloonsProfile.sol`]: PROGRESS },
{ [`${files}/bloonsProfile.sol`]: FRESH },
bloons
);
expect(localStorage.getItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`)).toBe(PROGRESS);
expect(localStorage.getItem(`${base}/bloonsProfile`)).toBe(PROGRESS);
});
});
+221
View File
@@ -0,0 +1,221 @@
import { loadSyncState } from './gameSaves';
import { loadSaves, type SaveFiles } from '@/lib/streamSaves';
/**
* Ruffle and the original Flash Player (streamed) store SharedObjects in the
* same .sol format, under different names:
*
* - Ruffle, in localStorage, base64-encoded, keyed by the site's host and the
* path it loaded the SWF from: `<site>/game-proxy/http://<host>/<path>/<name>`,
* or `<site>//<name>` for a localPath of "/".
* - Flash Player, as files in the streamed game's saves directory:
* `Flash_Player/#SharedObjects/<folder>/<host>/<path>/<name>.sol`.
*
* A SWF loaded from memory, as by a MochiAds wrapper, saves under its loader's
* path plus `[[DYNAMIC]]/<n>`, and a wrapper may do that in one player and not
* the other: Bloons saves at `bloons.swf/[[DYNAMIC]]/1/` in Ruffle and at
* `bloons.swf/` in Flash Player. Saves differing only by that step are
* treated as one save.
*
* These helpers translate between the two so a game's saves follow it from one
* player to the other.
*/
const SHARED_OBJECTS = 'Flash_Player/#SharedObjects/';
/** Flash Player names this folder randomly when it creates it; an existing one is reused */
const DEFAULT_FOLDER = 'DOTCOMAR';
/** Every .sol file starts with the bytes 00 BF 00, which base64-encode to "AL8" */
const SOL_PREFIX = 'AL8';
export interface FlashSaveContext {
/** Host Ruffle keys saves by: the site's own */
siteHost: string;
/** Path Ruffle loads the game's SWF host under, e.g. /game-proxy/http://example.com */
proxyRoot: string;
/** Host Flash Player keys saves by: the SWF's own */
swfHost: string;
}
/** The naming context for a SWF played through the game proxy, or null for any other URL. */
export function flashSaveContext(swfUrl: string, siteHost: string): FlashSaveContext | null {
const match = swfUrl.match(/^(\/game-proxy\/https?:\/\/([^/]+))\//);
return match ? { siteHost, proxyRoot: match[1], swfHost: match[2] } : null;
}
/**
* A Ruffle key's save as a Flash Player path, `<host>/<path>/<name>.sol`, or
* null if it isn't this game's. Root-path saves are shared by every game, so
* only those in rootKeys count, unless rootKeys is null.
*/
function ruffleKeyToPath(
key: string,
context: FlashSaveContext,
rootKeys: ReadonlySet<string> | null
): string | null {
if (!key.startsWith(`${context.siteHost}/`)) return null;
const rest = key.slice(context.siteHost.length);
const slash = rest.lastIndexOf('/');
const localPath = rest.slice(0, slash) || '/';
const name = rest.slice(slash + 1);
if (localPath === '/') {
return rootKeys === null || rootKeys.has(key) ? `${context.swfHost}/${name}.sol` : null;
}
if (localPath.startsWith(`${context.proxyRoot}/`)) {
return `${context.swfHost}${localPath.slice(context.proxyRoot.length)}/${name}.sol`;
}
return null;
}
/** The Ruffle key for a Flash Player path, `<host>/<path>/<name>.sol`, or null for another host's. */
function pathToRuffleKey(path: string, context: FlashSaveContext): string | null {
const parts = path.split('/');
if (parts.length < 2 || parts[0] !== context.swfHost || !path.endsWith('.sol')) return null;
const name = parts[parts.length - 1].slice(0, -'.sol'.length);
const dirs = parts.slice(1, -1);
return dirs.length
? `${context.siteHost}${context.proxyRoot}/${dirs.join('/')}/${name}`
: `${context.siteHost}//${name}`;
}
/** A save file's folder and path below it, or null for files that aren't SharedObjects. */
function splitSaveFile(file: string): { folder: string; path: string } | null {
if (!file.startsWith(SHARED_OBJECTS) || !file.endsWith('.sol')) return null;
const rest = file.slice(SHARED_OBJECTS.length);
const slash = rest.indexOf('/');
return slash > 0 ? { folder: rest.slice(0, slash), path: rest.slice(slash + 1) } : null;
}
/** A save's path without its [[DYNAMIC]]/<n> steps, which one player may add and the other not. */
function saveIdentity(path: string): string {
return path.replace(/\/\[\[DYNAMIC\]\]\/\d+(?=\/)/g, '');
}
function isDynamic(path: string): boolean {
return saveIdentity(path) !== path;
}
function sharedObjectsFolder(files: SaveFiles): string {
for (const file of Object.keys(files)) {
const split = splitSaveFile(file);
if (split) return split.folder;
}
return DEFAULT_FOLDER;
}
/**
* Flash Player save files for a game's Ruffle SharedObjects. Each save is
* written to its own path and to every path the stream already has for it.
* Where Ruffle has a save under both a [[DYNAMIC]] path and the plain one, the
* dynamic copy is used: that's the game itself, the plain one its wrapper's.
*/
export function ruffleToFlash(
entries: Record<string, string>,
context: FlashSaveContext,
rootKeys: ReadonlySet<string>,
existing: SaveFiles = {}
): SaveFiles {
const folder = sharedObjectsFolder(existing);
const chosen = new Map<string, { value: string; dynamic: boolean }>();
const targets = new Map<string, Set<string>>();
const addTarget = (identity: string, file: string) =>
targets.set(identity, (targets.get(identity) ?? new Set()).add(file));
for (const [key, value] of Object.entries(entries)) {
if (!value.startsWith(SOL_PREFIX)) continue;
const path = ruffleKeyToPath(key, context, rootKeys);
if (path === null) continue;
const identity = saveIdentity(path);
const current = chosen.get(identity);
if (!current || (isDynamic(path) && !current.dynamic)) {
chosen.set(identity, { value, dynamic: isDynamic(path) });
}
addTarget(identity, `${SHARED_OBJECTS}${folder}/${path}`);
}
for (const file of Object.keys(existing)) {
const split = splitSaveFile(file);
if (split && chosen.has(saveIdentity(split.path))) addTarget(saveIdentity(split.path), file);
}
const files: SaveFiles = {};
for (const [identity, { value }] of chosen) {
for (const file of targets.get(identity) ?? []) files[file] = value;
}
return files;
}
/** Ruffle localStorage entries for a game's Flash Player save files, path for path. */
export function flashToRuffle(files: SaveFiles, context: FlashSaveContext): Record<string, string> {
const entries: Record<string, string> = {};
for (const [file, value] of Object.entries(files)) {
const split = splitSaveFile(file);
const key = split && pathToRuffleKey(split.path, context);
if (key) entries[key] = value;
}
return entries;
}
function readLocalStorage(): Record<string, string> {
const entries: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = key === null ? null : localStorage.getItem(key);
if (key !== null && value !== null) entries[key] = value;
}
return entries;
}
/**
* The save files to start a streamed Flash game with: its stream saves, with
* its Ruffle saves on top. Ruffle is where a game is usually played, and the
* stream's own changes are copied back into Ruffle as they happen, so Ruffle's
* copy is the latest.
*/
export async function withRuffleSaves(
gameId: string,
files: SaveFiles,
context: FlashSaveContext
): Promise<SaveFiles> {
const { keys } = await loadSyncState(gameId);
return { ...files, ...ruffleToFlash(readLocalStorage(), context, new Set(keys), files) };
}
/**
* Copies the saves a streamed game changed since `previous` into Ruffle's
* storage: to the save's own key and to every key Ruffle already has for it.
*/
export function mirrorToRuffle(
files: SaveFiles,
previous: SaveFiles,
context: FlashSaveContext
): void {
const ruffleKeys = Object.keys(readLocalStorage());
for (const [file, value] of Object.entries(files)) {
if (previous[file] === value) continue;
const split = splitSaveFile(file);
const key = split && pathToRuffleKey(split.path, context);
if (!split || !key) continue;
const identity = saveIdentity(split.path);
const aliases = ruffleKeys.filter((k) => {
const path = ruffleKeyToPath(k, context, null);
return path !== null && saveIdentity(path) === identity;
});
for (const target of new Set([key, ...aliases])) {
if (localStorage.getItem(target) !== value) localStorage.setItem(target, value);
}
}
}
/**
* Before Ruffle plays a game, gives it the streamed Flash Player's saves it
* doesn't have yet, so progress made only in the stream isn't started over.
* Saves Ruffle already has are left alone.
*/
export async function addStreamSavesToRuffle(gameId: string, swfUrl: string): Promise<void> {
const context = flashSaveContext(swfUrl, window.location.hostname);
if (!context) return;
for (const [key, value] of Object.entries(flashToRuffle(await loadSaves(gameId), context))) {
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, value);
}
}
}
@@ -0,0 +1,26 @@
# User unit for the GitHub mirror, on a host that can push to GitHub. Never in
# a dejima session: a sandbox may push only to its own forge.
#
# install -m 755 mirror/github-mirror.sh ~/.local/bin/dotcom-arcade-github-mirror
# cp mirror/dotcom-arcade-github-mirror.service mirror/dotcom-arcade-github-mirror.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dotcom-arcade-github-mirror.timer
#
# A copy, not a link into the checkout: the timer holds a key that can push to
# GitHub, and a session working in this checkout can edit the script.
# Reinstall it after reviewing an update.
#
# ~/.config/dotcom-arcade/github-mirror.env sets MIRROR_SOURCE, the forge
# clone URL, and whatever else mirror/github-mirror.sh documents. The GitHub
# key, a deploy key with write access to the repository, belongs behind an ssh
# Host alias named in MIRROR_TARGET, so it is not also offered to the forge:
#
# MIRROR_SOURCE=git@forge.example.com:owner/dotcom-arcade.git
# MIRROR_TARGET=git@github-dotcom-arcade:branderson/dotcom-arcade.git
[Unit]
Description=Mirror Dotcom Arcade's main and release tags to GitHub
[Service]
Type=oneshot
EnvironmentFile=%h/.config/dotcom-arcade/github-mirror.env
ExecStart=%h/.local/bin/dotcom-arcade-github-mirror
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Mirror Dotcom Arcade's main and release tags to GitHub, hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Publishes main and its release tags to a read-only mirror on GitHub.
#
# MIRROR_SOURCE=<forge clone URL> mirror/github-mirror.sh sync
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
# mirror/github-mirror.sh --settings configure the GitHub repo (needs gh)
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
#
# Runs on a host that can push to GitHub, never in a dejima session: a sandbox
# may push only to its own forge.
#
# The GitHub copy is not byte-for-byte. Its main is one extra commit on top of
# the forge's main, whose tree is the forge's with the rewrites in transform()
# applied, and whose parents are the forge commit and the previous mirror
# commit. So every forge SHA is in the mirror unchanged, and each sync is a
# fast-forward for anyone tracking it. Tags are pushed as they are, pointing at
# the untransformed forge commits.
#
# Environment:
# MIRROR_SOURCE forge clone URL (required to sync)
# MIRROR_REPO GitHub repository, default branderson/dotcom-arcade
# MIRROR_TARGET push URL, default git@github.com:$MIRROR_REPO.git
# MIRROR_STATE working bare repository, default ~/.cache/dotcom-arcade-github-mirror.git
set -euo pipefail
MIRROR_REPO="${MIRROR_REPO:-branderson/dotcom-arcade}"
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_REPO.git}"
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/dotcom-arcade-github-mirror.git}"
say() { echo "github-mirror: $*"; }
die() { say "$*" >&2; exit 1; }
WORK=""
cleanup() {
[ -n "$WORK" ] || return 0
git -C "$MIRROR_STATE" worktree remove --force "$WORK" 2>/dev/null || true
rm -rf "$WORK"
}
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
# drifted away from what this expects stops the mirror instead of publishing
# the forge's version. Running it twice changes nothing.
transform() {
local readme="$1/README.md"
# perl -i would write a symlink's target back as the README, publishing it.
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $1"
# The first thing a visitor to a mirror does is open an issue or a pull
# request. Under the title.
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
s{^(# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** Dotcom Arcade is developed on a private\n> forge, and issues and pull requests are not accepted here.\n}m
' "$readme"
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|| die "README.md: no title to put the mirror notice under"
}
# Issues, pull requests, wiki and the like off; Actions stays on, since it
# publishes the images. Settings rather than content, so a sync does not
# reapply them; run once after creating the repository.
settings() {
command -v gh >/dev/null || die "--settings needs the gh CLI"
gh api -X PATCH "repos/$MIRROR_REPO" --silent \
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
-F has_wiki=false -F has_discussions=false
}
sync() {
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
local git=(git -C "$MIRROR_STATE")
trap cleanup EXIT
[ -d "$MIRROR_STATE" ] || git init -q --bare "$MIRROR_STATE"
"${git[@]}" fetch -q --no-tags --prune "$MIRROR_SOURCE" \
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
local src prev=""
src="$("${git[@]}" rev-parse refs/source/main)"
# An absent main (exit 2) is a first run; anything else is a real failure.
local rc=0
git ls-remote -q --exit-code "$MIRROR_TARGET" refs/heads/main >/dev/null || rc=$?
if [ "$rc" -eq 0 ]; then
"${git[@]}" fetch -q --no-tags "$MIRROR_TARGET" '+refs/heads/main:refs/mirror/main'
prev="$("${git[@]}" rev-parse refs/mirror/main)"
elif [ "$rc" -ne 2 ]; then
die "cannot read $MIRROR_TARGET"
fi
WORK="$(mktemp -d)"
"${git[@]}" worktree add -q --detach "$WORK" "$src"
transform "$WORK"
git -C "$WORK" add -A
local tree
tree="$(git -C "$WORK" write-tree)"
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$src" ] \
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
say "already at ${src:0:12}"
else
# The forge commit's committer and date, so the same input always
# makes the same commit.
local name email date commit
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
"${git[@]}" commit-tree "$tree" -p "$src" ${prev:+-p "$prev"} \
-m "Mirror ${src:0:12} for GitHub" \
-m "README header added by mirror/github-mirror.sh.")"
"${git[@]}" update-ref refs/mirror/next "$commit"
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$MIRROR_TARGET" "$commit:refs/heads/main"
say "main is ${src:0:12} as ${commit:0:12}"
fi
# Only tags on main: one on another branch would publish unreleased work.
local ref
local refspecs=()
while IFS= read -r ref; do
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
refspecs+=("$ref:refs/tags/${ref#refs/source/tags/}")
say "tag ${ref#refs/source/tags/}"
fi
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|| "${git[@]}" push -q "$MIRROR_TARGET" "${refspecs[@]}"
}
DRY_RUN=""
case "${1:-}" in
"") sync ;;
--dry-run) DRY_RUN=1; sync ;;
--settings) settings ;;
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2" ;;
*) sed -n '4,7p' "$0" >&2; exit 2 ;;
esac
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Shows what upstream flashpoint-web has changed since it was last merged, and
# how this project stands on each file it touches. Changes nothing but the
# refs/remotes/upstream/master ref. See docs/08-development/upstream-sync.md.
set -euo pipefail
URL=${UPSTREAM_URL:-https://github.com/darkraise/flashpoint-web.git}
BRANCH=master
UPSTREAM=refs/remotes/upstream/$BRANCH
cd "$(git rev-parse --show-toplevel)"
git fetch -q --no-tags "$URL" "+$BRANCH:$UPSTREAM"
base=$(git merge-base HEAD "$UPSTREAM")
count=$(git rev-list --count "$base..$UPSTREAM")
echo "Last merged from upstream: $(git log -1 --format='%h %ad %s' --date=short "$base")"
if ((count == 0)); then
echo "Up to date with upstream $BRANCH."
exit 0
fi
echo "$count new upstream commit(s):"
git log --format=' %h %ad %s' --date=short "$base..$UPSTREAM"
echo
echo "Files they change, by where this project stands on each:"
echo " unchanged here: merges as is changed here: merge both sides"
echo " removed here: keep it removed new upstream: take it, unless it belongs to a removed area"
echo
git diff --name-only "$base" "$UPSTREAM" | while IFS= read -r file; do
if ! git cat-file -e "HEAD:$file" 2>/dev/null; then
if git cat-file -e "$base:$file" 2>/dev/null; then
echo "removed here $file"
else
echo "new upstream $file"
fi
elif git diff --quiet "$base" HEAD -- "$file"; then
echo "unchanged here $file"
else
echo "changed here $file"
fi
done | sort
+3 -2
View File
@@ -46,8 +46,9 @@ export class DockerDriver implements Driver {
`PUID=${uid}`,
`PGID=${gid}`,
`SUBFOLDER=${streamPath(id)}`,
`SELKIES_MANUAL_WIDTH=${size.width}`,
`SELKIES_MANUAL_HEIGHT=${size.height}`,
// The game's size, rounded up to even for H.264
`SELKIES_MANUAL_WIDTH=${size.width + (size.width % 2)}`,
`SELKIES_MANUAL_HEIGHT=${size.height + (size.height % 2)}`,
'SELKIES_UI_SHOW_SIDEBAR=false',
'HARDEN_DESKTOP=true',
'RESTART_APP=true',
+6 -11
View File
@@ -59,7 +59,7 @@ export class GameCatalog {
return null;
}
const page = await this.readContent(gameId, row, row.cmd).catch(() => null);
return even((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
return ((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
}
const program = await this.launchProgram(row);
if (!program) {
@@ -68,19 +68,19 @@ export class GameCatalog {
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
if (!url) {
const exe = await readFile(program).catch(() => null);
return even((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE);
return ((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE);
}
const content = await this.readContent(gameId, row, url).catch(() => null);
const extension = path.extname(new URL(url).pathname).toLowerCase();
if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) {
return even((content && directorStageSize(content)) ?? DEFAULT_SIZE);
return ((content && directorStageSize(content)) ?? DEFAULT_SIZE);
}
if (extension === '.swf') {
// The exact stage: in full screen, games that set noScale keep their native size
return even((content && swfStageSize(content)) ?? DEFAULT_SIZE);
// The exact stage, so the movie fills the display without scaling
return ((content && swfStageSize(content)) ?? DEFAULT_SIZE);
}
const embed = content && embedSize(content.toString('latin1'));
return even(embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE);
return (embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE);
}
/**
@@ -151,11 +151,6 @@ async function resolveCaseInsensitive(root: string, relative: string): Promise<s
return (await stat(current).catch(() => null))?.isFile() ? current : null;
}
/** H.264 needs even dimensions. */
function even({ width, height }: Size): Size {
return { width: width + (width % 2), height: height + (height % 2) };
}
/** Reads the stage size from a SWF header: a rect of twips after the 8-byte header. */
export function swfStageSize(swf: Buffer): Size | null {
const signature = swf.toString('latin1', 0, 3);
+3 -3
View File
@@ -177,11 +177,11 @@ test('GameCatalog streams allowed platforms whose launcher resolves, sized from
legacy: { width: 300, height: 200 },
missing: { width: 800, height: 600 },
webstart: null,
spr: { width: 500, height: 426 },
oldcpu: { width: 500, height: 426 },
spr: { width: 500, height: 425 },
oldcpu: { width: 500, height: 425 },
secure: { width: 640, height: 480 },
navigator: { width: 528, height: 400 },
flash: { width: 500, height: 376 },
flash: { width: 500, height: 375 },
unity: null,
'no-program': null,
exe: { width: 800, height: 600 },
+1
View File
@@ -30,6 +30,7 @@ RUN apt-get update && \
libxtst6 \
libxxf86vm1 \
sqlite3 \
x11-utils \
xdotool && \
rm -rf /var/lib/apt/lists/*
+8 -3
View File
@@ -52,6 +52,11 @@ writable mirror of the install, since they write beside themselves; a
Director projector's `Prefs`, where it saves, is linked into `/config/saves`, as
is Flash Player's save directory, so the session manager syncs both to the
player's browser. OldCPUSimulator hangs under Wine, so its target runs under
`cpulimit` at the same share of the CPU. Flash projectors switch to full screen,
which hides their menu bar and scales the movie to the display, and Flashpoint
Navigator's toolbars are hidden.
`cpulimit` at the same share of the CPU. A Flash projector's window is
stretched to the display and moved up by its menu bar's height, which hides the
menu bar. GTK draws the menu bar in the window itself and the movie in a child
window below it, so the movie's offset, read with `xwininfo`, is the menu bar's
height. The window is made override-redirect first, since the window manager
keeps windows on screen, and is kept in place while it's open, since Flash
Player moves it back once the movie has loaded. Its full-screen mode isn't used, since some games, such
as Bloons, don't play right in it. Flashpoint Navigator's toolbars are hidden.
@@ -47,8 +47,42 @@ case ${app,,} in
mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia"
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
# Full screen hides the projector's menu bar and scales the movie to the display
(xdotool search --sync --onlyvisible --name 'Flash Player' windowactivate --sync key ctrl+f) &
# Hide the projector's menu bar by moving it above the display, and fill
# the display with the movie. Not full screen, which some games, such as
# Bloons, don't play right in. The window manager keeps windows on
# screen, so the window is taken out of its hands first.
(
set +eo pipefail
wid=$(xdotool search --sync --onlyvisible --name 'Flash Player' | head -1)
read -r width height < <(xdotool getdisplaygeometry)
# GTK draws the menu bar in the window itself, and the movie in the
# largest child window, below it: the movie's offset is the menu bar's height
menu=$(xwininfo -children -id "$wid" | awk '
match($0, /[0-9]+x[0-9]+\+-?[0-9]+\+-?[0-9]+/) {
split(substr($0, RSTART, RLENGTH), g, /[x+]/)
if (g[1] * g[2] > area) { area = g[1] * g[2]; top = g[4] }
}
END { print top + 0 }')
((menu >= 0 && menu < height)) || menu=0
echo "Flash Player window $wid: menu bar ${menu}px, display ${width}x${height}"
xdotool windowunmap --sync "$wid"
xdotool set_window --overrideredirect 1 "$wid"
xdotool windowmap --sync "$wid"
place() { xdotool windowsize "$wid" "$width" $((height + menu)) windowmove "$wid" 0 $((-menu)); }
place
xdotool windowfocus "$wid"
# Flash Player moves its window back once the movie has loaded, so it's
# kept in place for as long as it's open
moves=0
while geometry=$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null); do
eval "$geometry"
if ((X != 0 || Y != -menu || WIDTH != width || HEIGHT != height + menu)); then
((++moves <= 3)) && echo "Flash Player window moved to $X,$Y (${WIDTH}x${HEIGHT}); putting it back"
place
fi
sleep 0.5
done
) &
fi
cd "${argv[0]}"
exec "${argv[@]:1}" ;;