Compare commits
3
Commits
main
..
25dc5fc856
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25dc5fc856 | ||
|
|
4a58e7c44b | ||
|
|
2f6d4eca5e |
@@ -1,9 +1,11 @@
|
|||||||
# This workflow builds DOStalgia whenever code is pushed to main.
|
# This workflow builds and deploys DOStalgia whenever code is pushed to main.
|
||||||
# The runner has Docker socket access so it can manage containers on the host.
|
# The runner has Docker socket access so it can manage containers on the host.
|
||||||
|
# Parameters match the docker-compose at:
|
||||||
|
# /appdata/dockhand/stacks/unraid/dostalgia/compose.yaml
|
||||||
# See: https://docs.gitea.com/usage/actions
|
# See: https://docs.gitea.com/usage/actions
|
||||||
|
|
||||||
name: Build & Deploy
|
name: Build & Deploy
|
||||||
run-name: Build ${{ gitea.sha }}
|
run-name: Build ${{ gitea.sha.substring(0, 7) }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -11,6 +13,8 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: dostalgia
|
IMAGE_NAME: dostalgia
|
||||||
|
CONTAINER_NAME: dostalgia
|
||||||
|
DATA_VOLUME: /mnt/cache/appdata/dostalgia
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -25,5 +29,34 @@ jobs:
|
|||||||
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
|
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
|
||||||
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
|
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
- name: Deploy container
|
||||||
|
run: |
|
||||||
|
docker stop ${{ env.CONTAINER_NAME }} 2>/dev/null || true
|
||||||
|
docker rm ${{ env.CONTAINER_NAME }} 2>/dev/null || true
|
||||||
|
docker run -d \
|
||||||
|
--name ${{ env.CONTAINER_NAME }} \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network dockernet \
|
||||||
|
-p 8765:8765/tcp \
|
||||||
|
-v ${{ env.DATA_VOLUME }}:/data \
|
||||||
|
-e TWITCH_CLIENT_ID='${{ secrets.TWITCH_CLIENT_ID }}' \
|
||||||
|
-e TWITCH_CLIENT_SECRET='${{ secrets.TWITCH_CLIENT_SECRET }}' \
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
- name: Verify container is running
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(docker inspect -f '{{.State.Status}}' ${{ env.CONTAINER_NAME }})
|
||||||
|
if [ "$STATUS" = "running" ]; then
|
||||||
|
echo "✅ DOStalgia container is running"
|
||||||
|
echo "--- last logs ---"
|
||||||
|
docker logs ${{ env.CONTAINER_NAME }} --tail 5
|
||||||
|
echo "✅ DOStalgia deployed!"
|
||||||
|
else
|
||||||
|
echo "❌ Container status: $STATUS"
|
||||||
|
docker logs ${{ env.CONTAINER_NAME }} --tail 30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Clean up old images
|
- name: Clean up old images
|
||||||
run: docker image prune -f --filter "until=24h"
|
run: docker image prune -f --filter "until=24h"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and met
|
|||||||
### 🎮 Play in the browser
|
### 🎮 Play in the browser
|
||||||
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
|
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
|
||||||
|
|
||||||
**Saves are automatic** — in-game saves (js-dos FS changes) are persisted to the server (`data/saves/`). Come back from any browser or device and pick up where you left off.
|
**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off.
|
||||||
|
|
||||||
### 📦 Handles any file structure
|
### 📦 Handles any file structure
|
||||||
DOS games come in all shapes. DOStalgia handles them transparently:
|
DOS games come in all shapes. DOStalgia handles them transparently:
|
||||||
@@ -131,4 +131,4 @@ java -jar target/quarkus-app/quarkus-run.jar
|
|||||||
- **Frontend**: Svelte 5 SPA with hash-based routing
|
- **Frontend**: Svelte 5 SPA with hash-based routing
|
||||||
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
|
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
|
||||||
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
|
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
|
||||||
- **Saves**: `data/saves/` on the server (js-dos FS-changes bundles, synced via `/api/games/{id}/saves`)
|
- **Saves**: Browser localStorage / indexedDB (managed by js-dos)
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dostalgia-frontend",
|
"name": "dostalgia-frontend",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dostalgia-frontend",
|
"name": "dostalgia-frontend",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dostalgia-frontend",
|
"name": "dostalgia-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
|
||||||
artworkUrl,
|
|
||||||
bundleUrl,
|
|
||||||
setupBundleUrl,
|
|
||||||
pullSaves,
|
|
||||||
pushSaves,
|
|
||||||
deleteSaves,
|
|
||||||
} from "../api.js";
|
|
||||||
|
|
||||||
describe("artworkUrl", () => {
|
describe("artworkUrl", () => {
|
||||||
it("returns null for null input", () => {
|
it("returns null for null input", () => {
|
||||||
@@ -73,113 +66,3 @@ describe("setupBundleUrl", () => {
|
|||||||
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pullSaves", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when there is no save (404)", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 404, ok: false }));
|
|
||||||
await expect(pullSaves("game-1")).resolves.toBeNull();
|
|
||||||
expect(fetch).toHaveBeenCalledWith("/api/games/game-1/saves");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the raw bytes on success", async () => {
|
|
||||||
const bytes = new Uint8Array([1, 2, 3]);
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
status: 200,
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: () => Promise.resolve(bytes.buffer),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await expect(pullSaves("game-1")).resolves.toEqual(bytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on server error", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
status: 500,
|
|
||||||
ok: false,
|
|
||||||
json: () => Promise.resolve({ error: "boom" }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await expect(pullSaves("game-1")).rejects.toThrow("boom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pushSaves", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PUTs the raw bundle to the saves endpoint", async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
|
||||||
status: 200,
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ status: "saved" }),
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
const data = new Uint8Array([9, 8, 7]);
|
|
||||||
|
|
||||||
await expect(pushSaves("game-1", data)).resolves.toEqual({
|
|
||||||
status: "saved",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [url, opts] = fetchMock.mock.calls[0];
|
|
||||||
expect(url).toBe("/api/games/game-1/saves");
|
|
||||||
expect(opts.method).toBe("PUT");
|
|
||||||
expect(opts.headers["Content-Type"]).toBe("application/octet-stream");
|
|
||||||
expect(opts.body).toBe(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on server error", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
status: 400,
|
|
||||||
ok: false,
|
|
||||||
json: () => Promise.resolve({ error: "Empty save data" }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await expect(pushSaves("game-1", new Uint8Array())).rejects.toThrow(
|
|
||||||
"Empty save data"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteSaves", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DELETEs the saves endpoint", async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
|
||||||
status: 200,
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ status: "deleted" }),
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
await expect(deleteSaves("game-1")).resolves.toEqual({ status: "deleted" });
|
|
||||||
expect(fetchMock.mock.calls[0]).toEqual([
|
|
||||||
"/api/games/game-1/saves",
|
|
||||||
{ method: "DELETE" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on server error", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
status: 500,
|
|
||||||
ok: false,
|
|
||||||
json: () => Promise.resolve({ error: "boom" }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await expect(deleteSaves("game-1")).rejects.toThrow("boom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -90,41 +90,6 @@ export function downloadGame(id) {
|
|||||||
window.open(`${BASE}/api/games/${id}/download`, '_blank');
|
window.open(`${BASE}/api/games/${id}/download`, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pull saved js-dos FS changes (in-game saves) — Uint8Array or null if none */
|
|
||||||
export async function pullSaves(gameId) {
|
|
||||||
const res = await fetch(`${BASE}/api/games/${gameId}/saves`);
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
||||||
throw new Error(err.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return new Uint8Array(await res.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Push js-dos FS changes (in-game saves) to the server */
|
|
||||||
export async function pushSaves(gameId, data) {
|
|
||||||
const res = await fetch(`${BASE}/api/games/${gameId}/saves`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/octet-stream" },
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
||||||
throw new Error(err.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete server-side saves for a game */
|
|
||||||
export async function deleteSaves(gameId) {
|
|
||||||
const res = await fetch(`${BASE}/api/games/${gameId}/saves`, { method: "DELETE" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
||||||
throw new Error(err.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check IGDB status */
|
/** Check IGDB status */
|
||||||
export async function igdbStatus() {
|
export async function igdbStatus() {
|
||||||
const res = await fetch(`${BASE}/api/igdb/status`);
|
const res = await fetch(`${BASE}/api/igdb/status`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getGame, bundleUrl, setupBundleUrl, pullSaves, pushSaves, deleteSaves } from "../lib/api.js";
|
import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js";
|
||||||
import { push } from "../lib/router.js";
|
import { push } from "../lib/router.js";
|
||||||
|
|
||||||
let { id } = $props();
|
let { id } = $props();
|
||||||
@@ -70,26 +70,7 @@
|
|||||||
try {
|
try {
|
||||||
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
|
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
|
||||||
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
|
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
|
||||||
dosCI = window.Dos(dosContainer, {
|
dosCI = await window.Dos(dosContainer, { url });
|
||||||
url,
|
|
||||||
autoSave: true,
|
|
||||||
// Server-backed FS persistence: in-game saves survive sessions and
|
|
||||||
// follow the player across browsers/devices. Both normal and setup
|
|
||||||
// mode share one save set so SETUP.EXE changes carry over.
|
|
||||||
fsChanges: {
|
|
||||||
urlToKey: async () => String(game.id),
|
|
||||||
pull: async (key) => {
|
|
||||||
try {
|
|
||||||
return await pullSaves(key);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to load saves:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
push: (key, data) => pushSaves(key, data),
|
|
||||||
delete: (key) => deleteSaves(key),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
running = true;
|
running = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
@@ -97,12 +78,7 @@
|
|||||||
booting = false;
|
booting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopEmulator() {
|
function stopEmulator() {
|
||||||
// Persist FS changes before teardown — the reload below kills the
|
|
||||||
// emulator worker, so the save request must fire first (best effort).
|
|
||||||
if (dosCI?.save) {
|
|
||||||
try { await dosCI.save(); } catch (_) {}
|
|
||||||
}
|
|
||||||
// Force full page reload which terminates all workers, AudioContexts
|
// Force full page reload which terminates all workers, AudioContexts
|
||||||
// and WASM threads. Hash is preserved so the SPA routes correctly.
|
// and WASM threads. Hash is preserved so the SPA routes correctly.
|
||||||
window.location.hash = `#/game/${game.id}`;
|
window.location.hash = `#/game/${game.id}`;
|
||||||
@@ -131,12 +107,8 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (dosCI) {
|
if (dosCI) {
|
||||||
// Persist FS changes, then kill the emulation (DosProps.stop()).
|
try { dosCI.exit(); } catch (_) {}
|
||||||
const props = dosCI;
|
|
||||||
dosCI = null;
|
dosCI = null;
|
||||||
Promise.resolve(props.save?.()).catch(() => {}).finally(() => {
|
|
||||||
try { props.stop?.(); } catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (dosContainer) {
|
if (dosContainer) {
|
||||||
dosContainer.innerHTML = "";
|
dosContainer.innerHTML = "";
|
||||||
@@ -199,7 +171,7 @@
|
|||||||
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
|
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Starting emulator...</p>
|
<p>Starting emulator...</p>
|
||||||
<p class="save-hint">💾 Game saves are stored on the server</p>
|
<p class="save-hint">💾 Game saves are stored automatically</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="overlay-icon">⚠️</div>
|
<div class="overlay-icon">⚠️</div>
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ vi.mock("../../lib/api.js", () => ({
|
|||||||
getGame: vi.fn(),
|
getGame: vi.fn(),
|
||||||
bundleUrl: vi.fn(),
|
bundleUrl: vi.fn(),
|
||||||
setupBundleUrl: vi.fn(),
|
setupBundleUrl: vi.fn(),
|
||||||
pullSaves: vi.fn(),
|
|
||||||
pushSaves: vi.fn(),
|
|
||||||
deleteSaves: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock router
|
// Mock router
|
||||||
@@ -77,49 +74,4 @@ describe("Play", () => {
|
|||||||
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts js-dos with server-backed save hooks", async () => {
|
|
||||||
api.getGame.mockResolvedValue(makeGame());
|
|
||||||
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
|
|
||||||
|
|
||||||
const dosMock = vi.fn().mockReturnValue({
|
|
||||||
save: vi.fn().mockResolvedValue(true),
|
|
||||||
stop: vi.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
window.Dos = dosMock;
|
|
||||||
|
|
||||||
try {
|
|
||||||
render(Play, { props: { id: "game-1" } });
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(dosMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
const opts = dosMock.mock.calls[0][1];
|
|
||||||
expect(opts.url).toBe("/games/game-1.jsdos");
|
|
||||||
expect(opts.autoSave).toBe(true);
|
|
||||||
|
|
||||||
// Both normal and setup bundles map to the same per-game save key
|
|
||||||
await expect(opts.fsChanges.urlToKey("/games/game-1.jsdos")).resolves.toBe("game-1");
|
|
||||||
|
|
||||||
// pull() forwards to the API and returns null when there is no save
|
|
||||||
api.pullSaves.mockResolvedValue(null);
|
|
||||||
await expect(opts.fsChanges.pull("game-1")).resolves.toBeNull();
|
|
||||||
expect(api.pullSaves).toHaveBeenCalledWith("game-1");
|
|
||||||
|
|
||||||
// push()/delete() forward to the API
|
|
||||||
api.pushSaves.mockResolvedValue({ status: "saved" });
|
|
||||||
await opts.fsChanges.push("game-1", new Uint8Array([1]));
|
|
||||||
expect(api.pushSaves).toHaveBeenCalledWith("game-1", expect.any(Uint8Array));
|
|
||||||
|
|
||||||
api.deleteSaves.mockResolvedValue({ status: "deleted" });
|
|
||||||
await opts.fsChanges.delete("game-1");
|
|
||||||
expect(api.deleteSaves).toHaveBeenCalledWith("game-1");
|
|
||||||
|
|
||||||
// pull() survives API errors so the game still boots
|
|
||||||
api.pullSaves.mockRejectedValue(new Error("offline"));
|
|
||||||
await expect(opts.fsChanges.pull("game-1")).resolves.toBeNull();
|
|
||||||
} finally {
|
|
||||||
delete window.Dos;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 262 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 488 B |
Binary file not shown.
|
Before Width: | Height: | Size: 869 B |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>org.dostalgia</groupId>
|
<groupId>org.dostalgia</groupId>
|
||||||
<artifactId>dostalgia</artifactId>
|
<artifactId>dostalgia</artifactId>
|
||||||
<version>0.1.1</version>
|
<version>0.1.0</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.release>21</maven.compiler.release>
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import jakarta.ws.rs.Path;
|
|||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.PUT;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.core.StreamingOutput;
|
import jakarta.ws.rs.core.StreamingOutput;
|
||||||
@@ -161,68 +160,6 @@ public class GameController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream the saved js-dos FS-changes bundle (in-game saves) for a game.
|
|
||||||
* 404 when the game or the save doesn't exist.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("/{id}/saves")
|
|
||||||
@Produces(MediaType.APPLICATION_OCTET_STREAM)
|
|
||||||
public Response getSave(@PathParam("id") String id) {
|
|
||||||
try {
|
|
||||||
svc.load(id);
|
|
||||||
byte[] data = svc.loadSave(id);
|
|
||||||
if (data == null) {
|
|
||||||
return Response.status(404).entity(Map.of("error", "No save found")).build();
|
|
||||||
}
|
|
||||||
return Response.ok(data).build();
|
|
||||||
} catch (NoSuchFileException e) {
|
|
||||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist the js-dos FS-changes bundle (in-game saves) for a game. Body = raw bundle bytes. */
|
|
||||||
@PUT
|
|
||||||
@Path("/{id}/saves")
|
|
||||||
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
|
|
||||||
public Response putSave(@PathParam("id") String id, byte[] data) {
|
|
||||||
try {
|
|
||||||
svc.load(id);
|
|
||||||
if (data == null || data.length == 0) {
|
|
||||||
return Response.status(400).entity(Map.of("error", "Empty save data")).build();
|
|
||||||
}
|
|
||||||
svc.saveChanges(id, data);
|
|
||||||
return Response.ok(Map.of("status", "saved")).build();
|
|
||||||
} catch (NoSuchFileException e) {
|
|
||||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete the saved js-dos FS-changes for a game (fresh start on next launch). */
|
|
||||||
@DELETE
|
|
||||||
@Path("/{id}/saves")
|
|
||||||
public Response deleteSave(@PathParam("id") String id) {
|
|
||||||
try {
|
|
||||||
svc.load(id);
|
|
||||||
svc.deleteSave(id);
|
|
||||||
return Response.ok(Map.of("status", "deleted")).build();
|
|
||||||
} catch (NoSuchFileException e) {
|
|
||||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public Response delete(@PathParam("id") String id) {
|
public Response delete(@PathParam("id") String id) {
|
||||||
|
|||||||
@@ -42,16 +42,13 @@ public class GameService implements GameStore {
|
|||||||
|
|
||||||
Path gamesDir;
|
Path gamesDir;
|
||||||
|
|
||||||
Path savesDir;
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
void init() {
|
void init() {
|
||||||
gamesDir = Path.of(dataDir, "games");
|
gamesDir = Path.of(dataDir, "games");
|
||||||
savesDir = Path.of(dataDir, "saves");
|
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(gamesDir);
|
Files.createDirectories(gamesDir);
|
||||||
Files.createDirectories(Path.of(dataDir, "artwork"));
|
Files.createDirectories(Path.of(dataDir, "artwork"));
|
||||||
Files.createDirectories(savesDir);
|
Files.createDirectories(Path.of(dataDir, "saves"));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Cannot create data directories", e);
|
throw new RuntimeException("Cannot create data directories", e);
|
||||||
}
|
}
|
||||||
@@ -125,47 +122,11 @@ public class GameService implements GameStore {
|
|||||||
/** Delete a game and all its files. */
|
/** Delete a game and all its files. */
|
||||||
public void delete(String id) throws IOException {
|
public void delete(String id) throws IOException {
|
||||||
FileUtils.deleteDirectory(gameDir(id));
|
FileUtils.deleteDirectory(gameDir(id));
|
||||||
deleteSave(id);
|
|
||||||
// Clean up old flat .jsdos locations (pre-game-dir layout — backward compat)
|
// Clean up old flat .jsdos locations (pre-game-dir layout — backward compat)
|
||||||
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
|
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
|
||||||
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
|
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Path of the js-dos FS-changes save file for a game (in-game saves). */
|
|
||||||
public Path savePath(String id) {
|
|
||||||
assertSafeId(id);
|
|
||||||
return savesDir.resolve(id + ".fschanges.jsdos");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load the saved js-dos FS changes for a game, or null if none exist. */
|
|
||||||
public byte[] loadSave(String id) throws IOException {
|
|
||||||
Path path = savePath(id);
|
|
||||||
if (!Files.exists(path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Files.readAllBytes(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist js-dos FS changes for a game (atomic write). */
|
|
||||||
public void saveChanges(String id, byte[] data) throws IOException {
|
|
||||||
Path path = savePath(id);
|
|
||||||
Path tmp = path.resolveSibling(path.getFileName() + ".tmp");
|
|
||||||
Files.write(tmp, data);
|
|
||||||
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete saved js-dos FS changes for a game. Returns true if a save existed. */
|
|
||||||
public boolean deleteSave(String id) throws IOException {
|
|
||||||
return Files.deleteIfExists(savePath(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject ids that could escape the saves directory. */
|
|
||||||
private static void assertSafeId(String id) {
|
|
||||||
if (id == null || id.contains("/") || id.contains("\\") || id.contains("..")) {
|
|
||||||
throw new IllegalArgumentException("Invalid game id: " + id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sanitize a string for use as a game directory ID. */
|
/** Sanitize a string for use as a game directory ID. */
|
||||||
public static String sanitizeId(String s) {
|
public static String sanitizeId(String s) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class StaticResource {
|
|||||||
@GET
|
@GET
|
||||||
@jakarta.ws.rs.Path("/api/health")
|
@jakarta.ws.rs.Path("/api/health")
|
||||||
public Response health() {
|
public Response health() {
|
||||||
return Response.ok(Map.of("status", "ok", "version", "0.1.1")).build();
|
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String guessContentType(String name) {
|
private String guessContentType(String name) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# ─── Server ──────────────────────────────────────
|
# ─── Server ──────────────────────────────────────
|
||||||
quarkus.http.port=8765
|
quarkus.http.port=8765
|
||||||
quarkus.http.host=0.0.0.0
|
quarkus.http.host=0.0.0.0
|
||||||
|
quarkus.http.cors=true
|
||||||
|
|
||||||
# Allow large game uploads (DOS games can be 500MB+)
|
# Allow large game uploads (DOS games can be 500MB+)
|
||||||
quarkus.http.limits.max-body-size=2048M
|
quarkus.http.limits.max-body-size=2048M
|
||||||
|
|||||||
@@ -241,86 +241,6 @@ class GameServiceTest {
|
|||||||
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
|
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- js-dos FS-changes saves tests --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void saveChanges_andLoadSave_roundTrip(@TempDir Path tempDir) throws Exception {
|
|
||||||
GameService svc = new GameService();
|
|
||||||
svc.dataDir = tempDir.toString();
|
|
||||||
svc.init();
|
|
||||||
|
|
||||||
svc.save(new Game("game1", "Game One"));
|
|
||||||
byte[] data = "PK\u0003\u0004fake-bundle".getBytes();
|
|
||||||
svc.saveChanges("game1", data);
|
|
||||||
|
|
||||||
assertArrayEquals(data, svc.loadSave("game1"));
|
|
||||||
assertEquals(
|
|
||||||
tempDir.resolve("saves/game1.fschanges.jsdos"),
|
|
||||||
svc.savePath("game1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void loadSave_noSave_returnsNull(@TempDir Path tempDir) throws Exception {
|
|
||||||
GameService svc = new GameService();
|
|
||||||
svc.dataDir = tempDir.toString();
|
|
||||||
svc.init();
|
|
||||||
|
|
||||||
assertNull(svc.loadSave("game1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void saveChanges_overwritesPreviousWithoutLeavingTmp(@TempDir Path tempDir) throws Exception {
|
|
||||||
GameService svc = new GameService();
|
|
||||||
svc.dataDir = tempDir.toString();
|
|
||||||
svc.init();
|
|
||||||
|
|
||||||
svc.saveChanges("game1", "first".getBytes());
|
|
||||||
svc.saveChanges("game1", "second".getBytes());
|
|
||||||
|
|
||||||
assertArrayEquals("second".getBytes(), svc.loadSave("game1"));
|
|
||||||
try (var stream = Files.list(tempDir.resolve("saves"))) {
|
|
||||||
assertEquals(1, stream.count());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteSave_removesFileAndReportsExistence(@TempDir Path tempDir) throws Exception {
|
|
||||||
GameService svc = new GameService();
|
|
||||||
svc.dataDir = tempDir.toString();
|
|
||||||
svc.init();
|
|
||||||
|
|
||||||
svc.saveChanges("game1", "data".getBytes());
|
|
||||||
assertTrue(svc.deleteSave("game1"));
|
|
||||||
assertNull(svc.loadSave("game1"));
|
|
||||||
assertFalse(svc.deleteSave("game1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void savePath_rejectsPathTraversal(@TempDir Path tempDir) throws Exception {
|
|
||||||
GameService svc = new GameService();
|
|
||||||
svc.dataDir = tempDir.toString();
|
|
||||||
svc.init();
|
|
||||||
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath("../evil"));
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath("a/b"));
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath("a\\b"));
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void delete_removesSaveFile(@TempDir Path tempDir) throws Exception {
|
|
||||||
GameService svc = new GameService();
|
|
||||||
svc.dataDir = tempDir.toString();
|
|
||||||
svc.init();
|
|
||||||
|
|
||||||
svc.save(new Game("game1", "Game One"));
|
|
||||||
svc.saveChanges("game1", "data".getBytes());
|
|
||||||
|
|
||||||
svc.delete("game1");
|
|
||||||
|
|
||||||
assertFalse(Files.exists(tempDir.resolve("saves/game1.fschanges.jsdos")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- JSON serialization tests --
|
// -- JSON serialization tests --
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class StaticResourceTest {
|
|||||||
assertInstanceOf(Map.class, entity);
|
assertInstanceOf(Map.class, entity);
|
||||||
Map<?, ?> map = (Map<?, ?>) entity;
|
Map<?, ?> map = (Map<?, ?>) entity;
|
||||||
assertEquals("ok", map.get("status"));
|
assertEquals("ok", map.get("status"));
|
||||||
assertEquals("0.1.1", map.get("version"));
|
assertEquals("0.1.0", map.get("version"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user