Merge pull request 'Keep players' game saves on their account' (#5) from account-saves into main
Reviewed-on: branderson/flashpoint-web#5 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- ===================================
|
||||
-- Migration 008: Game saves
|
||||
-- ===================================
|
||||
-- Copies of the saves games keep in a player's browser, so a fresh browser can
|
||||
-- restore them. Each row is one snapshot; the newest few per user and game are
|
||||
-- kept as backups.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_save_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_game_save_snapshots_user_game
|
||||
ON game_save_snapshots(user_id, game_id, id DESC);
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import express, { Express } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs';
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// UserDatabaseService creates the directory
|
||||
const tempDir = vi.hoisted(
|
||||
() => `${process.env.TMPDIR ?? '/tmp'}/game-saves-${process.pid}-${Date.now()}`
|
||||
);
|
||||
|
||||
vi.mock('../config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config')>();
|
||||
return { config: { ...actual.config, userDbPath: `${tempDir}/user.db` } };
|
||||
});
|
||||
|
||||
import gameSavesRouter from './game-saves';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
import { UserDatabaseService } from '../services/UserDatabaseService';
|
||||
import { AuthService } from '../services/AuthService';
|
||||
|
||||
const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
|
||||
|
||||
describe('game saves API', () => {
|
||||
let app: Express;
|
||||
let alice: string;
|
||||
let bob: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await UserDatabaseService.initialize();
|
||||
const authService = new AuthService();
|
||||
alice = (await authService.trustedLogin('alice', null)).tokens.accessToken;
|
||||
bob = (await authService.trustedLogin('bobby', null)).tokens.accessToken;
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use('/api/game-saves', gameSavesRouter);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
UserDatabaseService.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const save = (token: string, hash: string, data: object = { localStorage: { k: hash } }) =>
|
||||
request(app)
|
||||
.post(`/api/game-saves/${GAME_ID}`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ data, hash, savedAt: '2026-10-12T10:00:00.000Z' });
|
||||
|
||||
const fetchSaves = (token: string) =>
|
||||
request(app).get(`/api/game-saves/${GAME_ID}`).set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('starts empty and requires a signed-in user', async () => {
|
||||
expect((await request(app).get(`/api/game-saves/${GAME_ID}`)).status).toBe(401);
|
||||
expect((await fetchSaves(alice)).body).toEqual({ latest: null, snapshots: [] });
|
||||
});
|
||||
|
||||
it('stores saves, skipping one identical to the current save', async () => {
|
||||
const first = await save(alice, 'h1');
|
||||
expect(first.status).toBe(201);
|
||||
expect((await save(alice, 'h1')).status).toBe(200);
|
||||
expect((await save(alice, 'h2')).status).toBe(201);
|
||||
|
||||
const { body } = await fetchSaves(alice);
|
||||
expect(body.latest).toMatchObject({ hash: 'h2', data: { localStorage: { k: 'h2' } } });
|
||||
expect(body.snapshots.map((s: { hash: string }) => s.hash)).toEqual(['h2', 'h1']);
|
||||
|
||||
const older = await request(app)
|
||||
.get(`/api/game-saves/${GAME_ID}/${first.body.id}`)
|
||||
.set('Authorization', `Bearer ${alice}`);
|
||||
expect(older.body).toMatchObject({ hash: 'h1', data: { localStorage: { k: 'h1' } } });
|
||||
});
|
||||
|
||||
it('keeps only the newest 10 saves', async () => {
|
||||
for (let i = 3; i <= 14; i++) {
|
||||
await save(alice, `h${i}`);
|
||||
}
|
||||
const { body } = await fetchSaves(alice);
|
||||
expect(body.snapshots).toHaveLength(10);
|
||||
expect(body.snapshots[0].hash).toBe('h14');
|
||||
expect(body.snapshots[9].hash).toBe('h5');
|
||||
});
|
||||
|
||||
it("keeps each user's saves separate", async () => {
|
||||
expect((await fetchSaves(bob)).body.latest).toBeNull();
|
||||
const aliceSnapshot = (await fetchSaves(alice)).body.latest.id;
|
||||
const res = await request(app)
|
||||
.get(`/api/game-saves/${GAME_ID}/${aliceSnapshot}`)
|
||||
.set('Authorization', `Bearer ${bob}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('accepts saves over the app-wide 1 MB body limit, and clamps future timestamps', async () => {
|
||||
const big = { indexedDB: 'x'.repeat(2 * 1024 * 1024) };
|
||||
const res = await request(app)
|
||||
.post(`/api/game-saves/${GAME_ID}`)
|
||||
.set('Authorization', `Bearer ${bob}`)
|
||||
.send({ data: big, hash: 'big', savedAt: '2999-01-01T00:00:00.000Z' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.size).toBeGreaterThan(2 * 1024 * 1024);
|
||||
expect(Date.parse(res.body.savedAt)).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('rejects invalid input', async () => {
|
||||
const bad = await request(app)
|
||||
.post('/api/game-saves/not-a-game')
|
||||
.set('Authorization', `Bearer ${alice}`)
|
||||
.send({ data: {}, hash: 'x', savedAt: '2026-10-12T10:00:00.000Z' });
|
||||
expect(bad.status).toBe(400);
|
||||
const noHash = await request(app)
|
||||
.post(`/api/game-saves/${GAME_ID}`)
|
||||
.set('Authorization', `Bearer ${alice}`)
|
||||
.send({ data: {}, savedAt: '2026-10-12T10:00:00.000Z' });
|
||||
expect(noHash.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import express, { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { GameSaveService } from '../services/GameSaveService';
|
||||
|
||||
const router = Router();
|
||||
const gameSaveService = new GameSaveService();
|
||||
|
||||
/** Saves can outgrow the app's 1 MB JSON limit; server.ts leaves parsing to this router. */
|
||||
export const GAME_SAVE_BODY_LIMIT = '10mb';
|
||||
|
||||
const gameIdSchema = z.string().uuid('Invalid game ID format');
|
||||
|
||||
const saveSchema = z.object({
|
||||
data: z.record(z.unknown()),
|
||||
hash: z.string().min(1).max(64),
|
||||
savedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
router.use(express.json({ limit: GAME_SAVE_BODY_LIMIT }));
|
||||
router.use(authenticate, requirePermission('games.play'));
|
||||
|
||||
function userId(req: express.Request): number {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
return req.user.id;
|
||||
}
|
||||
|
||||
// The current save with its data, and every kept snapshot's metadata
|
||||
router.get(
|
||||
'/:gameId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const gameId = gameIdSchema.parse(req.params.gameId);
|
||||
const id = userId(req);
|
||||
res.json({
|
||||
latest: gameSaveService.get(id, gameId),
|
||||
snapshots: gameSaveService.list(id, gameId),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:gameId/:snapshotId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const gameId = gameIdSchema.parse(req.params.gameId);
|
||||
const snapshotId = parseInt(req.params.snapshotId, 10);
|
||||
if (isNaN(snapshotId)) {
|
||||
throw new AppError(400, 'Invalid snapshot ID');
|
||||
}
|
||||
const snapshot = gameSaveService.get(userId(req), gameId, snapshotId);
|
||||
if (!snapshot) {
|
||||
throw new AppError(404, 'Save not found');
|
||||
}
|
||||
res.json(snapshot);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:gameId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const gameId = gameIdSchema.parse(req.params.gameId);
|
||||
const body = saveSchema.parse(req.body);
|
||||
// A browser's clock can run ahead; a save can't be from the future
|
||||
const savedAt = new Date(Math.min(Date.parse(body.savedAt), Date.now())).toISOString();
|
||||
const result = gameSaveService.save(userId(req), gameId, body.data, body.hash, savedAt);
|
||||
res.status(result.created ? 201 : 200).json(result.snapshot);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -22,6 +22,7 @@ import userPlaylistsRouter from './user-playlists';
|
||||
import sharedPlaylistsRouter from './shared-playlists';
|
||||
import favoritesRouter from './favorites';
|
||||
import ratingsRouter from './ratings';
|
||||
import gameSavesRouter from './game-saves';
|
||||
import ruffleRouter from './ruffle';
|
||||
import githubRouter from './github';
|
||||
import domainsRouter from './domains';
|
||||
@@ -49,6 +50,7 @@ export function setupRoutes(app: Express): void {
|
||||
app.use('/api/playlists/shared', sharedPlaylistsRouter);
|
||||
app.use('/api/favorites', favoritesRouter);
|
||||
app.use('/api/ratings', ratingsRouter);
|
||||
app.use('/api/game-saves', gameSavesRouter);
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api/games', downloadsRouter);
|
||||
app.use('/api/platforms', platformsRouter);
|
||||
|
||||
@@ -209,7 +209,11 @@ async function startServer() {
|
||||
})
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
// Game saves parse their own, larger bodies (routes/game-saves.ts)
|
||||
const jsonParser = express.json({ limit: '1mb' });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith('/api/game-saves') ? next() : jsonParser(req, res, next)
|
||||
);
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { UserDatabaseService } from './UserDatabaseService';
|
||||
|
||||
/** Snapshots kept per user and game, the newest being the current save. */
|
||||
export const SNAPSHOTS_KEPT = 10;
|
||||
|
||||
export interface GameSaveSnapshotMeta {
|
||||
id: number;
|
||||
hash: string;
|
||||
size: number;
|
||||
savedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GameSaveSnapshot extends GameSaveSnapshotMeta {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface SnapshotRow {
|
||||
id: number;
|
||||
content_hash: string;
|
||||
size: number;
|
||||
saved_at: string;
|
||||
created_at: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
function toMeta(row: SnapshotRow): GameSaveSnapshotMeta {
|
||||
return {
|
||||
id: row.id,
|
||||
hash: row.content_hash,
|
||||
size: row.size,
|
||||
savedAt: row.saved_at,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stores copies of the saves games keep in a player's browser. */
|
||||
export class GameSaveService {
|
||||
/** All snapshots for a game, newest first. */
|
||||
list(userId: number, gameId: string): GameSaveSnapshotMeta[] {
|
||||
return UserDatabaseService.all<SnapshotRow>(
|
||||
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots
|
||||
WHERE user_id = ? AND game_id = ? ORDER BY id DESC`,
|
||||
[userId, gameId]
|
||||
).map(toMeta);
|
||||
}
|
||||
|
||||
get(userId: number, gameId: string, id?: number): GameSaveSnapshot | null {
|
||||
const row = UserDatabaseService.get<SnapshotRow>(
|
||||
`SELECT id, content_hash, size, saved_at, created_at, data FROM game_save_snapshots
|
||||
WHERE user_id = ? AND game_id = ? ${id === undefined ? '' : 'AND id = ?'}
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
id === undefined ? [userId, gameId] : [userId, gameId, id]
|
||||
);
|
||||
return row ? { ...toMeta(row), data: JSON.parse(row.data ?? 'null') } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a snapshot as the game's current save, unless it matches the current
|
||||
* one, then drops snapshots beyond the newest SNAPSHOTS_KEPT.
|
||||
*/
|
||||
save(
|
||||
userId: number,
|
||||
gameId: string,
|
||||
data: unknown,
|
||||
hash: string,
|
||||
savedAt: string
|
||||
): { snapshot: GameSaveSnapshotMeta; created: boolean } {
|
||||
const json = JSON.stringify(data);
|
||||
const db = UserDatabaseService.getDatabase();
|
||||
return db.transaction(() => {
|
||||
const latest = db
|
||||
.prepare(
|
||||
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots
|
||||
WHERE user_id = ? AND game_id = ? ORDER BY id DESC LIMIT 1`
|
||||
)
|
||||
.get(userId, gameId) as SnapshotRow | undefined;
|
||||
if (latest?.content_hash === hash) {
|
||||
return { snapshot: toMeta(latest), created: false };
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO game_save_snapshots (user_id, game_id, content_hash, data, size, saved_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(userId, gameId, hash, json, Buffer.byteLength(json), savedAt);
|
||||
db.prepare(
|
||||
`DELETE FROM game_save_snapshots WHERE user_id = ? AND game_id = ? AND id NOT IN (
|
||||
SELECT id FROM game_save_snapshots WHERE user_id = ? AND game_id = ?
|
||||
ORDER BY id DESC LIMIT ?)`
|
||||
).run(userId, gameId, userId, gameId, SNAPSHOTS_KEPT);
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots WHERE id = ?`
|
||||
)
|
||||
.get(result.lastInsertRowid) as SnapshotRow;
|
||||
return { snapshot: toMeta(row), created: true };
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"msw": "^2.12.7",
|
||||
"postcss": "^8.4.38",
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Fragment, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft, MonitorPlay } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
MonitorPlay,
|
||||
History,
|
||||
} from 'lucide-react';
|
||||
import { LazyImage } from '@/components/common/LazyImage';
|
||||
import { RufflePlayer } from './RufflePlayer';
|
||||
import { StreamPlayer } from './StreamPlayer';
|
||||
import { SavesDialog } from './SavesDialog';
|
||||
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
||||
import { PlatformIcon } from '@/components/ui/platform-icon';
|
||||
|
||||
@@ -59,6 +70,10 @@ function GamePlayerInternal({
|
||||
const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
|
||||
// Streams a game Ruffle can play with the original player, for when Ruffle falls short
|
||||
const [preferStream, setPreferStream] = useState(false);
|
||||
// Signed-in players' saves are kept on their account
|
||||
const isSignedIn = useAuthStore((state) => state.isAuthenticated);
|
||||
const saveSync = useGameSaveSync(gameId, isSignedIn && (canPlayInBrowser || streamable));
|
||||
const [savesOpen, setSavesOpen] = useState(false);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
const [logoError, setLogoError] = useState(false);
|
||||
|
||||
@@ -234,6 +249,17 @@ function GamePlayerInternal({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isSignedIn && gameId ? (
|
||||
<button
|
||||
onClick={() => setSavesOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-secondary hover:bg-secondary/80 rounded-lg transition-colors flex-shrink-0"
|
||||
title="Saves kept on your account"
|
||||
aria-label="Saves"
|
||||
>
|
||||
<History size={18} aria-hidden="true" />
|
||||
<span className="text-sm hidden sm:inline">Saves</span>
|
||||
</button>
|
||||
) : null}
|
||||
{canSwitchPlayer ? (
|
||||
<button
|
||||
onClick={() => setPreferStream((prev) => !prev)}
|
||||
@@ -286,106 +312,123 @@ function GamePlayerInternal({
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Other platforms - stream from the server */}
|
||||
{isStreamed && gameId ? (
|
||||
<StreamPlayer gameId={gameId} title={title} />
|
||||
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||
<RufflePlayer
|
||||
swfUrl={contentUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full"
|
||||
scaleMode={defaultScaleMode}
|
||||
onLoadError={(error) => {
|
||||
logger.error('Ruffle load error:', error);
|
||||
}}
|
||||
onLoadSuccess={() => {
|
||||
logger.debug('Ruffle loaded successfully');
|
||||
}}
|
||||
/>
|
||||
) : /* Flash HTML wrapper - use iframe */ flashContentType === 'html-wrapper' &&
|
||||
contentUrl ? (
|
||||
<>
|
||||
<iframe
|
||||
src={contentUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
onLoad={() => {
|
||||
logger.debug('[Flash HTML Wrapper] Loaded successfully:', contentUrl);
|
||||
setIframeError(null);
|
||||
}}
|
||||
onError={(e) => {
|
||||
logger.error('[Flash HTML Wrapper] Load error:', e);
|
||||
setIframeError('Failed to load Flash game');
|
||||
}}
|
||||
/>
|
||||
{iframeError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
|
||||
<p className="mb-4">{iframeError}</p>
|
||||
<div className="bg-card rounded p-3 mb-4 border border-border">
|
||||
<p className="text-sm text-muted-foreground text-left">
|
||||
<strong>URL:</strong> {contentUrl}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the browser console for more details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : /* HTML5 game - use iframe */ platform === 'HTML5' && contentUrl ? (
|
||||
<>
|
||||
<iframe
|
||||
src={contentUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
onLoad={() => {
|
||||
logger.debug('[HTML5 Game] Loaded successfully:', contentUrl);
|
||||
setIframeError(null);
|
||||
}}
|
||||
onError={(e) => {
|
||||
logger.error('[HTML5 Game] Load error:', e);
|
||||
setIframeError('Failed to load HTML5 game');
|
||||
}}
|
||||
/>
|
||||
{iframeError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
|
||||
<p className="mb-4">{iframeError}</p>
|
||||
<div className="bg-card rounded p-3 mb-4 border border-border">
|
||||
<p className="text-sm text-muted-foreground text-left">
|
||||
<strong>URL:</strong> {contentUrl}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the browser console for more details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<AlertCircle size={48} className="text-yellow-500 mx-auto mb-4" />
|
||||
<p>No content URL available for this game</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Launch command: {launchCommand || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{!saveSync.ready ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<Fragment key={saveSync.epoch}>
|
||||
{/* Other platforms - stream from the server */}
|
||||
{isStreamed && gameId ? (
|
||||
<StreamPlayer gameId={gameId} title={title} />
|
||||
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||
<RufflePlayer
|
||||
swfUrl={contentUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full"
|
||||
scaleMode={defaultScaleMode}
|
||||
onLoadError={(error) => {
|
||||
logger.error('Ruffle load error:', error);
|
||||
}}
|
||||
onLoadSuccess={() => {
|
||||
logger.debug('Ruffle loaded successfully');
|
||||
}}
|
||||
/>
|
||||
) : /* Flash HTML wrapper - use iframe */ flashContentType === 'html-wrapper' &&
|
||||
contentUrl ? (
|
||||
<>
|
||||
<iframe
|
||||
src={contentUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
onLoad={() => {
|
||||
logger.debug('[Flash HTML Wrapper] Loaded successfully:', contentUrl);
|
||||
setIframeError(null);
|
||||
}}
|
||||
onError={(e) => {
|
||||
logger.error('[Flash HTML Wrapper] Load error:', e);
|
||||
setIframeError('Failed to load Flash game');
|
||||
}}
|
||||
/>
|
||||
{iframeError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
|
||||
<p className="mb-4">{iframeError}</p>
|
||||
<div className="bg-card rounded p-3 mb-4 border border-border">
|
||||
<p className="text-sm text-muted-foreground text-left">
|
||||
<strong>URL:</strong> {contentUrl}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the browser console for more details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : /* HTML5 game - use iframe */ platform === 'HTML5' && contentUrl ? (
|
||||
<>
|
||||
<iframe
|
||||
src={contentUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-top-navigation-by-user-activation"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
onLoad={() => {
|
||||
logger.debug('[HTML5 Game] Loaded successfully:', contentUrl);
|
||||
setIframeError(null);
|
||||
}}
|
||||
onError={(e) => {
|
||||
logger.error('[HTML5 Game] Load error:', e);
|
||||
setIframeError('Failed to load HTML5 game');
|
||||
}}
|
||||
/>
|
||||
{iframeError ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/90 z-10">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Failed to Load Game</h3>
|
||||
<p className="mb-4">{iframeError}</p>
|
||||
<div className="bg-card rounded p-3 mb-4 border border-border">
|
||||
<p className="text-sm text-muted-foreground text-left">
|
||||
<strong>URL:</strong> {contentUrl}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check the browser console for more details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<AlertCircle size={48} className="text-yellow-500 mx-auto mb-4" />
|
||||
<p>No content URL available for this game</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Launch command: {launchCommand || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
{isSignedIn && gameId ? (
|
||||
<SavesDialog
|
||||
gameId={gameId}
|
||||
isOpen={savesOpen}
|
||||
onClose={() => setSavesOpen(false)}
|
||||
onRestore={saveSync.restore}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { FormattedDate } from '@/components/common/FormattedDate';
|
||||
import { useDialog } from '@/contexts/DialogContext';
|
||||
import { gameSavesApi } from '@/lib/api/gameSaves';
|
||||
import { getErrorMessage } from '@/types/api-error';
|
||||
|
||||
interface SavesDialogProps {
|
||||
gameId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRestore: (snapshotId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Lists the saves kept on the player's account for a game, and restores one. */
|
||||
export function SavesDialog({ gameId, isOpen, onClose, onRestore }: SavesDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showConfirm, showToast } = useDialog();
|
||||
const [restoringId, setRestoringId] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['game-saves', gameId],
|
||||
queryFn: () => gameSavesApi.get(gameId),
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const restore = async (snapshotId: number) => {
|
||||
const confirmed = await showConfirm({
|
||||
title: 'Restore this save?',
|
||||
message: 'The game restarts from this save. Your current save is kept as a backup.',
|
||||
confirmText: 'Restore',
|
||||
variant: 'warning',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
setRestoringId(snapshotId);
|
||||
try {
|
||||
await onRestore(snapshotId);
|
||||
await queryClient.invalidateQueries({ queryKey: ['game-saves', gameId] });
|
||||
showToast('Save restored', 'success');
|
||||
onClose();
|
||||
} catch (restoreError) {
|
||||
showToast(getErrorMessage(restoreError) || 'Failed to restore save', 'error');
|
||||
} finally {
|
||||
setRestoringId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const snapshots = data?.snapshots ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Saves</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Your saves for this game are kept on your account, so another browser picks up where you
|
||||
left off. The last 10 are kept.
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="animate-spin text-muted-foreground" aria-label="Loading saves" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{getErrorMessage(error)}</p>
|
||||
) : snapshots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No saves yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{snapshots.map((snapshot, index) => (
|
||||
<li key={snapshot.id} className="flex items-center justify-between py-3 gap-4">
|
||||
<div className="text-sm">
|
||||
<FormattedDate date={snapshot.savedAt} type="datetime" />
|
||||
<div className="text-muted-foreground">
|
||||
{formatSize(snapshot.size)}
|
||||
{index === 0 ? ' · Current' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{index === 0 ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={restoringId !== null}
|
||||
onClick={() => void restore(snapshot.id)}
|
||||
>
|
||||
{restoringId === snapshot.id ? 'Restoring...' : 'Restore'}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import { useGameSaveSync } from './useGameSaveSync';
|
||||
import { saveHash, storeSyncState, type GameSaveData } from '@/lib/saves/gameSaves';
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadOnUnload: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/api/gameSaves', () => ({ gameSavesApi: api }));
|
||||
|
||||
const GAME = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
|
||||
const save = (value: string): GameSaveData => ({ localStorage: { 'site//save': value } });
|
||||
|
||||
function snapshot(value: string, savedAt: string, id = 1) {
|
||||
const data = save(value);
|
||||
return { id, hash: saveHash(data) ?? '', size: 10, savedAt, createdAt: savedAt, data };
|
||||
}
|
||||
|
||||
async function haveLocal(value: string, syncedHash: string | null, savedAt: string | null) {
|
||||
localStorage.setItem('site//save', value);
|
||||
await storeSyncState({ gameId: GAME, keys: ['site//save'], databases: [], syncedHash, savedAt });
|
||||
}
|
||||
|
||||
async function render(enabled = true) {
|
||||
const hook = renderHook(() => useGameSaveSync(GAME, enabled));
|
||||
await waitFor(() => expect(hook.result.current.ready).toBe(true));
|
||||
return hook;
|
||||
}
|
||||
|
||||
const uploadedValues = () =>
|
||||
api.upload.mock.calls.map(([, upload]) => upload.data.localStorage['site//save']);
|
||||
|
||||
describe('useGameSaveSync', () => {
|
||||
afterEach(async () => {
|
||||
// Unmounting saves what's left asynchronously; let it finish before the next test resets storage
|
||||
cleanup();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
localStorage.clear();
|
||||
api.upload.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("loads the account's save into a browser that has none", async () => {
|
||||
api.get.mockResolvedValue({
|
||||
latest: snapshot('from account', '2026-10-12T10:00:00Z'),
|
||||
snapshots: [],
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(localStorage.getItem('site//save')).toBe('from account');
|
||||
expect(api.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads a save the account does not have yet', async () => {
|
||||
api.get.mockResolvedValue({ latest: null, snapshots: [] });
|
||||
await haveLocal('local only', null, '2026-10-12T10:00:00Z');
|
||||
|
||||
await render();
|
||||
|
||||
expect(uploadedValues()).toEqual(['local only']);
|
||||
});
|
||||
|
||||
it('uploads a save changed here since the last sync', async () => {
|
||||
const account = snapshot('synced', '2026-10-12T09:00:00Z');
|
||||
api.get.mockResolvedValue({ latest: account, snapshots: [] });
|
||||
await haveLocal('played further', account.hash, '2026-10-12T10:00:00Z');
|
||||
|
||||
await render();
|
||||
|
||||
expect(localStorage.getItem('site//save')).toBe('played further');
|
||||
expect(uploadedValues()).toEqual(['played further']);
|
||||
});
|
||||
|
||||
it("takes the account's save when only another browser changed it", async () => {
|
||||
const synced = saveHash(save('synced'));
|
||||
api.get.mockResolvedValue({
|
||||
latest: snapshot('elsewhere', '2026-10-12T11:00:00Z'),
|
||||
snapshots: [],
|
||||
});
|
||||
await haveLocal('synced', synced, '2026-10-12T09:00:00Z');
|
||||
|
||||
await render();
|
||||
|
||||
expect(localStorage.getItem('site//save')).toBe('elsewhere');
|
||||
expect(api.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the newer save when both changed, backing up the other', async () => {
|
||||
api.get.mockResolvedValue({
|
||||
latest: snapshot('elsewhere', '2026-10-12T11:00:00Z'),
|
||||
snapshots: [],
|
||||
});
|
||||
await haveLocal('here', saveHash(save('long ago')), '2026-10-12T10:00:00Z');
|
||||
|
||||
await render();
|
||||
|
||||
expect(localStorage.getItem('site//save')).toBe('elsewhere');
|
||||
// This browser's save is uploaded first so it stays as a backup, then the winner again
|
||||
expect(uploadedValues()).toEqual(['here', 'elsewhere']);
|
||||
});
|
||||
|
||||
it('restores a backup and remounts the game', async () => {
|
||||
api.get.mockResolvedValue({
|
||||
latest: snapshot('current', '2026-10-12T11:00:00Z'),
|
||||
snapshots: [],
|
||||
});
|
||||
api.getSnapshot.mockResolvedValue(snapshot('older', '2026-10-12T08:00:00Z', 2));
|
||||
const { result } = await render();
|
||||
const epoch = result.current.epoch;
|
||||
|
||||
await act(() => result.current.restore(2));
|
||||
|
||||
expect(localStorage.getItem('site//save')).toBe('older');
|
||||
expect(uploadedValues()).toContain('older');
|
||||
expect(result.current.epoch).toBe(epoch + 1);
|
||||
});
|
||||
|
||||
it('does nothing for guests', async () => {
|
||||
await render(false);
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { gameSavesApi, type GameSaveUpload } from '@/lib/api/gameSaves';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
SaveChangeTracker,
|
||||
applySave,
|
||||
collectSave,
|
||||
loadSyncState,
|
||||
saveHash,
|
||||
storeSyncState,
|
||||
type SaveSyncState,
|
||||
} from '@/lib/saves/gameSaves';
|
||||
|
||||
const CHECK_INTERVAL_MS = 5000;
|
||||
const UPLOAD_DELAY_MS = 10000;
|
||||
|
||||
export interface GameSaveSync {
|
||||
/** False until this browser's save is reconciled with the account's */
|
||||
ready: boolean;
|
||||
/** Changes whenever a restore replaces the save, to remount the game */
|
||||
epoch: number;
|
||||
/** Replaces the save with an account snapshot, keeping the current one as a backup */
|
||||
restore: (snapshotId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a signed-in player's game save on their account. Before the game runs,
|
||||
* loads the account's save if this browser has nothing newer; while it runs,
|
||||
* uploads changes, the account keeping the last few as backups.
|
||||
*/
|
||||
export function useGameSaveSync(gameId: string | undefined, enabled: boolean): GameSaveSync {
|
||||
const active = enabled && Boolean(gameId);
|
||||
const [ready, setReady] = useState(!active);
|
||||
const [epoch, setEpoch] = useState(0);
|
||||
const stateRef = useRef<SaveSyncState | null>(null);
|
||||
const trackerRef = useRef<SaveChangeTracker | null>(null);
|
||||
const uploadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<GameSaveUpload | null>(null);
|
||||
|
||||
const persist = useCallback(async (state: SaveSyncState) => {
|
||||
stateRef.current = state;
|
||||
await storeSyncState(state);
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(async () => {
|
||||
const state = stateRef.current;
|
||||
if (!state || !gameId) return;
|
||||
const data = await collectSave(state);
|
||||
const hash = saveHash(data);
|
||||
if (!hash || hash === state.syncedHash) return;
|
||||
const savedAt = state.savedAt ?? new Date().toISOString();
|
||||
pendingRef.current = { data, hash, savedAt };
|
||||
await gameSavesApi.upload(gameId, { data, hash, savedAt });
|
||||
pendingRef.current = null;
|
||||
await persist({ ...state, syncedHash: hash });
|
||||
}, [gameId, persist]);
|
||||
|
||||
/** Checks for changes, and schedules an upload if there are any. */
|
||||
const check = useCallback(async () => {
|
||||
const tracker = trackerRef.current;
|
||||
const state = stateRef.current;
|
||||
if (!tracker || !state) return;
|
||||
const result = await tracker.check(state);
|
||||
if (!result.changed) return;
|
||||
const next = { ...result.state, savedAt: new Date().toISOString() };
|
||||
await persist(next);
|
||||
pendingRef.current = { data: await collectSave(next), hash: '', savedAt: next.savedAt };
|
||||
if (uploadTimerRef.current) clearTimeout(uploadTimerRef.current);
|
||||
uploadTimerRef.current = setTimeout(() => {
|
||||
upload().catch((error) => logger.error('[GameSaves] Upload failed:', error));
|
||||
}, UPLOAD_DELAY_MS);
|
||||
}, [persist, upload]);
|
||||
|
||||
// Reconcile before the game starts
|
||||
useEffect(() => {
|
||||
if (!active || !gameId) return;
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
|
||||
(async () => {
|
||||
let state = await loadSyncState(gameId);
|
||||
const local = await collectSave(state);
|
||||
const localHash = saveHash(local);
|
||||
const { latest } = await gameSavesApi.get(gameId);
|
||||
|
||||
if (!latest) {
|
||||
stateRef.current = state;
|
||||
if (localHash) await upload();
|
||||
} else if (latest.hash === localHash) {
|
||||
await persist({ ...state, syncedHash: latest.hash });
|
||||
} else if (!localHash || localHash === state.syncedHash) {
|
||||
// Unchanged here since the last sync: the account's save is newer
|
||||
state = await applySave(state, latest.data);
|
||||
await persist({ ...state, syncedHash: latest.hash, savedAt: latest.savedAt });
|
||||
} else {
|
||||
// Changed here; also changed elsewhere unless the account still holds what was synced
|
||||
const localIsNewer =
|
||||
latest.hash === state.syncedHash ||
|
||||
(state.savedAt !== null && Date.parse(state.savedAt) > Date.parse(latest.savedAt));
|
||||
stateRef.current = state;
|
||||
await upload();
|
||||
if (!localIsNewer) {
|
||||
// The account's save wins; the upload above keeps this one as a backup
|
||||
state = await applySave(stateRef.current ?? state, latest.data);
|
||||
await gameSavesApi.upload(gameId, {
|
||||
data: latest.data,
|
||||
hash: latest.hash,
|
||||
savedAt: latest.savedAt,
|
||||
});
|
||||
await persist({ ...state, syncedHash: latest.hash, savedAt: latest.savedAt });
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((error) => logger.error('[GameSaves] Could not sync with the account:', error))
|
||||
.then(async () => {
|
||||
stateRef.current ??= await loadSyncState(gameId);
|
||||
trackerRef.current = await SaveChangeTracker.start(gameId);
|
||||
})
|
||||
.catch((error) => logger.error('[GameSaves] Could not start tracking saves:', error))
|
||||
.finally(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, gameId, persist, upload, epoch]);
|
||||
|
||||
// Watch for changes while the game runs, and save what's left when it stops
|
||||
useEffect(() => {
|
||||
if (!active || !ready || !gameId) return;
|
||||
const interval = setInterval(() => {
|
||||
check().catch((error) => logger.error('[GameSaves] Check failed:', error));
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
const onPageHide = () => {
|
||||
const pending = pendingRef.current;
|
||||
const state = stateRef.current;
|
||||
if (pending && state) {
|
||||
const hash = saveHash(pending.data);
|
||||
if (hash && hash !== state.syncedHash) {
|
||||
gameSavesApi.uploadOnUnload(gameId, { ...pending, hash });
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
if (uploadTimerRef.current) clearTimeout(uploadTimerRef.current);
|
||||
check()
|
||||
.then(upload)
|
||||
.catch((error) => logger.error('[GameSaves] Final upload failed:', error));
|
||||
};
|
||||
}, [active, ready, gameId, check, upload]);
|
||||
|
||||
const restore = useCallback(
|
||||
async (snapshotId: number) => {
|
||||
if (!gameId) return;
|
||||
await check();
|
||||
await upload();
|
||||
const snapshot = await gameSavesApi.getSnapshot(gameId, snapshotId);
|
||||
const state = await applySave(
|
||||
stateRef.current ?? (await loadSyncState(gameId)),
|
||||
snapshot.data
|
||||
);
|
||||
const savedAt = new Date().toISOString();
|
||||
await gameSavesApi.upload(gameId, { data: snapshot.data, hash: snapshot.hash, savedAt });
|
||||
await persist({ ...state, syncedHash: snapshot.hash, savedAt });
|
||||
setEpoch((value) => value + 1);
|
||||
},
|
||||
[gameId, check, upload, persist]
|
||||
);
|
||||
|
||||
return { ready: ready || !active, epoch, restore };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { apiClient } from './client';
|
||||
import type { GameSaveData } from '@/lib/saves/gameSaves';
|
||||
|
||||
export interface GameSaveSnapshotMeta {
|
||||
id: number;
|
||||
hash: string;
|
||||
size: number;
|
||||
savedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GameSaveSnapshot extends GameSaveSnapshotMeta {
|
||||
data: GameSaveData;
|
||||
}
|
||||
|
||||
export interface GameSaves {
|
||||
latest: GameSaveSnapshot | null;
|
||||
snapshots: GameSaveSnapshotMeta[];
|
||||
}
|
||||
|
||||
export interface GameSaveUpload {
|
||||
data: GameSaveData;
|
||||
hash: string;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
/** Keepalive requests outliving a closing page may carry at most 64 KiB. */
|
||||
const KEEPALIVE_LIMIT = 60 * 1024;
|
||||
|
||||
export const gameSavesApi = {
|
||||
get: async (gameId: string): Promise<GameSaves> => {
|
||||
const { data } = await apiClient.get<GameSaves>(`/game-saves/${gameId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
getSnapshot: async (gameId: string, snapshotId: number): Promise<GameSaveSnapshot> => {
|
||||
const { data } = await apiClient.get<GameSaveSnapshot>(`/game-saves/${gameId}/${snapshotId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
upload: async (gameId: string, upload: GameSaveUpload): Promise<GameSaveSnapshotMeta> => {
|
||||
const { data } = await apiClient.post<GameSaveSnapshotMeta>(`/game-saves/${gameId}`, upload);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads while the page is closing, when an axios request would be cancelled.
|
||||
* Returns false when the save is too large for a keepalive request.
|
||||
*/
|
||||
uploadOnUnload: (gameId: string, upload: GameSaveUpload): boolean => {
|
||||
const body = JSON.stringify(upload);
|
||||
if (body.length > KEEPALIVE_LIMIT) return false;
|
||||
void fetch(`/api/game-saves/${gameId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
body,
|
||||
}).catch(() => undefined);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ export { apiClient } from './client';
|
||||
export { authApi } from './auth';
|
||||
|
||||
export { gamesApi } from './games';
|
||||
export { gameSavesApi } from './gameSaves';
|
||||
|
||||
export { playlistsApi, communityPlaylistsApi } from './playlists';
|
||||
export type { CommunityPlaylistsResponse } from './playlists';
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import {
|
||||
SaveChangeTracker,
|
||||
applySave,
|
||||
collectSave,
|
||||
loadSyncState,
|
||||
saveHash,
|
||||
storeSyncState,
|
||||
} from './gameSaves';
|
||||
import { loadSaves, storeSaves } from '@/lib/streamSaves';
|
||||
|
||||
const GAME = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
|
||||
|
||||
async function writeDatabase(name: string, value: string): Promise<void> {
|
||||
const open = indexedDB.open(name, 1);
|
||||
open.onupgradeneeded = () => open.result.createObjectStore('state');
|
||||
const db = await new Promise<IDBDatabase>(
|
||||
(resolve) => (open.onsuccess = () => resolve(open.result))
|
||||
);
|
||||
const tx = db.transaction('state', 'readwrite');
|
||||
tx.objectStore('state').put(value, 'save');
|
||||
await new Promise((resolve) => (tx.oncomplete = resolve));
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe('game saves in the browser', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('attributes storage that changes while a game runs, ignoring the app and untouched storage', async () => {
|
||||
localStorage.setItem('other-game/save', 'unchanged');
|
||||
await writeDatabase('other-game-db', 'unchanged');
|
||||
const tracker = await SaveChangeTracker.start(GAME);
|
||||
let state = await loadSyncState(GAME);
|
||||
|
||||
expect((await tracker.check(state)).changed).toBe(false);
|
||||
|
||||
localStorage.setItem('games.example/game-proxy/http:/example.com/game.swf/save', 'AMF');
|
||||
localStorage.setItem('flashpoint-theme-settings', '{}');
|
||||
// Selkies' settings, from a streamed game's iframe on the same origin
|
||||
const selkiesPrefix = `${location.origin}/stream/`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
localStorage.setItem(
|
||||
`${selkiesPrefix}0123456789abcdef0123456789abcdef_scaleLocallyManual`,
|
||||
'true'
|
||||
);
|
||||
await writeDatabase('wildlife-rescue:/scope/', 'level 3');
|
||||
await storeSaves(GAME, { 'cookies.txt': 'YQ==' });
|
||||
|
||||
const result = await tracker.check(state);
|
||||
expect(result.changed).toBe(true);
|
||||
state = result.state;
|
||||
expect(state.keys).toEqual(['games.example/game-proxy/http:/example.com/game.swf/save']);
|
||||
expect(state.databases).toEqual(['wildlife-rescue:/scope/']);
|
||||
|
||||
const save = await collectSave(state);
|
||||
expect(save.localStorage).toEqual({
|
||||
'games.example/game-proxy/http:/example.com/game.swf/save': 'AMF',
|
||||
});
|
||||
expect(save.stream).toEqual({ 'cookies.txt': 'YQ==' });
|
||||
expect(save.indexedDB?.map((db) => db.name)).toEqual(['wildlife-rescue:/scope/']);
|
||||
expect(saveHash(save)).not.toBeNull();
|
||||
expect(saveHash({})).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves out Selkies settings a save picked up before they were excluded', async () => {
|
||||
const selkiesKey =
|
||||
`${location.origin}/stream/`.replace(/[^a-zA-Z0-9._-]/g, '_') + 'abc_scaleLocallyManual';
|
||||
localStorage.setItem(selkiesKey, 'true');
|
||||
localStorage.setItem('game-key', 'save');
|
||||
const save = await collectSave({
|
||||
gameId: GAME,
|
||||
keys: [selkiesKey, 'game-key'],
|
||||
databases: [],
|
||||
syncedHash: null,
|
||||
savedAt: null,
|
||||
});
|
||||
expect(save.localStorage).toEqual({ 'game-key': 'save' });
|
||||
});
|
||||
|
||||
it('applies a save into a fresh browser, replacing what the game kept before', async () => {
|
||||
localStorage.setItem('key-a', 'old');
|
||||
const tracker = await SaveChangeTracker.start(GAME);
|
||||
localStorage.setItem('key-a', 'mine');
|
||||
await writeDatabase('game-db', 'mine');
|
||||
await storeSaves(GAME, { 'cookies.txt': 'YQ==' });
|
||||
const { state } = await tracker.check(await loadSyncState(GAME));
|
||||
const save = JSON.parse(JSON.stringify(await collectSave(state)));
|
||||
await storeSyncState(state);
|
||||
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
localStorage.clear();
|
||||
localStorage.setItem('key-a', 'stale');
|
||||
const applied = await applySave(await loadSyncState(GAME), save);
|
||||
|
||||
expect(localStorage.getItem('key-a')).toBe('mine');
|
||||
expect(await loadSaves(GAME)).toEqual({ 'cookies.txt': 'YQ==' });
|
||||
expect(saveHash(await collectSave(applied))).toBe(saveHash(save));
|
||||
|
||||
// Applying a save without a key removes the key the game had
|
||||
await applySave(applied, { indexedDB: save.indexedDB });
|
||||
expect(localStorage.getItem('key-a')).toBeNull();
|
||||
expect(await loadSaves(GAME)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { loadSaves, storeSaves, type SaveFiles } from '@/lib/streamSaves';
|
||||
import { hashSave } from './hash';
|
||||
import {
|
||||
deleteDatabase,
|
||||
exportDatabase,
|
||||
importDatabase,
|
||||
listDatabases,
|
||||
type SerializedDatabase,
|
||||
} from './indexedDb';
|
||||
|
||||
/** Everything a game keeps in this browser, in the form stored on the account. */
|
||||
export interface GameSaveData {
|
||||
/** Files a streamed game keeps in its saves directory */
|
||||
stream?: SaveFiles;
|
||||
/** Ruffle SharedObjects and HTML5 localStorage entries */
|
||||
localStorage?: Record<string, string>;
|
||||
/** Databases HTML5 games save in, such as Unity WebGL's /idbfs */
|
||||
indexedDB?: SerializedDatabase[];
|
||||
}
|
||||
|
||||
/** What this browser knows about a game's save. */
|
||||
export interface SaveSyncState {
|
||||
gameId: string;
|
||||
/** localStorage keys and IndexedDB databases seen changing while the game ran */
|
||||
keys: string[];
|
||||
databases: string[];
|
||||
/** Hash of the save last uploaded or downloaded */
|
||||
syncedHash: string | null;
|
||||
/** When the game last changed its save here */
|
||||
savedAt: string | null;
|
||||
}
|
||||
|
||||
const STATE_DB = 'flashpoint-save-sync';
|
||||
const STATE_STORE = 'games';
|
||||
|
||||
/** flashpoint-web's own storage, never part of a game's save */
|
||||
const APP_DATABASES = new Set(['flashpoint-stream-saves', STATE_DB]);
|
||||
// Unity WebGL's asset cache: large and re-downloadable, not a save
|
||||
const IGNORED_DATABASES = new Set(['UnityCache']);
|
||||
|
||||
/**
|
||||
* Selkies, the client in a streamed game's iframe, shares this origin and keys
|
||||
* its settings by its page address (/stream/<session>/), sanitized like this.
|
||||
*/
|
||||
function streamClientPrefix(): string {
|
||||
return `${window.location.origin}/stream/`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function isAppKey(key: string): boolean {
|
||||
return (
|
||||
key.startsWith('flashpoint-') ||
|
||||
key === 'activityDashboardCollapsed' ||
|
||||
key.startsWith(streamClientPrefix())
|
||||
);
|
||||
}
|
||||
|
||||
function isGameDatabase(name: string): boolean {
|
||||
return !APP_DATABASES.has(name) && !IGNORED_DATABASES.has(name);
|
||||
}
|
||||
|
||||
function openStateDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(STATE_DB, 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore(STATE_STORE, { keyPath: 'gameId' });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function withStateStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
const db = await openStateDb();
|
||||
try {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const request = fn(db.transaction(STATE_STORE, mode).objectStore(STATE_STORE));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSyncState(gameId: string): Promise<SaveSyncState> {
|
||||
const stored = await withStateStore<SaveSyncState | undefined>('readonly', (s) => s.get(gameId));
|
||||
return stored ?? { gameId, keys: [], databases: [], syncedHash: null, savedAt: null };
|
||||
}
|
||||
|
||||
export async function storeSyncState(state: SaveSyncState): Promise<void> {
|
||||
await withStateStore('readwrite', (s) => s.put(state));
|
||||
}
|
||||
|
||||
export function isEmptySave(data: GameSaveData): boolean {
|
||||
return !data.stream && !data.localStorage && !data.indexedDB;
|
||||
}
|
||||
|
||||
/** Hash of a save, or null when there is nothing to save. */
|
||||
export function saveHash(data: GameSaveData): string | null {
|
||||
return isEmptySave(data) ? null : hashSave(data);
|
||||
}
|
||||
|
||||
/** Gathers a game's save from this browser. */
|
||||
export async function collectSave(state: SaveSyncState): Promise<GameSaveData> {
|
||||
const data: GameSaveData = {};
|
||||
|
||||
const stream = await loadSaves(state.gameId);
|
||||
if (Object.keys(stream).length > 0) data.stream = stream;
|
||||
|
||||
const entries = state.keys
|
||||
.filter((key) => !isAppKey(key))
|
||||
.map((key) => [key, localStorage.getItem(key)] as const)
|
||||
.filter((entry): entry is readonly [string, string] => entry[1] !== null);
|
||||
if (entries.length > 0) data.localStorage = Object.fromEntries(entries);
|
||||
|
||||
const databases = (await Promise.all(state.databases.map(exportDatabase))).filter(
|
||||
(db): db is SerializedDatabase => db !== null
|
||||
);
|
||||
if (databases.length > 0) data.indexedDB = databases;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Makes this browser hold exactly the given save, and returns the updated state. */
|
||||
export async function applySave(state: SaveSyncState, data: GameSaveData): Promise<SaveSyncState> {
|
||||
if (data.stream || Object.keys(await loadSaves(state.gameId)).length > 0) {
|
||||
await storeSaves(state.gameId, data.stream ?? {});
|
||||
}
|
||||
|
||||
const keys = Object.keys(data.localStorage ?? {});
|
||||
for (const key of state.keys.filter((k) => !keys.includes(k))) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
for (const [key, value] of Object.entries(data.localStorage ?? {})) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
const databases = (data.indexedDB ?? []).map((db) => db.name);
|
||||
for (const name of state.databases.filter((n) => !databases.includes(n))) {
|
||||
await deleteDatabase(name);
|
||||
}
|
||||
for (const db of data.indexedDB ?? []) {
|
||||
await importDatabase(db);
|
||||
}
|
||||
|
||||
return { ...state, keys, databases };
|
||||
}
|
||||
|
||||
function gameStorage(): Map<string, string> {
|
||||
const values = new Map<string, string>();
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null && !isAppKey(key)) {
|
||||
values.set(key, localStorage.getItem(key) ?? '');
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function gameDatabaseHashes(): Promise<Map<string, string>> {
|
||||
const hashes = new Map<string, string>();
|
||||
const names = (await listDatabases().catch(() => [] as string[])).filter(isGameDatabase);
|
||||
for (const name of names) {
|
||||
const db = await exportDatabase(name).catch(() => null);
|
||||
if (db) hashes.set(name, hashSave(db));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notices what a running game changes. Games share flashpoint-web's origin and
|
||||
* Ruffle saves may use a site-wide path, so storage that changes while a game
|
||||
* runs is attributed to it rather than guessed from names.
|
||||
*/
|
||||
export class SaveChangeTracker {
|
||||
private storage: Map<string, string>;
|
||||
private databases: Map<string, string>;
|
||||
private streamHash: string | null;
|
||||
|
||||
private constructor(
|
||||
storage: Map<string, string>,
|
||||
databases: Map<string, string>,
|
||||
streamHash: string | null
|
||||
) {
|
||||
this.storage = storage;
|
||||
this.databases = databases;
|
||||
this.streamHash = streamHash;
|
||||
}
|
||||
|
||||
static async start(gameId: string): Promise<SaveChangeTracker> {
|
||||
return new SaveChangeTracker(
|
||||
gameStorage(),
|
||||
await gameDatabaseHashes(),
|
||||
hashSave(await loadSaves(gameId))
|
||||
);
|
||||
}
|
||||
|
||||
/** Attributes storage changed since the last check to the game; returns whether anything changed. */
|
||||
async check(state: SaveSyncState): Promise<{ state: SaveSyncState; changed: boolean }> {
|
||||
let changed = false;
|
||||
const keys = new Set(state.keys);
|
||||
const databases = new Set(state.databases);
|
||||
|
||||
const storage = gameStorage();
|
||||
for (const [key, value] of storage) {
|
||||
if (this.storage.get(key) !== value) {
|
||||
keys.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const key of this.storage.keys()) {
|
||||
if (!storage.has(key) && keys.has(key)) changed = true;
|
||||
}
|
||||
this.storage = storage;
|
||||
|
||||
const hashes = await gameDatabaseHashes();
|
||||
for (const [name, hash] of hashes) {
|
||||
if (this.databases.get(name) !== hash) {
|
||||
databases.add(name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
this.databases = hashes;
|
||||
|
||||
const streamHash = hashSave(await loadSaves(state.gameId));
|
||||
if (streamHash !== this.streamHash) {
|
||||
this.streamHash = streamHash;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { state: { ...state, keys: [...keys], databases: [...databases] }, changed };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { canonicalJson, hashSave } from './hash';
|
||||
|
||||
describe('save hashing', () => {
|
||||
it('ignores key order and detects changes', () => {
|
||||
expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}');
|
||||
expect(hashSave({ b: 1, a: 2 })).toBe(hashSave({ a: 2, b: 1 }));
|
||||
expect(hashSave({ a: 1 })).not.toBe(hashSave({ a: 2 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** JSON with object keys sorted, so equal data always serializes the same way. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v: unknown) =>
|
||||
v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
||||
: v
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 53-bit cyrb53 hash, used to tell whether a save changed. Not cryptographic:
|
||||
* crypto.subtle is unavailable on plain-HTTP LAN deployments, and nothing here
|
||||
* needs collision resistance against an attacker.
|
||||
*/
|
||||
export function hashSave(value: unknown): string {
|
||||
const text = canonicalJson(value);
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import { exportDatabase, importDatabase, listDatabases } from './indexedDb';
|
||||
|
||||
function request<T>(req: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
const open = indexedDB.open('/idbfs', 21);
|
||||
open.onupgradeneeded = () => {
|
||||
const db = open.result;
|
||||
const files = db.createObjectStore('FILE_DATA');
|
||||
files.createIndex('timestamp', 'timestamp');
|
||||
db.createObjectStore('players', { keyPath: 'id', autoIncrement: true });
|
||||
};
|
||||
const db = await request(open);
|
||||
const tx = db.transaction(['FILE_DATA', 'players'], 'readwrite');
|
||||
tx.objectStore('FILE_DATA').put(
|
||||
{
|
||||
timestamp: new Date(1700000000000),
|
||||
mode: 33206,
|
||||
contents: new Uint8Array([0, 1, 254, 255]),
|
||||
floats: new Float32Array([1.5, -2]),
|
||||
buffer: new Uint8Array([9, 8]).buffer,
|
||||
tags: new Set(['a', 'b']),
|
||||
meta: new Map<string, unknown>([['level', 3]]),
|
||||
tricky: { $t: 'not a tag', v: 1 },
|
||||
missing: undefined,
|
||||
notANumber: NaN,
|
||||
big: 12345678901234567890n,
|
||||
},
|
||||
'/idbfs/save.dat'
|
||||
);
|
||||
tx.objectStore('players').put({ name: 'eliza', points: 10 });
|
||||
await new Promise((resolve) => (tx.oncomplete = resolve));
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe('IndexedDB export and import', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
it('round-trips a database, including non-JSON values, indexes and generated keys', async () => {
|
||||
await seed();
|
||||
const exported = await exportDatabase('/idbfs');
|
||||
expect(exported).not.toBeNull();
|
||||
// Survives being sent as JSON
|
||||
const copy = JSON.parse(JSON.stringify(exported));
|
||||
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
await importDatabase(copy);
|
||||
expect(await listDatabases()).toEqual(['/idbfs']);
|
||||
|
||||
const db = await request(indexedDB.open('/idbfs'));
|
||||
expect(db.version).toBe(21);
|
||||
const tx = db.transaction(['FILE_DATA', 'players']);
|
||||
const file = await request(tx.objectStore('FILE_DATA').get('/idbfs/save.dat'));
|
||||
expect(file.timestamp).toEqual(new Date(1700000000000));
|
||||
// Compared by content: fake-indexeddb clones into Node's realm, not jsdom's
|
||||
expect(Object.prototype.toString.call(file.contents)).toBe('[object Uint8Array]');
|
||||
expect(Array.from(file.contents)).toEqual([0, 1, 254, 255]);
|
||||
expect(Array.from(file.floats)).toEqual([1.5, -2]);
|
||||
expect(Array.from(new Uint8Array(file.buffer))).toEqual([9, 8]);
|
||||
expect(file.tags).toEqual(new Set(['a', 'b']));
|
||||
expect(file.meta).toEqual(new Map([['level', 3]]));
|
||||
expect(file.tricky).toEqual({ $t: 'not a tag', v: 1 });
|
||||
expect('missing' in file && file.missing === undefined).toBe(true);
|
||||
expect(file.notANumber).toBeNaN();
|
||||
expect(file.big).toBe(12345678901234567890n);
|
||||
expect([...tx.objectStore('FILE_DATA').indexNames]).toEqual(['timestamp']);
|
||||
expect(await request(tx.objectStore('players').get(1))).toEqual({
|
||||
id: 1,
|
||||
name: 'eliza',
|
||||
points: 10,
|
||||
});
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('exports nothing for a database that does not exist', async () => {
|
||||
expect(await exportDatabase('nope')).toBeNull();
|
||||
expect(await listDatabases()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Exports and imports whole IndexedDB databases as JSON, so games that save in
|
||||
* IndexedDB (Unity WebGL's /idbfs, many HTML5 games) can have those saves copied.
|
||||
* Values are structured-clone data, so non-JSON types are tagged.
|
||||
*/
|
||||
|
||||
type Encoded =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| Encoded[]
|
||||
| { [key: string]: Encoded }
|
||||
| { $t: string; v: Encoded };
|
||||
|
||||
export interface SerializedIndex {
|
||||
name: string;
|
||||
keyPath: string | string[];
|
||||
unique: boolean;
|
||||
multiEntry: boolean;
|
||||
}
|
||||
|
||||
export interface SerializedStore {
|
||||
name: string;
|
||||
keyPath: string | string[] | null;
|
||||
autoIncrement: boolean;
|
||||
indexes: SerializedIndex[];
|
||||
records: { key: Encoded; value: Encoded }[];
|
||||
}
|
||||
|
||||
export interface SerializedDatabase {
|
||||
name: string;
|
||||
version: number;
|
||||
stores: SerializedStore[];
|
||||
}
|
||||
|
||||
const TYPED_ARRAYS = {
|
||||
Int8Array,
|
||||
Uint8Array,
|
||||
Uint8ClampedArray,
|
||||
Int16Array,
|
||||
Uint16Array,
|
||||
Int32Array,
|
||||
Uint32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
} as const;
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(text: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(text);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type tag that also works for values from another realm, such as a game's
|
||||
* iframe, where instanceof against this window's constructors fails.
|
||||
*/
|
||||
function typeOf(value: object): string {
|
||||
return Object.prototype.toString.call(value).slice(8, -1);
|
||||
}
|
||||
|
||||
async function encode(value: unknown): Promise<Encoded> {
|
||||
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (value === undefined) return { $t: 'undefined', v: null };
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : { $t: 'number', v: String(value) };
|
||||
}
|
||||
if (typeof value === 'bigint') return { $t: 'bigint', v: value.toString() };
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error(`Cannot save a value of type ${typeof value}`);
|
||||
}
|
||||
const type = typeOf(value);
|
||||
if (type === 'Date') return { $t: 'Date', v: (value as Date).getTime() };
|
||||
if (type === 'ArrayBuffer') {
|
||||
return { $t: 'ArrayBuffer', v: toBase64(new Uint8Array(value as ArrayBuffer)) };
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
return { $t: type, v: toBase64(bytes) };
|
||||
}
|
||||
if (type === 'Blob' || type === 'File') {
|
||||
const blob = value as Blob;
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
return { $t: 'Blob', v: { type: blob.type, data: toBase64(bytes) } };
|
||||
}
|
||||
if (type === 'Map') {
|
||||
const entries = [...(value as Map<unknown, unknown>)];
|
||||
return {
|
||||
$t: 'Map',
|
||||
v: await Promise.all(entries.map(async ([k, v]) => [await encode(k), await encode(v)])),
|
||||
};
|
||||
}
|
||||
if (type === 'Set') {
|
||||
return { $t: 'Set', v: await Promise.all([...(value as Set<unknown>)].map(encode)) };
|
||||
}
|
||||
if (Array.isArray(value)) return Promise.all(value.map(encode));
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value as Record<string, unknown>).map(
|
||||
async ([k, v]) => [k, await encode(v)] as const
|
||||
)
|
||||
);
|
||||
const object = Object.fromEntries(entries);
|
||||
// An object that happens to have a $t key must not be read back as a tagged value
|
||||
return '$t' in object ? { $t: 'Object', v: object } : object;
|
||||
}
|
||||
|
||||
function decode(value: Encoded): unknown {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(decode);
|
||||
if (!('$t' in value)) {
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, decode(v)]));
|
||||
}
|
||||
const { $t, v } = value as { $t: string; v: Encoded };
|
||||
switch ($t) {
|
||||
case 'undefined':
|
||||
return undefined;
|
||||
case 'number':
|
||||
return Number(v);
|
||||
case 'bigint':
|
||||
return BigInt(v as string);
|
||||
case 'Date':
|
||||
return new Date(v as number);
|
||||
case 'ArrayBuffer':
|
||||
return fromBase64(v as string).buffer;
|
||||
case 'Blob': {
|
||||
const { type, data } = v as { type: string; data: string };
|
||||
return new Blob([fromBase64(data)], { type });
|
||||
}
|
||||
case 'Map':
|
||||
return new Map((v as Encoded[][]).map(([k, val]) => [decode(k), decode(val)]));
|
||||
case 'Set':
|
||||
return new Set((v as Encoded[]).map(decode));
|
||||
case 'Object':
|
||||
return Object.fromEntries(
|
||||
Object.entries(v as Record<string, Encoded>).map(([k, x]) => [k, decode(x)])
|
||||
);
|
||||
case 'DataView': {
|
||||
const bytes = fromBase64(v as string);
|
||||
return new DataView(bytes.buffer);
|
||||
}
|
||||
default: {
|
||||
const TypedArray = TYPED_ARRAYS[$t as keyof typeof TYPED_ARRAYS];
|
||||
if (!TypedArray) throw new Error(`Unknown saved value type: ${$t}`);
|
||||
return new TypedArray(fromBase64(v as string).buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function promised<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Names of the databases on this origin. */
|
||||
export async function listDatabases(): Promise<string[]> {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases.map((d) => d.name).filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
export async function exportDatabase(name: string): Promise<SerializedDatabase | null> {
|
||||
const known = await listDatabases();
|
||||
if (!known.includes(name)) return null;
|
||||
const db = await promised(indexedDB.open(name));
|
||||
try {
|
||||
const storeNames = [...db.objectStoreNames];
|
||||
if (storeNames.length === 0) {
|
||||
return { name, version: db.version, stores: [] };
|
||||
}
|
||||
// Read everything inside one transaction, then encode outside it: encoding
|
||||
// Blobs is asynchronous and would let the transaction close
|
||||
const tx = db.transaction(storeNames, 'readonly');
|
||||
const raw = await Promise.all(
|
||||
storeNames.map(async (storeName) => {
|
||||
const store = tx.objectStore(storeName);
|
||||
const [keys, values] = await Promise.all([
|
||||
promised(store.getAllKeys()),
|
||||
promised(store.getAll()),
|
||||
]);
|
||||
return { store, keys, values };
|
||||
})
|
||||
);
|
||||
const stores = await Promise.all(
|
||||
raw.map(async ({ store, keys, values }) => ({
|
||||
name: store.name,
|
||||
keyPath: store.keyPath,
|
||||
autoIncrement: store.autoIncrement,
|
||||
indexes: [...store.indexNames].map((indexName) => {
|
||||
const index = store.index(indexName);
|
||||
return {
|
||||
name: index.name,
|
||||
keyPath: index.keyPath,
|
||||
unique: index.unique,
|
||||
multiEntry: index.multiEntry,
|
||||
};
|
||||
}),
|
||||
records: await Promise.all(
|
||||
keys.map(async (key, i) => ({ key: await encode(key), value: await encode(values[i]) }))
|
||||
),
|
||||
}))
|
||||
);
|
||||
return { name, version: db.version, stores };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces a database with a serialized copy. */
|
||||
export async function importDatabase(serialized: SerializedDatabase): Promise<void> {
|
||||
await promised(indexedDB.deleteDatabase(serialized.name));
|
||||
const request = indexedDB.open(serialized.name, serialized.version);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
for (const store of serialized.stores) {
|
||||
const objectStore = db.createObjectStore(store.name, {
|
||||
keyPath: store.keyPath ?? undefined,
|
||||
autoIncrement: store.autoIncrement,
|
||||
});
|
||||
for (const index of store.indexes) {
|
||||
objectStore.createIndex(index.name, index.keyPath, {
|
||||
unique: index.unique,
|
||||
multiEntry: index.multiEntry,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const db = await promised(request);
|
||||
try {
|
||||
if (serialized.stores.length === 0) return;
|
||||
const tx = db.transaction(
|
||||
serialized.stores.map((s) => s.name),
|
||||
'readwrite'
|
||||
);
|
||||
for (const store of serialized.stores) {
|
||||
const objectStore = tx.objectStore(store.name);
|
||||
for (const record of store.records) {
|
||||
if (store.keyPath === null) {
|
||||
objectStore.put(decode(record.value), decode(record.key) as IDBValidKey);
|
||||
} else {
|
||||
objectStore.put(decode(record.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDatabase(name: string): Promise<void> {
|
||||
await promised(indexedDB.deleteDatabase(name));
|
||||
}
|
||||
Generated
+37
@@ -2355,6 +2355,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"msw": "^2.12.7",
|
||||
"postcss": "^8.4.38",
|
||||
@@ -4817,6 +4818,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4833,6 +4835,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4849,6 +4852,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4865,6 +4869,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4881,6 +4886,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4897,6 +4903,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4913,6 +4920,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4929,6 +4937,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4945,6 +4954,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4961,6 +4971,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4977,6 +4988,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4993,6 +5005,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5009,6 +5022,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5025,6 +5039,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5041,6 +5056,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5057,6 +5073,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5073,6 +5090,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5089,6 +5107,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5105,6 +5124,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5121,6 +5141,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5137,6 +5158,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5153,6 +5175,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5169,6 +5192,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5185,6 +5209,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5201,6 +5226,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5217,6 +5243,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11947,6 +11974,16 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
|
||||
Reference in New Issue
Block a user