Compare commits
132 Commits
main
..
e452df00e4
| Author | SHA1 | Date | |
|---|---|---|---|
| e452df00e4 | |||
| 7bd25136ab | |||
| 3a4e4f7e95 | |||
| 30927c4159 | |||
| 9afbac32f1 | |||
| f69f3fa30a | |||
| c3a332d600 | |||
| 397c99bd0c | |||
| a109dee5ba | |||
| f8adddec52 | |||
| bb38066069 | |||
| e5d2fc2096 | |||
| c3b6ea8567 | |||
| ab7a5ed8de | |||
| 554347ac0e | |||
| 9a5ed5bc03 | |||
| 149d6d6c1a | |||
| efe0017756 | |||
| 82b379d211 | |||
| 9801e37bec | |||
| f2fb1c035d | |||
| fc6f44b2ce | |||
| ee05a7f031 | |||
| 106cb5bf83 | |||
| 145f11eaa0 | |||
| 22a9a55af8 | |||
| 9fceeb4b78 | |||
| e9c4d963ad | |||
| d119b3ccb9 | |||
| d652f3f94b | |||
| 8108a661fa | |||
| 57842abe0d | |||
| 8a45ceccdc | |||
| 3162b9ff38 | |||
| 6f68e6c062 | |||
| 1fc0a11512 | |||
| 632fd7f554 | |||
| 85b0aa8c0c | |||
| 4cdd72b3e2 | |||
| 6f59028cb0 | |||
| 383c66b166 | |||
| 7c4d090de0 | |||
| 3b7c9b587f | |||
| de9e978e55 | |||
| 4a3ba3b986 | |||
| 7b7cc56bc1 | |||
| 1362e6034a | |||
| 5abc066178 | |||
| f297d30248 | |||
| 4044ef3786 | |||
| 677740aece | |||
| 9bb341eab9 | |||
| e2dca1db39 | |||
| ab1ba503ff | |||
| 4c3ded802b | |||
| 9b95e5b994 | |||
| 5f69ea5ead | |||
| e39ee05a16 | |||
| 2ab6b19598 | |||
| 02d0c4f377 | |||
| e262a46487 | |||
| 815247f760 | |||
| 54542612a5 | |||
| 473fb7a216 | |||
| 45d145a950 | |||
| dee37b3f01 | |||
| fa1804dc65 | |||
| 12db0e9bb9 | |||
| 27a9c180d7 | |||
| 3f69820911 | |||
| 13293b325e | |||
| b8ccba4d20 | |||
| 676e101ffc | |||
| e15ce10f5a | |||
| a9e52ade68 | |||
| ff7b7b446f | |||
| a4b633ae86 | |||
| 41d1e22522 | |||
| 95c7ba7192 | |||
| 5b55a53b73 | |||
| 733bdaa44f | |||
| f9967eacf8 | |||
| eb37cd43bb | |||
| 8f14ecb5a1 | |||
| 5a3500c586 | |||
| 65e098244a | |||
| efa0aa5f81 | |||
| ba34065278 | |||
| e11f19c769 | |||
| 6840b3eee1 | |||
| 5f5cb8411f | |||
| 00267da3dd | |||
| 61a83cff65 | |||
| 72391fb577 | |||
| 3c49d72f56 | |||
| e7b845e8a1 | |||
| 596b897df6 | |||
| 542db6a916 | |||
| 6c56cbc992 | |||
| 4bd771f97f | |||
| 9a4e87d044 | |||
| 655d3e83cd | |||
| feccf4178f | |||
| d751779984 | |||
| 3ae6f43ccb | |||
| 404e8b6719 | |||
| 446a9d61b8 | |||
| 0912e43d99 | |||
| 1f55f63b99 | |||
| b8cc11caf4 | |||
| 8c1be9db03 | |||
| b2eb99ec82 | |||
| 672be01e8c | |||
| 466299b8f0 | |||
| f2bd6ca3fb | |||
| 5579afb5e7 | |||
| 28ea711b24 | |||
| 7f17f4b31e | |||
| f6a1dccdf2 | |||
| a3ba5f8098 | |||
| 40aede0dfc | |||
| 3f41f6f678 | |||
| 5aedf20b7e | |||
| 353f1d8af3 | |||
| 338ec870f5 | |||
| 2b82404cf8 | |||
| 545e2d2bb5 | |||
| df5ce4f127 | |||
| aad4ec21a6 | |||
| 70fba6d14a | |||
| 1efd10f81f | |||
| c3c154ec65 |
@@ -1,10 +0,0 @@
|
||||
# DOStalgia — Environment Configuration
|
||||
# Copy this file to .env and fill in your values:
|
||||
# cp .env.example .env
|
||||
|
||||
# Twitch Client ID for IGDB metadata auto-scrape.
|
||||
# Get yours at https://dev.twitch.tv/console/apps → Create Application
|
||||
# (Set OAuth Redirect URL to http://localhost, Category to "Other")
|
||||
# Optional — IGDB features gracefully skip if not set.
|
||||
# TWITCH_CLIENT_ID=your_client_id_here
|
||||
# TWITCH_CLIENT_SECRET=your_client_secret_here
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
# Parameters match the docker-compose at:
|
||||
# /appdata/dockhand/stacks/unraid/dostalgia/compose.yaml
|
||||
# See: https://docs.gitea.com/usage/actions
|
||||
|
||||
name: Build & Deploy
|
||||
@@ -11,6 +13,8 @@ on:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: dostalgia
|
||||
CONTAINER_NAME: dostalgia
|
||||
DATA_VOLUME: /mnt/cache/appdata/dostalgia
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -25,5 +29,34 @@ jobs:
|
||||
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
|
||||
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
|
||||
run: docker image prune -f --filter "until=24h"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Extract tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: droideparanoico/dostalgia
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -2,10 +2,6 @@
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
# Environment — secrets
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Built output
|
||||
frontend/dist/
|
||||
dist/
|
||||
@@ -40,6 +36,3 @@ data/saves/*
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Gitea
|
||||
.gitea/
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
# DOStalgia — Quarkus multi-stage build
|
||||
# Dostalgia — Quarkus multi-stage build
|
||||
|
||||
# ─── 1. Build frontend ───────────────────────────
|
||||
FROM node:20-alpine AS frontend
|
||||
@@ -7,7 +7,6 @@ COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
RUN npm test
|
||||
|
||||
# ─── 2. Build Quarkus app ────────────────────────
|
||||
FROM maven:3-eclipse-temurin-21-alpine AS build
|
||||
@@ -16,7 +15,7 @@ COPY pom.xml ./
|
||||
COPY src ./src
|
||||
# Copy the built frontend into the source tree so maven-resources-plugin picks it up
|
||||
COPY --from=frontend /build/dist ./frontend/dist/
|
||||
RUN mvn package -q -DskipFrontend=true -DskipFrontendTests=true
|
||||
RUN mvn package -DskipTests -q
|
||||
|
||||
# ─── 3. Runtime ──────────────────────────────────
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 droideparanoico
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,109 +1,17 @@
|
||||
# DOStalgia 🕹️
|
||||
# Dostalgia
|
||||
|
||||
A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and metadata from IGDB, and play them directly in your browser via js-dos (DOSBox compiled to WebAssembly).
|
||||
|
||||
### Library view
|
||||

|
||||
|
||||
### Game detail view
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 🎮 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.
|
||||
|
||||
**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
|
||||
DOS games come in all shapes. DOStalgia handles them transparently:
|
||||
|
||||
- **Flattening** — If your ZIP has a single root directory (e.g. `doom/` with all files inside), the extractor flattens it so the game files sit at the bundle root. No extra nesting.
|
||||
- **Subdirectory games** — If files are deeper, the autoexec automatically `cd`s to the right directory before launching the executable.
|
||||
- **CD images** — Games shipped on CD-ROM often need the disc mounted. DOStalgia detects `.iso`, `.cue`, `.img`, `.ccd`, and `.bin` files, fixes broken CloneCD `.cue` references (where the referenced `.bin` is actually `.img`), and mounts them in both `dosbox.conf` and `jsdos.json`.
|
||||
- **CD-only games** — If the archive contains only CD images with no executable, a CD-only bundle is created that mounts the disc and drops you at the DOS prompt.
|
||||
- **Hardcoded paths** — `ConfigPatcher` scans game config files for hardcoded absolute paths (e.g. `C:\FALLOUT1\MASTER.DAT`) that broke after flattening, and rewrites them to relative paths.
|
||||
|
||||
Other solutions such as [RomM](https://www.romm.app/) generate raw ZIP bundles and require you to write your own `dosbox.conf` with manual `mount` and `imgmount` commands. DOStalgia automates all of this for you.
|
||||
|
||||
### 🔍 Smart executable detection
|
||||
On upload, DOStalgia scans every `.exe`, `.com`, and `.bat` file and picks the best candidate as the main executable using a scoring system:
|
||||
|
||||
1. **Not an installer** — INSTALL/SETUP/CONFIG executables are deprioritised
|
||||
2. **DOS executables** — Pure DOS apps are preferred over Windows ones
|
||||
3. **Larger files** — Bigger executables are more likely to be the game
|
||||
4. **Shallow depth** — Files closer to the root are preferred
|
||||
5. **Self-extractor filtering** — PKZIP/PKSFX stubs are filtered out
|
||||
|
||||
All discovered executables are stored and available in the **Edit** page, where you can pick a different one via radio buttons.
|
||||
|
||||
Again, RomM requires you to manually specify the executable in a custom `dosbox.conf`, while DOStalgia detects and configures it for you.
|
||||
|
||||
> **⚠️ Cache note:** If you change the executable, the `.jsdos` bundle is patched in-place. Your browser may serve a cached version of the old bundle — if the game doesn't launch with the new executable, **hard-refresh** the play page (Ctrl+Shift+R / Cmd+Shift+R).
|
||||
|
||||
### 🛠 One-click Setup launcher
|
||||
Many DOS games include a `SETUP.EXE`, `INSTALL.EXE`, or `CONFIG.EXE` used to configure sound, controls, and graphics. When DOStalgia detects one:
|
||||
|
||||
- A **🛠 Setup** button appears on the game detail page
|
||||
- Clicking it launches the setup executable *without modifying the main game bundle*
|
||||
- The setup bundle is generated **on-the-fly** by the server — a modified `.jsdos` is streamed with the setup executable in the autoexec, then discarded. Nothing is written to disk.
|
||||
|
||||
This is not supported in RomM, where you would have to run the setup manually from the DOS prompt every time.
|
||||
|
||||
### 🪟 Windows game detection
|
||||
DOSBox can't run Windows executables. DOStalgia's `PlatformDetector` reads MZ/PE/NE headers to detect Windows executables:
|
||||
|
||||
- The game detail page shows a **🪟 Requires Windows 3.1** badge
|
||||
- The **Play** button is disabled with "Unplayable" text
|
||||
- If you click Play anyway, a warning overlay explains the limitation with a **Try anyway** fallback
|
||||
- Setup executables that are Windows-native are also filtered out from the Setup button
|
||||
|
||||
### 📡 IGDB metadata & media
|
||||
DOStalgia integrates with [IGDB](https://www.igdb.com/) (via Twitch OAuth2) to auto-populate game info:
|
||||
|
||||
- **Auto Scrape** — On upload, DOStalgia searches IGDB by title and fills in year, genre, developer, publisher, description, and cover art. If IGDB finds a DOS result, it prioritises it.
|
||||
- **Manual Search** — From the game detail page you can search IGDB by any query, browse results with cover thumbnails and DOS badges, and apply the one you want.
|
||||
- **Media** — Videos (YouTube embeds) and screenshots (1080p) are fetched and displayed in a scrollable media gallery with a preview player.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Go to https://dev.twitch.tv/console/apps → **Register Your Application**
|
||||
2. Name: `dostalgia` (or anything), OAuth Redirect URL: `http://localhost`, Category: **Other**
|
||||
3. Copy the **Client ID** and generate a **Client Secret**
|
||||
4. Provide them via environment variables:
|
||||
|
||||
Method | How
|
||||
--- | ---
|
||||
Docker run | `-e TWITCH_CLIENT_ID=xxx -e TWITCH_CLIENT_SECRET=yyy`
|
||||
Docker Compose | Copy `.env.example` to `.env` and fill in the values
|
||||
Local dev | `export TWITCH_CLIENT_ID=xxx TWITCH_CLIENT_SECRET=yyy`
|
||||
|
||||
IGDB features degrade gracefully — if credentials aren't set, uploads and metadata editing still work, just without auto-scrape.
|
||||
A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### With Docker Compose (easiest)
|
||||
|
||||
```bash
|
||||
# 1. (Optional) Enable IGDB metadata auto-scrape
|
||||
cp .env.example .env
|
||||
# Edit .env with your Twitch credentials (see IGDB section below)
|
||||
|
||||
# 2. Pull & run
|
||||
docker compose up -d
|
||||
# Build and run with Docker
|
||||
docker build -t dostalgia .
|
||||
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
|
||||
```
|
||||
|
||||
Open http://localhost:8765
|
||||
|
||||
### With Docker
|
||||
|
||||
```bash
|
||||
docker run -p 8765:8765 -v $(pwd)/data:/data \
|
||||
-e TWITCH_CLIENT_ID=your_id \
|
||||
-e TWITCH_CLIENT_SECRET=your_secret \
|
||||
droideparanoico/dostalgia
|
||||
```
|
||||
|
||||
## Development (without Docker)
|
||||
|
||||
Terminal 1 — Frontend dev server:
|
||||
@@ -127,8 +35,34 @@ java -jar target/quarkus-app/quarkus-run.jar
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external database
|
||||
- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero database
|
||||
- **Frontend**: Svelte 5 SPA with hash-based routing
|
||||
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
|
||||
- **Storage**: JSON-per-game under `/data/games/{id}/game.json`
|
||||
- **Saves**: Browser localStorage / indexedDB (managed by js-dos)
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/games` | List all games |
|
||||
| GET | `/api/games/:id` | Get game details |
|
||||
| PATCH | `/api/games/:id` | Update game metadata |
|
||||
| DELETE | `/api/games/:id` | Delete a game |
|
||||
| POST | `/api/upload` | Upload a game ZIP (multipart) |
|
||||
| POST | `/api/games/:id/cover` | Upload cover art |
|
||||
| GET | `/api/igdb/search?q=` | Search IGDB (placeholder) |
|
||||
|
||||
## Game JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "doom",
|
||||
"title": "Doom",
|
||||
"year": 1993,
|
||||
"genre": "FPS",
|
||||
"developer": "id Software",
|
||||
"bundle_file": "doom.jsdos",
|
||||
"has_cover": true,
|
||||
"ready": true
|
||||
}
|
||||
```
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
services:
|
||||
dostalgia:
|
||||
image: droideparanoico/dostalgia
|
||||
build: .
|
||||
container_name: dostalgia
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
# Configure here your data directory if you want persistence.
|
||||
- ./data:/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DOSTALGIA_DATA_DIR=/data
|
||||
# IGDB credentials — set these for auto-scrape to work:
|
||||
# - TWITCH_CLIENT_ID=your_client_id
|
||||
# - TWITCH_CLIENT_SECRET=your_client_secret
|
||||
restart: unless-stopped
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
|
||||
<title>DOStalgia</title>
|
||||
<title>Dostalgia</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
|
||||
Generated
+3
-1874
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,16 @@
|
||||
{
|
||||
"name": "dostalgia-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.8"
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header onUploadClick={() => uploadTriggered++} hideUpload={route !== "/"} />
|
||||
<Header onUploadClick={() => uploadTriggered++} />
|
||||
|
||||
<main class="container">
|
||||
{#if route === "/"}
|
||||
|
||||
+8
-13
@@ -98,8 +98,7 @@ h3 { font-size: 1.2rem; }
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BUTTONS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
/* Only targets DOStalgia buttons, not js-dos emulator buttons inside .dos-container */
|
||||
.btn.btn:not(.dos-container .btn) {
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -112,36 +111,32 @@ h3 { font-size: 1.2rem; }
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
line-height: normal;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn.btn:not(.dos-container .btn):hover {
|
||||
.btn:hover {
|
||||
background: var(--phosphor-burn);
|
||||
border-color: var(--phosphor);
|
||||
box-shadow: 0 0 12px var(--phosphor-dark);
|
||||
}
|
||||
.btn.btn:not(.dos-container .btn):active {
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary.btn-primary:not(.dos-container .btn) {
|
||||
.btn-primary {
|
||||
background: var(--phosphor-dark);
|
||||
border-color: var(--phosphor);
|
||||
color: var(--phosphor-glow);
|
||||
}
|
||||
.btn-primary.btn-primary:not(.dos-container .btn):hover {
|
||||
.btn-primary:hover {
|
||||
background: var(--phosphor-dim);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-danger.btn-danger:not(.dos-container .btn) {
|
||||
.btn-danger {
|
||||
border-color: #cc3333;
|
||||
color: #cc3333;
|
||||
}
|
||||
.btn-danger.btn-danger:not(.dos-container .btn):hover {
|
||||
.btn-danger:hover {
|
||||
background: #331111;
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
@@ -192,7 +187,7 @@ input::placeholder {
|
||||
@media (max-width: 640px) {
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
.btn.btn:not(.dos-container .btn) { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
||||
.btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
||||
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
let { onUploadClick = () => {}, hideUpload = false } = $props();
|
||||
let { onUploadClick = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
@@ -17,9 +17,7 @@
|
||||
<span class="logo-text">DOSTALGIA</span>
|
||||
</a>
|
||||
<nav>
|
||||
{#if !hideUpload}
|
||||
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
||||
{/if}
|
||||
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import GameCard from "../GameCard.svelte";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "test-1",
|
||||
title: "Test Game",
|
||||
year: 1995,
|
||||
genre: "Adventure",
|
||||
platform: "dos",
|
||||
has_cover: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GameCard", () => {
|
||||
it("renders the game title", () => {
|
||||
render(GameCard, { props: { game: makeGame() } });
|
||||
expect(screen.getByText("Test Game")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the year when provided", () => {
|
||||
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
|
||||
expect(screen.getByText("1997")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render year when absent", () => {
|
||||
render(GameCard, { props: { game: makeGame({ year: null }) } });
|
||||
expect(screen.queryByText("1995")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the genre when provided", () => {
|
||||
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
|
||||
expect(screen.getByText("RPG")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a cover image when has_cover is true", () => {
|
||||
const game = makeGame({ has_cover: true });
|
||||
render(GameCard, { props: { game } });
|
||||
const img = screen.getByRole("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
|
||||
expect(img.getAttribute("alt")).toBe(game.title);
|
||||
});
|
||||
|
||||
it("shows a placeholder when has_cover is false", () => {
|
||||
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
|
||||
expect(screen.getByText("💾")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Windows badge for windows platform", () => {
|
||||
render(GameCard, {
|
||||
props: { game: makeGame({ platform: "windows" }) },
|
||||
});
|
||||
expect(screen.getByText(/🪟/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show Windows badge for DOS platform", () => {
|
||||
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
|
||||
expect(screen.queryByText(/🪟/)).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates on click", async () => {
|
||||
window.location.hash = "";
|
||||
render(GameCard, { props: { game: makeGame() } });
|
||||
const btn = screen.getByRole("button");
|
||||
btn.click();
|
||||
expect(window.location.hash).toBe("#/game/test-1");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
|
||||
|
||||
describe("artworkUrl", () => {
|
||||
it("returns null for null input", () => {
|
||||
expect(artworkUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined input", () => {
|
||||
expect(artworkUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through absolute HTTP URLs", () => {
|
||||
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
|
||||
"https://example.com/cover.jpg"
|
||||
);
|
||||
});
|
||||
|
||||
it("proxies relative paths through /artwork/", () => {
|
||||
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
|
||||
});
|
||||
|
||||
it("preserves nested relative paths", () => {
|
||||
expect(artworkUrl("some/deep/path/file.webp")).toBe(
|
||||
"/artwork/some/deep/path/file.webp"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleUrl", () => {
|
||||
it("returns null for null game", () => {
|
||||
expect(bundleUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined game", () => {
|
||||
expect(bundleUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds URL from bundle_file", () => {
|
||||
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
|
||||
});
|
||||
|
||||
it("preserves subdirectory bundle paths", () => {
|
||||
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
|
||||
"/games/subdir/game.zip"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupBundleUrl", () => {
|
||||
it("returns null for null game", () => {
|
||||
expect(setupBundleUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined game", () => {
|
||||
expect(setupBundleUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds API setup URL from game id", () => {
|
||||
expect(setupBundleUrl({ id: "game-1" })).toBe(
|
||||
"/api/games/game-1/setup-bundle"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles games with numeric id", () => {
|
||||
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { push, replace } from "../router.js";
|
||||
|
||||
describe("router", () => {
|
||||
describe("push", () => {
|
||||
it("sets window.location.hash with # prefix", () => {
|
||||
window.location.hash = "";
|
||||
push("/game/123");
|
||||
expect(window.location.hash).toBe("#/game/123");
|
||||
});
|
||||
|
||||
it("handles root path", () => {
|
||||
window.location.hash = "";
|
||||
push("/");
|
||||
expect(window.location.hash).toBe("#/");
|
||||
});
|
||||
|
||||
it("replaces hash on subsequent calls", () => {
|
||||
window.location.hash = "";
|
||||
push("/first");
|
||||
push("/second");
|
||||
expect(window.location.hash).toBe("#/second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace", () => {
|
||||
it("sets hash via replace (no back-navigation allowed)", () => {
|
||||
window.location.hash = "#/old";
|
||||
replace("/new");
|
||||
expect(window.location.hash).toBe("#/new");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** API client for DOStalgia backend. */
|
||||
/** API client for Dostalgia backend. */
|
||||
|
||||
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
← {isSetup ? "Back" : game.title}
|
||||
</button>
|
||||
{#if running}
|
||||
<button class="btn btn-danger" onclick={stopEmulator}>⏹ Stop</button>
|
||||
<button class="btn stop-btn" onclick={stopEmulator}>⏹ Stop</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -211,6 +211,14 @@
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.link-button:hover { color: var(--phosphor); }
|
||||
.stop-btn {
|
||||
border-color: #cc3333;
|
||||
color: #ff4444;
|
||||
}
|
||||
.stop-btn:hover {
|
||||
background: #331111;
|
||||
box-shadow: 0 0 12px #331111;
|
||||
}
|
||||
|
||||
/* Emulator canvas — always mounted */
|
||||
.dos-container {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import GameDetail from "../GameDetail.svelte";
|
||||
|
||||
// Mock api.js
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
deleteGame: vi.fn(),
|
||||
updateGame: vi.fn(),
|
||||
searchIGDB: vi.fn(),
|
||||
scrapeIGDB: vi.fn(),
|
||||
downloadGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
artworkUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock("../../lib/router.js", () => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "game-1",
|
||||
title: "Commander Keen",
|
||||
year: 1991,
|
||||
genre: "Platformer",
|
||||
developer: "id Software",
|
||||
publisher: "Apogee",
|
||||
description: "A classic DOS platformer.",
|
||||
platform: "dos",
|
||||
ready: true,
|
||||
has_cover: false,
|
||||
has_setup: false,
|
||||
bundle_file: "commander-keen.zip",
|
||||
bundle_size: 512000,
|
||||
executables: ["keen.exe"],
|
||||
executable: null,
|
||||
videos: [],
|
||||
screenshots: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GameDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
expect(screen.getByText("Loading...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error when game is not found", async () => {
|
||||
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||
render(GameDetail, { props: { id: "missing" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Game not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders game title", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Commander Keen")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders developer and publisher", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ developer: "id Software", publisher: "Apogee" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/id Software/)).toBeTruthy();
|
||||
expect(screen.getByText(/Apogee/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders year and genre as links", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ year: 1991, genre: "Platformer" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("1991").closest("a")).toBeTruthy();
|
||||
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows DOS badge for dos platform", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("💾 DOS")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Windows badge for windows platform", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Play button for ready DOS games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ ready: true, platform: "dos" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("▶ Play")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows disabled Play button for Windows games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ ready: true, platform: "windows" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Setup button when has_setup is true", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ has_setup: true })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("🛠 Setup")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Edit, Download, Delete buttons", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||
expect(screen.getByText("⬇ Download")).toBeTruthy();
|
||||
expect(screen.getByText("✕ Delete")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("enters edit mode when Edit button is clicked", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||
});
|
||||
screen.getByText("✏ Edit").click();
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeTruthy();
|
||||
expect(screen.getByText("Cancel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cover placeholder when no cover", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("💾")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows description when provided", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ description: "A classic DOS platformer." })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("A classic DOS platformer.")
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Library from "../Library.svelte";
|
||||
|
||||
// Mock the API module that Library imports
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
listGames: vi.fn(),
|
||||
uploadGame: vi.fn(),
|
||||
searchIGDB: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "g1",
|
||||
title: "Test Game",
|
||||
year: 1995,
|
||||
genre: "Adventure",
|
||||
platform: "dos",
|
||||
has_cover: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Library", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.hash = "#/";
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
render(Library);
|
||||
expect(screen.getByText("Scanning library...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no games", async () => {
|
||||
api.listGames.mockResolvedValue({ games: [] });
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("No games yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders game cards when games load", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "Game One" }),
|
||||
makeGame({ id: "g2", title: "Game Two" }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Game One")).toBeTruthy();
|
||||
expect(screen.getByText("Game Two")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows game count", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "One" }),
|
||||
makeGame({ id: "g2", title: "Two" }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("2 games")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows singular game count for one game", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [makeGame({ id: "g1", title: "Only" })],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("1 game")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
it("shows filter options derived from loaded games", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
|
||||
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
|
||||
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
// Year labels appear both as filter options and in game cards
|
||||
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies genre filter from props", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
|
||||
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
|
||||
],
|
||||
});
|
||||
render(Library, { props: { genreFilter: "RPG" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("RPG Game")).toBeTruthy();
|
||||
expect(screen.queryByText("Adventure Game")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("applies year filter from props", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
|
||||
makeGame({ id: "g2", title: "New Game", year: 1997 }),
|
||||
],
|
||||
});
|
||||
render(Library, { props: { yearFilter: "1997" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("New Game")).toBeTruthy();
|
||||
expect(screen.queryByText("Old Game")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows filter-empty message when no filter options exist", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", genre: null, year: null }),
|
||||
makeGame({ id: "g2", genre: null, year: null }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
const dashes = screen.getAllByText("—");
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Play from "../Play.svelte";
|
||||
|
||||
// Mock the API module that Play imports
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock("../../lib/router.js", () => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "game-1",
|
||||
title: "Commander Keen",
|
||||
platform: "dos",
|
||||
ready: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Play", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
expect(screen.getByText("Loading game data...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows not-ready state when game is not processed", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ ready: false }));
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Not ready")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows unsupported warning for Windows games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ platform: "windows", ready: true })
|
||||
);
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Windows game")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error box when game fetch fails", async () => {
|
||||
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||
render(Play, { props: { id: "missing" } });
|
||||
// The error should appear in the overlay as well
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("⚠️")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows emulator booting state for ready DOS games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ platform: "dos", ready: true })
|
||||
);
|
||||
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
@@ -16,12 +15,4 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
conditions: process.env.VITEST ? ["browser", "module", "import"] : [],
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.js"],
|
||||
},
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 261 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 |
Binary file not shown.
|
Before Width: | Height: | Size: 314 KiB |
@@ -4,9 +4,9 @@
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>org.dostalgia</groupId>
|
||||
<groupId>com.dostalgia</groupId>
|
||||
<artifactId>dostalgia</artifactId>
|
||||
<version>0.1.1</version>
|
||||
<version>0.1.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
@@ -15,10 +15,6 @@
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<quarkus.platform.version>3.19.2</quarkus.platform.version>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<!-- Set to true to skip all frontend npm tasks (install/build/test) -->
|
||||
<skipFrontend>false</skipFrontend>
|
||||
<!-- Set to true to skip only frontend tests (backend tests still run) -->
|
||||
<skipFrontendTests>false</skipFrontendTests>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -101,55 +97,6 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- Run frontend npm tasks during the Maven lifecycle -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>npm-install</id>
|
||||
<phase>initialize</phase>
|
||||
<goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<workingDirectory>frontend</workingDirectory>
|
||||
<executable>npm</executable>
|
||||
<arguments>
|
||||
<argument>ci</argument>
|
||||
<argument>--prefer-offline</argument>
|
||||
</arguments>
|
||||
<skip>${skipFrontend}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>frontend-build</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<workingDirectory>frontend</workingDirectory>
|
||||
<executable>npm</executable>
|
||||
<arguments>
|
||||
<argument>run</argument>
|
||||
<argument>build</argument>
|
||||
</arguments>
|
||||
<skip>${skipFrontend}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>frontend-test</id>
|
||||
<phase>test</phase>
|
||||
<goals><goal>exec</goal></goals>
|
||||
<configuration>
|
||||
<workingDirectory>frontend</workingDirectory>
|
||||
<executable>npm</executable>
|
||||
<arguments>
|
||||
<argument>test</argument>
|
||||
</arguments>
|
||||
<skip>${skipFrontendTests}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
+19
-8
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
@@ -51,7 +51,7 @@ public class ConfigBuilder {
|
||||
|
||||
/** Build jsdos.json for a CD-only game. */
|
||||
public byte[] buildCdOnlyJsdosJson(List<String> cdImages) {
|
||||
String script = buildCdMountScript(cdImages) + "c:\n";
|
||||
String script = buildCdOnlyAutoexecScript(cdImages);
|
||||
return buildJsdosJsonRaw(script);
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ public class ConfigBuilder {
|
||||
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
|
||||
}
|
||||
|
||||
private static String buildCdMountScript(List<String> cdImages) {
|
||||
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
|
||||
StringBuilder script = new StringBuilder();
|
||||
script.append("mount c .\n");
|
||||
char driveLetter = 'D';
|
||||
@@ -200,11 +200,6 @@ public class ConfigBuilder {
|
||||
.append(img).append("\"").append(flags).append("\n");
|
||||
driveLetter++;
|
||||
}
|
||||
return script.toString();
|
||||
}
|
||||
|
||||
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
|
||||
StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
|
||||
script.append("c:\n");
|
||||
if (dir != null) {
|
||||
script.append("path=c:\\\n");
|
||||
@@ -214,6 +209,22 @@ public class ConfigBuilder {
|
||||
return script.toString();
|
||||
}
|
||||
|
||||
private static String buildCdOnlyAutoexecScript(List<String> cdImages) {
|
||||
StringBuilder script = new StringBuilder();
|
||||
script.append("mount c .\n");
|
||||
char driveLetter = 'D';
|
||||
for (String img : cdImages) {
|
||||
String imgLower = img.toLowerCase();
|
||||
String flags = imgLower.endsWith(".bin") && !imgLower.endsWith(".cue")
|
||||
? " -t cdrom -fs iso" : " -t cdrom";
|
||||
script.append("imgmount ").append(driveLetter).append(" \"")
|
||||
.append(img).append("\"").append(flags).append("\n");
|
||||
driveLetter++;
|
||||
}
|
||||
script.append("c:\n");
|
||||
return script.toString();
|
||||
}
|
||||
|
||||
private static byte[] buildJsdosJsonRaw(String script) {
|
||||
String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\""
|
||||
+ escapeJson(script) + "\"}}},"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
+25
-13
@@ -1,6 +1,5 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
@@ -25,6 +24,7 @@ public class ExecutableDetector {
|
||||
@Inject
|
||||
PlatformDetector platform;
|
||||
|
||||
/** Executable stems to never select as main game executable. */
|
||||
private static final Set<String> SKIP_EXE_NAMES = Set.of(
|
||||
"dos4gw", "dos32a", "dos4gw2", "pmode", "pmodew", "cwsdpmi",
|
||||
"emm386", "himem", "himemx", "debug",
|
||||
@@ -35,17 +35,31 @@ public class ExecutableDetector {
|
||||
"ace", "unace", "zoo", "arc", "ha", "cab"
|
||||
);
|
||||
|
||||
/** Collect all discoverable executables (relative paths) from the extracted directory. */
|
||||
public List<String> findAll(Path dir) throws IOException {
|
||||
return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
List<String> result = new ArrayList<>();
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
|
||||
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
result.sort(String::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Find the best main executable. */
|
||||
public String findMain(Path dir) throws IOException {
|
||||
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||
return FileVisitResult.CONTINUE;
|
||||
@@ -81,23 +95,23 @@ public class ExecutableDetector {
|
||||
return candidates.getFirst().path();
|
||||
}
|
||||
|
||||
/** Find a setup/install/config executable. */
|
||||
public String findSetup(Path dir) throws IOException {
|
||||
record Candidate(int depth, boolean isWindows, long size, String path) {}
|
||||
record Candidate(int depth, long size, String path) {}
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
List<String> patterns = List.of("setup", "setmain", "install", "config", "custom");
|
||||
List<String> patterns = List.of("setup", "install", "config", "custom");
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
|
||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||
return FileVisitResult.CONTINUE;
|
||||
for (String pat : patterns) {
|
||||
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
|
||||
boolean isWin = "windows".equals(platform.detect(f));
|
||||
candidates.add(new Candidate(
|
||||
f.getNameCount() - dir.getNameCount(), isWin, a.size(), f.toString()));
|
||||
f.getNameCount() - dir.getNameCount(), a.size(), f.toString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -106,13 +120,11 @@ public class ExecutableDetector {
|
||||
});
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
candidates.sort(Comparator
|
||||
.comparingInt((Candidate c) -> c.isWindows() ? 1 : 0) // prefer DOS over Windows
|
||||
.thenComparingInt(Candidate::depth)
|
||||
.thenComparingLong(Candidate::size));
|
||||
candidates.sort(Comparator.comparingInt(Candidate::depth).thenComparingLong(Candidate::size));
|
||||
return candidates.getFirst().path();
|
||||
}
|
||||
|
||||
/** Check if an executable is likely a self-extracting archive (PKSFX stub, etc.). */
|
||||
public static boolean isLikelySelfExtractor(Path exePath) {
|
||||
try (var fis = Files.newInputStream(exePath)) {
|
||||
byte[] buf = new byte[32768];
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
@@ -15,7 +15,7 @@ import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
@Path("/api/games")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
+34
-9
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -12,10 +12,13 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
@@ -77,12 +80,9 @@ public class GameService implements GameStore {
|
||||
);
|
||||
// Detect bundle size
|
||||
try {
|
||||
String bf = game.getBundleFile();
|
||||
if (bf != null) {
|
||||
Path bundlePath = gamesDir.resolve(bf);
|
||||
if (Files.exists(bundlePath)) {
|
||||
game.setBundleSize(Files.size(bundlePath));
|
||||
}
|
||||
Path bundlePath = gamesDir.resolve(game.getBundleFile());
|
||||
if (Files.exists(bundlePath)) {
|
||||
game.setBundleSize(Files.size(bundlePath));
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
return game;
|
||||
@@ -121,7 +121,21 @@ public class GameService implements GameStore {
|
||||
|
||||
/** Delete a game and all its files. */
|
||||
public void delete(String id) throws IOException {
|
||||
FileUtils.deleteDirectory(gameDir(id));
|
||||
Path dir = gameDir(id);
|
||||
if (Files.exists(dir)) {
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Clean up old flat .jsdos locations (pre-game-dir layout — backward compat)
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
|
||||
@@ -211,7 +225,18 @@ public class GameService implements GameStore {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(relExe.replace('\\', '/'))) {
|
||||
return PlatformDetector.detectFromBytes(PlatformDetector.readHeaderBytes(zis));
|
||||
byte[] buf = new byte[0x80];
|
||||
int read = zis.readNBytes(buf, 0, buf.length);
|
||||
if (read < 2) return null;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||
if (read < 0x40) return "dos";
|
||||
int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8)
|
||||
| ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24);
|
||||
if (peOffset < 0 || peOffset + 2 > read) return "dos";
|
||||
byte sig1 = buf[peOffset], sig2 = buf[peOffset + 1];
|
||||
if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45))
|
||||
return "windows";
|
||||
return "dos";
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
+233
-86
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -54,10 +54,8 @@ public class IgdbService {
|
||||
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
|
||||
return accessToken;
|
||||
}
|
||||
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
|
||||
String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured"));
|
||||
String body = "client_id=" + cid
|
||||
+ "&client_secret=" + secret
|
||||
String body = "client_id=" + clientId.get()
|
||||
+ "&client_secret=" + clientSecret.get()
|
||||
+ "&grant_type=client_credentials";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
@@ -74,34 +72,27 @@ public class IgdbService {
|
||||
JsonNode json = mapper.readTree(res.body());
|
||||
accessToken = json.get("access_token").asText();
|
||||
int expiresIn = json.get("expires_in").asInt();
|
||||
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120);
|
||||
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); // 2 min buffer
|
||||
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private HttpRequest.Builder igdbRequest(String path) throws Exception {
|
||||
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(IGDB_API + path))
|
||||
.header("Client-ID", cid)
|
||||
.header("Client-ID", clientId.get())
|
||||
.header("Authorization", "Bearer " + getToken())
|
||||
.header("Content-Type", "text/plain");
|
||||
}
|
||||
|
||||
/** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */
|
||||
private JsonNode postAndGet(String endpoint, String apql) throws Exception {
|
||||
HttpRequest req = igdbRequest(endpoint)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) return null;
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
return results.isArray() ? results : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search IGDB for games matching the given query.
|
||||
* Returns a list of simplified result maps suitable for JSON serialization.
|
||||
*/
|
||||
public List<Map<String, Object>> search(String query) throws Exception {
|
||||
if (!isConfigured()) return List.of();
|
||||
|
||||
// Search limited to DOS platform (IGDB platform ID 13)
|
||||
String apql = "search \"" + sanitize(query) + "\";"
|
||||
+ " fields name,first_release_date,genres.name,"
|
||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||
@@ -109,9 +100,19 @@ public class IgdbService {
|
||||
+ " where platforms = [13];"
|
||||
+ " limit 20;";
|
||||
|
||||
JsonNode results = postAndGet("/games", apql);
|
||||
if (results == null || results.isEmpty()) {
|
||||
LOG.info("IGDB search: no results for '" + query + "'");
|
||||
HttpRequest req = igdbRequest("/games")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) {
|
||||
LOG.warning("IGDB search failed: HTTP " + res.statusCode() + " " + res.body());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
if (!results.isArray() || results.isEmpty()) {
|
||||
LOG.info("IGDB search: no results for '" + query + "'. Response: " + res.body());
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -121,13 +122,22 @@ public class IgdbService {
|
||||
entry.put("igdb_id", game.get("id").asInt());
|
||||
entry.put("name", game.has("name") ? game.get("name").asText() : query);
|
||||
|
||||
epochToYear(game).ifPresent(y -> entry.put("year", y));
|
||||
if (game.has("first_release_date")) {
|
||||
long epoch = game.get("first_release_date").asLong();
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis(epoch * 1000);
|
||||
entry.put("year", cal.get(Calendar.YEAR));
|
||||
}
|
||||
|
||||
List<String> genres = extractGenres(game);
|
||||
if (!genres.isEmpty()) {
|
||||
if (game.has("genres") && game.get("genres").isArray()) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
for (JsonNode g : game.get("genres")) {
|
||||
if (g.has("name")) genres.add(g.get("name").asText());
|
||||
}
|
||||
entry.put("genres", genres);
|
||||
}
|
||||
|
||||
// Extract developer and publisher
|
||||
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
|
||||
String developer = null;
|
||||
String publisher = null;
|
||||
@@ -148,104 +158,216 @@ public class IgdbService {
|
||||
entry.put("summary", game.get("summary").asText());
|
||||
}
|
||||
|
||||
// Platforms (check if DOS or PC)
|
||||
if (game.has("platforms") && game.get("platforms").isArray()) {
|
||||
List<Integer> platformIds = new ArrayList<>();
|
||||
for (JsonNode p : game.get("platforms")) {
|
||||
platformIds.add(p.asInt());
|
||||
}
|
||||
entry.put("platform_ids", platformIds);
|
||||
entry.put("is_dos", platformIds.contains(13));
|
||||
boolean isDos = platformIds.contains(13); // 13 = DOS
|
||||
entry.put("is_dos", isDos);
|
||||
}
|
||||
|
||||
// Cover URL — IGDB now returns cover as object with url (since we use cover.url in fields)
|
||||
if (game.has("cover") && game.get("cover").has("url")) {
|
||||
String thumbUrl = game.get("cover").get("url").asText();
|
||||
// IGDB URL format: //images.igdb.com/igdb/image/upload/t_thumb/co1x8h.jpg
|
||||
// Replace t_thumb with our desired size
|
||||
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
||||
entry.put("cover_url", coverUrl);
|
||||
} else if (game.has("cover") && game.get("cover").isInt()) {
|
||||
// Fallback: cover is just an integer ID, fetch URL separately
|
||||
int coverId = game.get("cover").asInt();
|
||||
String coverUrl = fetchCoverUrl(coverId);
|
||||
if (coverUrl != null) entry.put("cover_url", coverUrl);
|
||||
if (coverUrl != null) {
|
||||
entry.put("cover_url", coverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
out.add(entry);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the cover image URL for a given IGDB cover ID.
|
||||
*/
|
||||
private String fetchCoverUrl(int coverId) throws Exception {
|
||||
JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
|
||||
if (results == null || results.isEmpty()) return null;
|
||||
String apql = "fields url; where id = " + coverId + ";";
|
||||
HttpRequest req = igdbRequest("/covers")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) return null;
|
||||
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
if (!results.isArray() || results.isEmpty()) return null;
|
||||
|
||||
String thumbUrl = results.get(0).get("url").asText();
|
||||
// IGDB returns: //images.igdb.com/.../t_thumb/xxx.jpg
|
||||
// Replace t_thumb with desired size
|
||||
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
private List<String> fetchVideos(int igdbId) throws Exception {
|
||||
JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";");
|
||||
if (results == null) return List.of();
|
||||
/**
|
||||
* Fetch YouTube video IDs associated with an IGDB game.
|
||||
*/
|
||||
public List<String> fetchVideos(int igdbId) throws Exception {
|
||||
String apql = "fields video_id; where game = " + igdbId + ";";
|
||||
HttpRequest req = igdbRequest("/game_videos")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) return List.of();
|
||||
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
if (!results.isArray()) return List.of();
|
||||
|
||||
List<String> videos = new ArrayList<>();
|
||||
for (JsonNode v : results) {
|
||||
if (v.has("video_id")) videos.add(v.get("video_id").asText());
|
||||
if (v.has("video_id")) {
|
||||
videos.add(v.get("video_id").asText());
|
||||
}
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
private List<String> fetchScreenshots(int igdbId) throws Exception {
|
||||
JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";");
|
||||
if (results == null) return List.of();
|
||||
/**
|
||||
* Fetch screenshot image URLs for an IGDB game.
|
||||
*/
|
||||
public List<String> fetchScreenshots(int igdbId) throws Exception {
|
||||
String apql = "fields url; where game = " + igdbId + ";";
|
||||
HttpRequest req = igdbRequest("/screenshots")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) return List.of();
|
||||
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
if (!results.isArray()) return List.of();
|
||||
|
||||
List<String> screenshots = new ArrayList<>();
|
||||
for (JsonNode s : results) {
|
||||
if (s.has("url")) {
|
||||
String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
|
||||
String thumbUrl = s.get("url").asText();
|
||||
// Use bigger size for display
|
||||
String fullUrl = "https:" + thumbUrl.replace("t_thumb", "t_1080p");
|
||||
screenshots.add(fullUrl);
|
||||
}
|
||||
}
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-search IGDB for the game title and apply the best match.
|
||||
* Updates the Game object in-place. Does NOT save — caller must svc.save().
|
||||
* Does NOT throw on failure; logs warnings instead.
|
||||
*/
|
||||
public void autoScrape(Game game) {
|
||||
if (!isConfigured()) return;
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> results = search(game.getTitle());
|
||||
if (results.isEmpty()) {
|
||||
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick best match: prefer DOS platform, otherwise first result
|
||||
Map<String, Object> best = null;
|
||||
for (Map<String, Object> r : results) {
|
||||
if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; }
|
||||
if (Boolean.TRUE.equals(r.get("is_dos"))) {
|
||||
best = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (best == null) best = results.getFirst();
|
||||
if (best == null) best = results.get(0);
|
||||
|
||||
applyMatch(game, best);
|
||||
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
|
||||
|
||||
// Fetch videos and screenshots
|
||||
Object igdbIdObj = best.get("igdb_id");
|
||||
if (igdbIdObj instanceof Number igdbIdNum) {
|
||||
int igdbId = igdbIdNum.intValue();
|
||||
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); }
|
||||
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); }
|
||||
try {
|
||||
List<String> videos = fetchVideos(igdbId);
|
||||
if (!videos.isEmpty()) {
|
||||
game.setVideos(videos);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("IGDB: failed to fetch videos: " + e.getMessage());
|
||||
}
|
||||
try {
|
||||
List<String> screenshots = fetchScreenshots(igdbId);
|
||||
if (!screenshots.isEmpty()) {
|
||||
game.setScreenshots(screenshots);
|
||||
game.setHasScreenshots(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a specific IGDB ID to a game. Fetches fresh data and downloads cover.
|
||||
*/
|
||||
public void applyIgdbId(Game game, int igdbId) throws Exception {
|
||||
// Fetch game details from IGDB by ID
|
||||
String apql = "fields name,first_release_date,genres.name,"
|
||||
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
|
||||
+ " summary,cover.url;"
|
||||
+ " where id = " + igdbId + ";";
|
||||
JsonNode results = postAndGet("/games", apql);
|
||||
if (results == null || results.isEmpty()) {
|
||||
|
||||
HttpRequest req = igdbRequest("/games")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(apql))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) {
|
||||
throw new RuntimeException("IGDB fetch failed: HTTP " + res.statusCode());
|
||||
}
|
||||
|
||||
JsonNode results = mapper.readTree(res.body());
|
||||
if (!results.isArray() || results.isEmpty()) {
|
||||
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
|
||||
}
|
||||
|
||||
applyMatch(game, results.get(0));
|
||||
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); }
|
||||
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
|
||||
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); }
|
||||
|
||||
// Fetch videos and screenshots
|
||||
try {
|
||||
List<String> videos = fetchVideos(igdbId);
|
||||
if (!videos.isEmpty()) {
|
||||
game.setVideos(videos);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage());
|
||||
}
|
||||
try {
|
||||
List<String> screenshots = fetchScreenshots(igdbId);
|
||||
if (!screenshots.isEmpty()) {
|
||||
game.setScreenshots(screenshots);
|
||||
game.setHasScreenshots(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply IGDB game data to a local Game object. Downloads cover if available.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void applyMatch(Game game, Object matchData) throws Exception {
|
||||
Map<String, Object> data;
|
||||
@@ -256,26 +378,56 @@ public class IgdbService {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank()))
|
||||
|
||||
// Update game fields
|
||||
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) {
|
||||
game.setTitle((String) data.get("name"));
|
||||
if (data.containsKey("year")) game.setYear((Integer) data.get("year"));
|
||||
if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer"));
|
||||
if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher"));
|
||||
if (data.containsKey("summary")) game.setDescription((String) data.get("summary"));
|
||||
}
|
||||
if (data.containsKey("year")) {
|
||||
game.setYear((Integer) data.get("year"));
|
||||
}
|
||||
if (data.containsKey("developer")) {
|
||||
game.setDeveloper((String) data.get("developer"));
|
||||
}
|
||||
if (data.containsKey("publisher")) {
|
||||
game.setPublisher((String) data.get("publisher"));
|
||||
}
|
||||
if (data.containsKey("summary")) {
|
||||
game.setDescription((String) data.get("summary"));
|
||||
}
|
||||
if (data.containsKey("genres")) {
|
||||
List<String> genres = (List<String>) data.get("genres");
|
||||
if (!genres.isEmpty()) game.setGenre(genres.getFirst());
|
||||
if (!genres.isEmpty()) {
|
||||
game.setGenre(genres.getFirst());
|
||||
}
|
||||
}
|
||||
if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id"));
|
||||
if (data.containsKey("igdb_id")) {
|
||||
game.setIgdbId((Integer) data.get("igdb_id"));
|
||||
}
|
||||
|
||||
// Download cover
|
||||
String coverUrl = (String) data.get("cover_url");
|
||||
if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl);
|
||||
if (coverUrl != null && !coverUrl.isBlank()) {
|
||||
downloadCover(game, coverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a cover image from IGDB CDN and save locally.
|
||||
*/
|
||||
private void downloadCover(Game game, String coverUrl) throws Exception {
|
||||
// Ensure full URL (IGDB sometimes omits https:)
|
||||
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl;
|
||||
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
|
||||
gameStore.gameDir(game.getId()).resolve("cover.jpg")));
|
||||
gameStore.gameDir(game.getId()).resolve("cover.jpg")
|
||||
));
|
||||
|
||||
if (res.statusCode() == 200) {
|
||||
game.setHasCover(true);
|
||||
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'");
|
||||
@@ -284,28 +436,6 @@ public class IgdbService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert IGDB epoch (seconds) to year, from 'first_release_date' field. */
|
||||
private static Optional<Integer> epochToYear(JsonNode node) {
|
||||
if (node.has("first_release_date")) {
|
||||
long epoch = node.get("first_release_date").asLong();
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis(epoch * 1000);
|
||||
return Optional.of(cal.get(Calendar.YEAR));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Extract genre names from a JsonNode that may have a 'genres' array. */
|
||||
private static List<String> extractGenres(JsonNode node) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
if (node.has("genres") && node.get("genres").isArray()) {
|
||||
for (JsonNode g : node.get("genres")) {
|
||||
if (g.has("name")) genres.add(g.get("name").asText());
|
||||
}
|
||||
}
|
||||
return genres;
|
||||
}
|
||||
|
||||
private String sanitize(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
@@ -314,12 +444,22 @@ public class IgdbService {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
|
||||
map.put("name", node.has("name") ? node.get("name").asText() : "");
|
||||
epochToYear(node).ifPresent(y -> map.put("year", y));
|
||||
|
||||
List<String> genres = extractGenres(node);
|
||||
if (!genres.isEmpty()) {
|
||||
if (node.has("first_release_date")) {
|
||||
long epoch = node.get("first_release_date").asLong();
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis(epoch * 1000);
|
||||
map.put("year", cal.get(Calendar.YEAR));
|
||||
}
|
||||
|
||||
if (node.has("genres") && node.get("genres").isArray()) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
for (JsonNode g : node.get("genres")) {
|
||||
if (g.has("name")) genres.add(g.get("name").asText());
|
||||
}
|
||||
map.put("genres", genres);
|
||||
}
|
||||
|
||||
if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
|
||||
for (JsonNode ic : node.get("involved_companies")) {
|
||||
if (ic.has("company") && ic.get("company").has("name")) {
|
||||
@@ -331,12 +471,18 @@ public class IgdbService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.has("summary")) map.put("summary", node.get("summary").asText());
|
||||
|
||||
if (node.has("summary")) {
|
||||
map.put("summary", node.get("summary").asText());
|
||||
}
|
||||
|
||||
// Fetch cover URL — handle cover.url object or raw ID
|
||||
if (node.has("cover")) {
|
||||
JsonNode cover = node.get("cover");
|
||||
try {
|
||||
if (cover.has("url")) {
|
||||
String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE);
|
||||
String thumbUrl = cover.get("url").asText();
|
||||
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
|
||||
map.put("cover_url", coverUrl);
|
||||
} else if (cover.isInt() || cover.isLong()) {
|
||||
String coverUrl = fetchCoverUrl(cover.asInt());
|
||||
@@ -347,6 +493,7 @@ public class IgdbService {
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Detects platform type (DOS vs Windows) by reading executable headers.
|
||||
* Pure logic — no I/O beyond reading the file header bytes.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class PlatformDetector {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
|
||||
|
||||
/**
|
||||
* Detect whether an executable targets DOS or Windows by reading its PE/NE header.
|
||||
* <ul>
|
||||
* <li>Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"</li>
|
||||
* <li>NE (Windows 3.x), PE (Win32) → "windows"</li>
|
||||
* <li>Cannot read → null</li>
|
||||
* </ul>
|
||||
*/
|
||||
public String detect(Path exePath) {
|
||||
String name = exePath.getFileName().toString().toLowerCase();
|
||||
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
|
||||
|
||||
try (var fis = Files.newInputStream(exePath)) {
|
||||
byte[] buf = new byte[0x80];
|
||||
int read = fis.readNBytes(buf, 0, buf.length);
|
||||
if (read < 2) return null;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||
if (read < 0x40) return "dos";
|
||||
|
||||
// Read e_lfanew at offset 0x3C
|
||||
int peOffset = (buf[0x3C] & 0xFF)
|
||||
| ((buf[0x3D] & 0xFF) << 8)
|
||||
| ((buf[0x3E] & 0xFF) << 16)
|
||||
| ((buf[0x3F] & 0xFF) << 24);
|
||||
|
||||
if (peOffset < 0) return "dos";
|
||||
|
||||
byte sig1, sig2;
|
||||
if (peOffset + 2 <= read) {
|
||||
sig1 = buf[peOffset];
|
||||
sig2 = buf[peOffset + 1];
|
||||
} else {
|
||||
try (var fis2 = Files.newInputStream(exePath)) {
|
||||
sig1 = (byte) fis2.read();
|
||||
sig2 = (byte) fis2.read();
|
||||
}
|
||||
}
|
||||
|
||||
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|
||||
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
|
||||
return "windows";
|
||||
|
||||
return "dos";
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-17
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
@@ -20,40 +20,49 @@ public class StaticResource {
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
/** Serve a file from a subdirectory of the data dir with path traversal protection. */
|
||||
private Response serveFile(Path base, String path, String cacheHeader) {
|
||||
Path file = base.resolve(path).normalize();
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/games/{path: .*}")
|
||||
public Response getGameFile(@PathParam("path") String path) {
|
||||
Path file = Path.of(dataDir, "games", path).normalize();
|
||||
Path base = Path.of(dataDir, "games").normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return Response.status(403).build();
|
||||
}
|
||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||
return Response.status(404).build();
|
||||
}
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
var builder = Response.ok(new FileStream(file)).type(contentType);
|
||||
if (cacheHeader != null) {
|
||||
builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/games/{path: .*}")
|
||||
public Response getGameFile(@PathParam("path") String path) {
|
||||
return serveFile(Path.of(dataDir, "games"), path, "Accept-Ranges: bytes");
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
return Response.ok(new FileStream(file))
|
||||
.type(contentType)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/artwork/{path: .*}")
|
||||
public Response getArtwork(@PathParam("path") String path) {
|
||||
return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
|
||||
Path file = Path.of(dataDir, "artwork", path).normalize();
|
||||
Path base = Path.of(dataDir, "artwork").normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return Response.status(403).build();
|
||||
}
|
||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||
return Response.status(404).build();
|
||||
}
|
||||
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
return Response.ok(new FileStream(file))
|
||||
.type(contentType)
|
||||
.header("Cache-Control", "public, max-age=86400")
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Health check */
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/api/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) {
|
||||
+21
-8
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
@@ -11,9 +11,12 @@ import org.jboss.resteasy.reactive.RestForm;
|
||||
import org.jboss.resteasy.reactive.multipart.FileUpload;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
@@ -136,8 +139,7 @@ public class UploadResource {
|
||||
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
|
||||
|
||||
// Detect platform (DOS vs Windows)
|
||||
Path mainExePath = mainExe != null ? Path.of(mainExe) : null;
|
||||
String platformType = mainExe != null ? platform.detect(mainExePath) : "dos";
|
||||
String platformType = mainExe != null ? platform.detect(Path.of(mainExe)) : "dos";
|
||||
|
||||
// Save metadata
|
||||
Game game = new Game(gameId, title);
|
||||
@@ -146,15 +148,14 @@ public class UploadResource {
|
||||
|
||||
// Store the selected executable and the full list for the UI picker
|
||||
String relMain = mainExe != null
|
||||
? extractDir.relativize(mainExePath).toString().replace('\\', '/')
|
||||
? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/')
|
||||
: "";
|
||||
game.setExecutable(relMain);
|
||||
game.setExecutables(exeDetector.findAll(extractDir));
|
||||
if (setupExe != null) {
|
||||
Path setupExePath = Path.of(setupExe);
|
||||
String setupPlatform = platform.detect(setupExePath);
|
||||
String setupPlatform = platform.detect(Path.of(setupExe));
|
||||
if (!"windows".equals(setupPlatform)) {
|
||||
game.setSetupExe(extractDir.relativize(setupExePath).toString());
|
||||
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
||||
}
|
||||
}
|
||||
game.setReady(true);
|
||||
@@ -217,6 +218,18 @@ public class UploadResource {
|
||||
}
|
||||
|
||||
private void deleteDir(Path dir) throws IOException {
|
||||
FileUtils.deleteDirectory(dir);
|
||||
if (!Files.exists(dir)) return;
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+34
-28
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
@@ -13,8 +13,6 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* ZIP extraction, directory flattening, and .jsdos bundle creation.
|
||||
@@ -27,6 +25,7 @@ public class ZipService {
|
||||
@Inject
|
||||
ConfigBuilder config;
|
||||
|
||||
/** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */
|
||||
private static final Set<String> SKIP_EXT = Set.of(
|
||||
".nrg", ".mdf", ".mds", ".sub", ".dmg"
|
||||
);
|
||||
@@ -51,13 +50,14 @@ public class ZipService {
|
||||
|
||||
/**
|
||||
* If the extraction directory contains a single subdirectory, move its contents up.
|
||||
* Handles ZIPs where all game files are inside a folder.
|
||||
*/
|
||||
public void flattenSingleDir(Path dir) throws IOException {
|
||||
Path singleDir = null;
|
||||
try (var files = Files.list(dir)) {
|
||||
for (Path entry : files.toList()) {
|
||||
if (Files.isDirectory(entry)) {
|
||||
if (singleDir != null) return;
|
||||
if (singleDir != null) return; // multiple directories — abort
|
||||
singleDir = entry;
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,10 @@ public class ZipService {
|
||||
|
||||
/**
|
||||
* Create a .jsdos bundle ZIP from the extracted game directory.
|
||||
* Generates DOSBox configs and packs everything into a valid js-dos bundle.
|
||||
*/
|
||||
public void createBundle(Path extractDir, String exePath, List<String> cdImages, Path bundlePath) throws IOException {
|
||||
// Write .jsdos config into the extract directory
|
||||
Path jsdos = extractDir.resolve(".jsdos");
|
||||
Files.createDirectories(jsdos);
|
||||
if (exePath != null) {
|
||||
@@ -98,7 +100,8 @@ public class ZipService {
|
||||
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
|
||||
}
|
||||
|
||||
try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||
// ZIP it up from extractDir
|
||||
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||
|
||||
// Phase 1: Collect all directory paths
|
||||
var allDirs = new TreeSet<String>();
|
||||
@@ -113,16 +116,24 @@ public class ZipService {
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Write directory entries first
|
||||
// Phase 2: Write directory entries first (sorted → shallowest first)
|
||||
for (String dir : allDirs) {
|
||||
zos.putNextEntry(new ZipEntry(dir));
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
// Phase 3: .jsdos config files (before game files)
|
||||
// Phase 3: .jsdos config files (must come before game files)
|
||||
try (var walk = Files.walk(jsdos)) {
|
||||
walk.filter(Files::isRegularFile).sorted()
|
||||
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||
walk.filter(Files::isRegularFile).sorted().forEach(f -> {
|
||||
try {
|
||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
||||
Files.copy(f, zos);
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 4: Game files (excluding skipped extensions)
|
||||
@@ -135,27 +146,22 @@ public class ZipService {
|
||||
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
|
||||
})
|
||||
.sorted()
|
||||
.forEach(f -> zipEntry(zos, extractDir, f));
|
||||
.forEach(f -> {
|
||||
try {
|
||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
||||
Files.copy(f, zos);
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up .jsdos config files from the extract dir
|
||||
try (var cleanup = Files.walk(jsdos)) {
|
||||
cleanup.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a single file into a ZIP output stream, computing entry name relative to extractDir. */
|
||||
private static void zipEntry(ZipOutputStream zos, Path extractDir, Path file) {
|
||||
try {
|
||||
String entryName = extractDir.relativize(file).toString().replace('\\', '/');
|
||||
zos.putNextEntry(new ZipEntry(entryName));
|
||||
Files.copy(file, zos);
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Shared file-system utilities. */
|
||||
public final class FileUtils {
|
||||
private FileUtils() {}
|
||||
|
||||
/** Recursively delete a directory tree (walk in reverse order so dirs are empty when deleted). */
|
||||
public static void deleteDirectory(final Path dir) throws IOException {
|
||||
if (!Files.exists(dir)) return;
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull final Path f, @Nonnull final BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nonnull FileVisitResult postVisitDirectory(@Nonnull final Path d, final IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory tree and collect relative paths of files whose extension matches
|
||||
* one of the given extensions. Extensions should include the dot, e.g. {@code ".exe"}.
|
||||
* Results are sorted alphabetically.
|
||||
*/
|
||||
public static List<String> findFilesByExtensions(Path dir, Set<String> extensions) throws IOException {
|
||||
List<String> result = new ArrayList<>();
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
for (String ext : extensions) {
|
||||
if (name.endsWith(ext)) {
|
||||
result.add(dir.relativize(f).toString().replace('\\', '/'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
result.sort(String::compareTo);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Detects platform type (DOS vs Windows) by reading executable headers.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class PlatformDetector {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
|
||||
|
||||
public String detect(Path exePath) {
|
||||
String name = exePath.getFileName().toString().toLowerCase();
|
||||
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
|
||||
|
||||
try (var fis = Files.newInputStream(exePath)) {
|
||||
return detectFromBytes(readHeaderBytes(fis));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */
|
||||
static byte[] readHeaderBytes(InputStream is) throws IOException {
|
||||
byte[] buf = new byte[0x1000];
|
||||
int read = is.readNBytes(buf, 0, buf.length);
|
||||
return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf;
|
||||
}
|
||||
|
||||
/** Detect platform from pre-read MZ/PE header bytes. */
|
||||
static String detectFromBytes(byte[] buf) {
|
||||
return detectFromBytes(buf, buf.length);
|
||||
}
|
||||
|
||||
/** Detect platform from pre-read MZ/PE header bytes with explicit read length. */
|
||||
static String detectFromBytes(byte[] buf, int read) {
|
||||
if (read < 2) return null;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||
if (read < 0x40) return "dos";
|
||||
|
||||
int peOffset = (buf[0x3C] & 0xFF)
|
||||
| ((buf[0x3D] & 0xFF) << 8)
|
||||
| ((buf[0x3E] & 0xFF) << 16)
|
||||
| ((buf[0x3F] & 0xFF) << 24);
|
||||
|
||||
if (peOffset < 0 || peOffset + 2 > read) return "dos";
|
||||
|
||||
byte sig1 = buf[peOffset];
|
||||
byte sig2 = buf[peOffset + 1];
|
||||
|
||||
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|
||||
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
|
||||
return "windows";
|
||||
|
||||
return "dos";
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ quarkus.http.cors=true
|
||||
|
||||
# Allow large game uploads (DOS games can be 500MB+)
|
||||
quarkus.http.limits.max-body-size=2048M
|
||||
quarkus.resteasy-reactive.multipart.input-part.max-size=2048M
|
||||
|
||||
# ─── Jackson: snake_case to match frontend expectations ─
|
||||
quarkus.jackson.property-naming-strategy=SNAKE_CASE
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
+1
-65
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -9,7 +9,6 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -137,69 +136,6 @@ class ExecutableDetectorTest {
|
||||
assertFalse(ExecutableDetector.isLikelySelfExtractor(f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception {
|
||||
// Windows PE executable: MZ header + PE signature at offset 0x80
|
||||
byte[] windowsExe = new byte[0x100];
|
||||
windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ
|
||||
windowsExe[0x3C] = (byte) 0x80; // PE offset at 0x80
|
||||
windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature
|
||||
Files.write(dir.resolve("WIN.EXE"), windowsExe);
|
||||
|
||||
// DOS executable: just MZ header, no PE
|
||||
byte[] dosExe = new byte[0x40];
|
||||
dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ
|
||||
Files.write(dir.resolve("DOS.EXE"), dosExe);
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertNotNull(main);
|
||||
assertTrue(main.endsWith("DOS.EXE"),
|
||||
"DOS executable should be preferred over Windows, but got: " + main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||
Files.writeString(dir.resolve("notes.md"), "markdown");
|
||||
assertNull(detector.findMain(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception {
|
||||
// Self-extractor with PK\x03\x04 signature bytes (ZIP local header)
|
||||
byte[] sfx = new byte[0x80];
|
||||
sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header
|
||||
sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04
|
||||
Files.write(dir.resolve("SFX.EXE"), sfx);
|
||||
|
||||
// Real game executable
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(2000));
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertNotNull(main);
|
||||
assertTrue(main.endsWith("GAME.EXE"),
|
||||
"Self-extractor with PK signature bytes should be skipped, but got: " + main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMain_handlesMixedContent(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("readme.txt"), "text");
|
||||
Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped
|
||||
Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized
|
||||
Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win
|
||||
Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com
|
||||
|
||||
String main = detector.findMain(dir);
|
||||
assertNotNull(main);
|
||||
assertTrue(main.endsWith("GAME.EXE"),
|
||||
"Main game executable should be selected, but got: " + main);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception {
|
||||
assertTrue(detector.findAll(dir).isEmpty());
|
||||
}
|
||||
|
||||
private static byte[] createMZ(int size) {
|
||||
byte[] buf = new byte[size];
|
||||
buf[0] = 0x4D; // M
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.dostalgia;
|
||||
package com.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -1,94 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FileUtilsTest {
|
||||
|
||||
@Test
|
||||
void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) {
|
||||
Path nonExistent = dir.resolve("doesnotexist");
|
||||
assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("file1.txt"), "hello");
|
||||
Files.writeString(dir.resolve("file2.txt"), "world");
|
||||
Path sub = Files.createDirectories(dir.resolve("sub"));
|
||||
Files.writeString(sub.resolve("nested.txt"), "deep");
|
||||
|
||||
assertTrue(Files.exists(dir.resolve("file1.txt")));
|
||||
FileUtils.deleteDirectory(dir);
|
||||
assertFalse(Files.exists(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception {
|
||||
Path a = Files.createDirectories(dir.resolve("a/b/c"));
|
||||
Files.writeString(a.resolve("deep.txt"), "deep");
|
||||
Path sub2 = Files.createDirectories(dir.resolve("sub2"));
|
||||
Files.writeString(sub2.resolve("file.txt"), "data");
|
||||
|
||||
FileUtils.deleteDirectory(dir);
|
||||
assertFalse(Files.exists(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("game.exe"), "data");
|
||||
Files.writeString(dir.resolve("helper.com"), "data");
|
||||
Files.writeString(dir.resolve("run.bat"), "data");
|
||||
Files.writeString(dir.resolve("readme.txt"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
assertEquals(3, result.size());
|
||||
assertTrue(result.contains("game.exe"));
|
||||
assertTrue(result.contains("helper.com"));
|
||||
assertTrue(result.contains("run.bat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("GAME.EXE"), "data");
|
||||
Files.writeString(dir.resolve("Helper.Com"), "data");
|
||||
Files.writeString(dir.resolve("RUN.BAT"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
assertEquals(3, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception {
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("readme.txt"), "data");
|
||||
Files.writeString(dir.resolve("notes.md"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception {
|
||||
Path sub = Files.createDirectories(dir.resolve("subdir"));
|
||||
Files.writeString(sub.resolve("game.exe"), "data");
|
||||
Files.writeString(dir.resolve("root.exe"), "data");
|
||||
|
||||
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
|
||||
assertEquals(2, result.size());
|
||||
assertTrue(result.contains("root.exe"));
|
||||
assertTrue(result.contains("subdir/game.exe"));
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GameServiceTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
// -- sanitizeId tests --
|
||||
|
||||
@Test
|
||||
void sanitizeId_normalString_kept() {
|
||||
assertEquals("hello", GameService.sanitizeId("hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_uppercaseToLowercase() {
|
||||
assertEquals("hello", GameService.sanitizeId("HELLO"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_spacesToHyphens() {
|
||||
assertEquals("hello-world", GameService.sanitizeId("hello world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_specialCharsStripped() {
|
||||
assertEquals("helloworld", GameService.sanitizeId("hello!@#$world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_tripleHyphenStripped() {
|
||||
assertEquals("test", GameService.sanitizeId("---test---"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_emptyReturnsUnknown() {
|
||||
assertEquals("unknown", GameService.sanitizeId(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_onlySpecialCharsReturnsUnknown() {
|
||||
assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_mixedCaseAndSpaces() {
|
||||
assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_underscoresToHyphens() {
|
||||
assertEquals("my-game", GameService.sanitizeId("my_game"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeId_numbersPreserved() {
|
||||
assertEquals("game2", GameService.sanitizeId("Game2"));
|
||||
}
|
||||
|
||||
// -- gameDir / gameJsonPath tests --
|
||||
|
||||
@Test
|
||||
void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Path dir = svc.gameDir("test-game");
|
||||
assertEquals(tempDir.resolve("games/test-game"), dir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Path path = svc.gameJsonPath("test-game");
|
||||
assertEquals(tempDir.resolve("games/test-game/game.json"), path);
|
||||
}
|
||||
|
||||
// -- init tests --
|
||||
|
||||
@Test
|
||||
void init_createsDirectories(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertTrue(Files.isDirectory(tempDir.resolve("games")));
|
||||
assertTrue(Files.isDirectory(tempDir.resolve("artwork")));
|
||||
assertTrue(Files.isDirectory(tempDir.resolve("saves")));
|
||||
}
|
||||
|
||||
// -- save / load tests --
|
||||
|
||||
@Test
|
||||
void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Game game = new Game("test-game", "Test Game");
|
||||
game.setYear(1995);
|
||||
game.setGenre("FPS");
|
||||
svc.save(game);
|
||||
|
||||
Game loaded = svc.load("test-game");
|
||||
assertEquals("test-game", loaded.getId());
|
||||
assertEquals("Test Game", loaded.getTitle());
|
||||
assertEquals(1995, loaded.getYear());
|
||||
assertEquals("FPS", loaded.getGenre());
|
||||
assertNotNull(loaded.getCreatedAt());
|
||||
assertNotNull(loaded.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
Game game = new Game("game1", "Game One");
|
||||
svc.save(game);
|
||||
Thread.sleep(10); // ensure timestamp advances
|
||||
|
||||
game.setGenre("Adventure");
|
||||
svc.save(game);
|
||||
|
||||
Game loaded = svc.load("game1");
|
||||
assertNotNull(loaded.getUpdatedAt());
|
||||
assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt())
|
||||
|| loaded.getUpdatedAt().equals(loaded.getCreatedAt()),
|
||||
"updatedAt should be >= createdAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent"));
|
||||
}
|
||||
|
||||
// -- list tests --
|
||||
|
||||
@Test
|
||||
void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
List<Game> games = svc.list();
|
||||
assertTrue(games.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("game1", "Game One"));
|
||||
svc.save(new Game("game2", "Game Two"));
|
||||
|
||||
List<Game> games = svc.list();
|
||||
assertEquals(2, games.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_sortsByTitle(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("zzz", "Z Game"));
|
||||
svc.save(new Game("aaa", "A Game"));
|
||||
|
||||
List<Game> games = svc.list();
|
||||
assertEquals(2, games.size());
|
||||
assertEquals("A Game", games.get(0).getTitle());
|
||||
assertEquals("Z Game", games.get(1).getTitle());
|
||||
}
|
||||
|
||||
// -- delete tests --
|
||||
|
||||
@Test
|
||||
void delete_removesGameDir(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("game1", "Game One"));
|
||||
assertTrue(Files.exists(svc.gameDir("game1")));
|
||||
|
||||
svc.delete("game1");
|
||||
assertFalse(Files.exists(svc.gameDir("game1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertDoesNotThrow(() -> svc.delete("nonexistent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
// Create backward-compat files that delete should clean up
|
||||
Files.writeString(tempDir.resolve("games/game1.jsdos"), "old");
|
||||
Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup");
|
||||
|
||||
svc.delete("game1");
|
||||
|
||||
assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos")));
|
||||
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
|
||||
}
|
||||
|
||||
// -- JSON serialization tests --
|
||||
|
||||
@Test
|
||||
void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception {
|
||||
Game g = new Game("my-game", "My Game");
|
||||
g.setIgdbCoverId("abc123");
|
||||
g.setHasCover(true);
|
||||
g.setHasScreenshots(false);
|
||||
g.setIgdbId(42);
|
||||
g.setBundleSize(1024L);
|
||||
g.setSetupExe("setup.exe");
|
||||
|
||||
String json = MAPPER.writeValueAsString(g);
|
||||
assertTrue(json.contains("\"igdb_cover_id\""));
|
||||
assertTrue(json.contains("\"has_cover\""));
|
||||
assertTrue(json.contains("\"has_screenshots\""));
|
||||
assertTrue(json.contains("\"bundle_size\""));
|
||||
assertTrue(json.contains("\"setup_exe\""));
|
||||
assertFalse(json.contains("igdbCoverId"));
|
||||
assertFalse(json.contains("hasCover"));
|
||||
assertFalse(json.contains("hasScreenshots"));
|
||||
assertFalse(json.contains("bundleSize"));
|
||||
assertFalse(json.contains("setupExe"));
|
||||
|
||||
Game deserialized = MAPPER.readValue(json, Game.class);
|
||||
assertEquals("my-game", deserialized.getId());
|
||||
assertEquals("My Game", deserialized.getTitle());
|
||||
assertEquals("abc123", deserialized.getIgdbCoverId());
|
||||
assertTrue(deserialized.isHasCover());
|
||||
assertFalse(deserialized.isHasScreenshots());
|
||||
assertEquals(Integer.valueOf(42), deserialized.getIgdbId());
|
||||
assertEquals(Long.valueOf(1024L), deserialized.getBundleSize());
|
||||
assertEquals("setup.exe", deserialized.getSetupExe());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception {
|
||||
Game g = new Game("ts-game", "Timestamp Test");
|
||||
String json = MAPPER.writeValueAsString(g);
|
||||
|
||||
assertTrue(json.contains("\"created_at\""));
|
||||
assertTrue(json.contains("\"updated_at\""));
|
||||
|
||||
Game deserialized = MAPPER.readValue(json, Game.class);
|
||||
assertNotNull(deserialized.getCreatedAt());
|
||||
assertNotNull(deserialized.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GameTest {
|
||||
|
||||
@Test
|
||||
void noArgConstructor_setsNothing() {
|
||||
Game g = new Game();
|
||||
assertNull(g.getId());
|
||||
assertNull(g.getTitle());
|
||||
assertNull(g.getYear());
|
||||
assertNull(g.getGenre());
|
||||
assertNull(g.getDeveloper());
|
||||
assertNull(g.getPublisher());
|
||||
assertNull(g.getDescription());
|
||||
assertNull(g.getRating());
|
||||
assertNull(g.getBundleFile());
|
||||
assertNull(g.getSetupExe());
|
||||
assertNull(g.getBundleSize());
|
||||
assertNull(g.getExecutable());
|
||||
assertNull(g.getExecutables());
|
||||
assertNull(g.getPlatform());
|
||||
assertNull(g.getIgdbId());
|
||||
assertNull(g.getIgdbCoverId());
|
||||
assertFalse(g.isHasCover());
|
||||
assertFalse(g.isHasScreenshots());
|
||||
assertNull(g.getScreenshots());
|
||||
assertNull(g.getVideos());
|
||||
assertFalse(g.isReady());
|
||||
assertNull(g.getCreatedAt());
|
||||
assertNull(g.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoArgConstructor_setsIdTitleAndTimestamps() {
|
||||
Game g = new Game("test-id", "Test Game");
|
||||
assertEquals("test-id", g.getId());
|
||||
assertEquals("Test Game", g.getTitle());
|
||||
assertFalse(g.isReady());
|
||||
assertNotNull(g.getCreatedAt());
|
||||
assertNotNull(g.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersAndGetters_roundTrip() {
|
||||
Game g = new Game();
|
||||
g.setId("id1");
|
||||
g.setTitle("Title");
|
||||
g.setYear(1995);
|
||||
g.setGenre("Action");
|
||||
g.setDeveloper("DevCo");
|
||||
g.setPublisher("PubCo");
|
||||
g.setDescription("A great game");
|
||||
g.setRating(4.5);
|
||||
g.setBundleFile("game.zip");
|
||||
g.setSetupExe("setup.exe");
|
||||
g.setBundleSize(12345L);
|
||||
g.setExecutable("game.exe");
|
||||
g.setExecutables(List.of("game.exe", "setup.exe"));
|
||||
g.setPlatform("dos");
|
||||
g.setIgdbId(42);
|
||||
g.setIgdbCoverId("abc123");
|
||||
g.setHasCover(true);
|
||||
g.setHasScreenshots(true);
|
||||
g.setScreenshots(List.of("shot1.png"));
|
||||
g.setVideos(List.of("vid1"));
|
||||
g.setReady(true);
|
||||
Instant now = Instant.now();
|
||||
g.setCreatedAt(now);
|
||||
g.setUpdatedAt(now);
|
||||
|
||||
assertEquals("id1", g.getId());
|
||||
assertEquals("Title", g.getTitle());
|
||||
assertEquals(1995, g.getYear());
|
||||
assertEquals("Action", g.getGenre());
|
||||
assertEquals("DevCo", g.getDeveloper());
|
||||
assertEquals("PubCo", g.getPublisher());
|
||||
assertEquals("A great game", g.getDescription());
|
||||
assertEquals(4.5, g.getRating());
|
||||
assertEquals("game.zip", g.getBundleFile());
|
||||
assertEquals("setup.exe", g.getSetupExe());
|
||||
assertEquals(12345L, g.getBundleSize());
|
||||
assertEquals("game.exe", g.getExecutable());
|
||||
assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables());
|
||||
assertEquals("dos", g.getPlatform());
|
||||
assertEquals(42, g.getIgdbId());
|
||||
assertEquals("abc123", g.getIgdbCoverId());
|
||||
assertTrue(g.isHasCover());
|
||||
assertTrue(g.isHasScreenshots());
|
||||
assertEquals(List.of("shot1.png"), g.getScreenshots());
|
||||
assertEquals(List.of("vid1"), g.getVideos());
|
||||
assertTrue(g.isReady());
|
||||
assertEquals(now, g.getCreatedAt());
|
||||
assertEquals(now, g.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHasSetup_nullSetupExe_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setSetupExe(null);
|
||||
assertFalse(g.isHasSetup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHasSetup_blankSetupExe_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setSetupExe(" ");
|
||||
assertFalse(g.isHasSetup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHasSetup_nonBlankSetupExe_returnsTrue() {
|
||||
Game g = new Game();
|
||||
g.setSetupExe("setup.exe");
|
||||
assertTrue(g.isHasSetup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_notReady_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setReady(false);
|
||||
assertFalse(g.isPlayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_readyNullPlatform_returnsTrue() {
|
||||
Game g = new Game();
|
||||
g.setReady(true);
|
||||
g.setPlatform(null);
|
||||
assertTrue(g.isPlayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_readyDosPlatform_returnsTrue() {
|
||||
Game g = new Game();
|
||||
g.setReady(true);
|
||||
g.setPlatform("dos");
|
||||
assertTrue(g.isPlayable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isPlayable_readyWindowsPlatform_returnsFalse() {
|
||||
Game g = new Game();
|
||||
g.setReady(true);
|
||||
g.setPlatform("windows");
|
||||
assertFalse(g.isPlayable());
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class IgdbServiceTest {
|
||||
|
||||
private IgdbService service;
|
||||
private ObjectMapper mapper;
|
||||
|
||||
private Method epochToYearMethod;
|
||||
private Method extractGenresMethod;
|
||||
private Method sanitizeMethod;
|
||||
private Method jsonNodeToMapMethod;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() throws Exception {
|
||||
service = new IgdbService();
|
||||
service.clientId = Optional.of("test-client-id");
|
||||
service.clientSecret = Optional.of("test-client-secret");
|
||||
mapper = new ObjectMapper();
|
||||
|
||||
epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class);
|
||||
epochToYearMethod.setAccessible(true);
|
||||
|
||||
extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class);
|
||||
extractGenresMethod.setAccessible(true);
|
||||
|
||||
sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class);
|
||||
sanitizeMethod.setAccessible(true);
|
||||
|
||||
jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class);
|
||||
jsonNodeToMapMethod.setAccessible(true);
|
||||
}
|
||||
|
||||
// -- isConfigured tests --
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsTrueWhenBothPresentAndNonBlank() {
|
||||
assertTrue(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientIdEmpty() {
|
||||
service.clientId = Optional.empty();
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientSecretEmpty() {
|
||||
service.clientSecret = Optional.empty();
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientIdBlank() {
|
||||
service.clientId = Optional.of("");
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isConfigured_returnsFalseWhenClientSecretBlank() {
|
||||
service.clientSecret = Optional.of("");
|
||||
assertFalse(service.isConfigured());
|
||||
}
|
||||
|
||||
// -- epochToYear tests --
|
||||
|
||||
@Test
|
||||
void epochToYear_withReleaseDate_returnsYear() throws Exception {
|
||||
// 946684800 = 2000-01-01T00:00:00Z
|
||||
JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertTrue(year.isPresent());
|
||||
int y = year.get();
|
||||
assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y);
|
||||
}
|
||||
|
||||
@Test
|
||||
void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertFalse(year.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void epochToYear_withEpochZero_returnsYear() throws Exception {
|
||||
// epoch 0 = 1970-01-01T00:00:00Z
|
||||
JsonNode node = mapper.readTree("{\"first_release_date\": 0}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertTrue(year.isPresent());
|
||||
assertEquals(1970, year.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void epochToYear_negativeEpoch_returnsYear() throws Exception {
|
||||
// negative epoch is before 1970
|
||||
JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}");
|
||||
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
|
||||
assertTrue(year.isPresent());
|
||||
// Calendar may handle negative values differently; just verify it returns something
|
||||
assertNotNull(year.get());
|
||||
}
|
||||
|
||||
// -- extractGenres tests --
|
||||
|
||||
@Test
|
||||
void extractGenres_withGenresArray_returnsList() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertEquals(2, genres.size());
|
||||
assertTrue(genres.contains("Action"));
|
||||
assertTrue(genres.contains("Adventure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractGenres_emptyArray_returnsEmptyList() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"genres\": []}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertTrue(genres.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractGenres_missingField_returnsEmptyList() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"name\": \"Test\"}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertTrue(genres.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractGenres_genreWithoutName_skipsEntry() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}");
|
||||
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
|
||||
assertEquals(1, genres.size());
|
||||
assertEquals("Action", genres.get(0));
|
||||
}
|
||||
|
||||
// -- sanitize tests --
|
||||
|
||||
@Test
|
||||
void sanitize_escapesBackslashes() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "path\\to\\file");
|
||||
assertEquals("path\\\\to\\\\file", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_escapesQuotes() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "say \"hello\"");
|
||||
assertEquals("say \\\"hello\\\"", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_escapesBothBackslashesAndQuotes() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\"");
|
||||
assertEquals("\\\\\\\"mixed\\\\\\\"", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_normalString_unchanged() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "hello world");
|
||||
assertEquals("hello world", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitize_emptyString_unchanged() throws Exception {
|
||||
String result = (String) sanitizeMethod.invoke(service, "");
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
// -- jsonNodeToMap tests --
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_basicFields() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals(123, map.get("igdb_id"));
|
||||
assertEquals("Test Game", map.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withGenres() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertTrue(map.containsKey("genres"));
|
||||
assertEquals(List.of("RPG"), map.get("genres"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withInvolvedCompanies() throws Exception {
|
||||
JsonNode node = mapper.readTree("""
|
||||
{
|
||||
"id": 1,
|
||||
"name": "G",
|
||||
"involved_companies": [
|
||||
{ "company": { "name": "DevStudio" }, "developer": true, "publisher": false },
|
||||
{ "company": { "name": "PubStudio" }, "developer": false, "publisher": true }
|
||||
]
|
||||
}
|
||||
""");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals("DevStudio", map.get("developer"));
|
||||
assertEquals("PubStudio", map.get("publisher"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withSummary() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals("A great game", map.get("summary"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_withCoverUrl() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
String coverUrl = (String) map.get("cover_url");
|
||||
assertNotNull(coverUrl);
|
||||
assertTrue(coverUrl.startsWith("https:"));
|
||||
assertTrue(coverUrl.contains("t_cover_big"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception {
|
||||
JsonNode node = mapper.readTree("{}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals(0, map.get("igdb_id"));
|
||||
assertEquals("", map.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception {
|
||||
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertFalse(map.containsKey("developer"));
|
||||
assertFalse(map.containsKey("publisher"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonNodeToMap_firstDevAndPubOnly() throws Exception {
|
||||
// Multiple developers/publishers — only first of each should be recorded
|
||||
JsonNode node = mapper.readTree("""
|
||||
{
|
||||
"id": 1, "name": "G",
|
||||
"involved_companies": [
|
||||
{ "company": { "name": "FirstDev" }, "developer": true, "publisher": false },
|
||||
{ "company": { "name": "SecondDev" }, "developer": true, "publisher": false },
|
||||
{ "company": { "name": "FirstPub" }, "developer": false, "publisher": true }
|
||||
]
|
||||
}
|
||||
""");
|
||||
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
|
||||
assertEquals("FirstDev", map.get("developer"));
|
||||
assertEquals("FirstPub", map.get("publisher"));
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StaticResourceTest {
|
||||
|
||||
@Test
|
||||
void health_returnsOkStatusAndVersion(@TempDir Path tempDir) {
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.health();
|
||||
assertEquals(200, resp.getStatus());
|
||||
Object entity = resp.getEntity();
|
||||
assertInstanceOf(Map.class, entity);
|
||||
Map<?, ?> map = (Map<?, ?>) entity;
|
||||
assertEquals("ok", map.get("status"));
|
||||
assertEquals("0.1.1", map.get("version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("test.jsdos"), "zip content");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("test.jsdos");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/zip", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("archive.zip"), "zip content");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("archive.zip");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/zip", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("cover.jpg"), "image data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("cover.jpg");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("photo.jpeg"), "image data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("photo.jpeg");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("screenshot.png"), "png data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("screenshot.png");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/png", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("image.webp"), "webp data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("image.webp");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/webp", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("anim.gif"), "gif data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("anim.gif");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/gif", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("data.json"), "{}");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("data.json");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/json", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception {
|
||||
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
|
||||
Files.writeString(gamesDir.resolve("game.exe"), "MZ");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("game.exe");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("application/octet-stream", resp.getMediaType().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) {
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("../etc/passwd");
|
||||
assertEquals(403, resp.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("games"));
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getGameFile("nonexistent.exe");
|
||||
assertEquals(404, resp.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception {
|
||||
Path artworkDir = Files.createDirectories(tempDir.resolve("artwork"));
|
||||
Files.writeString(artworkDir.resolve("cover.jpg"), "image data");
|
||||
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getArtwork("cover.jpg");
|
||||
assertEquals(200, resp.getStatus());
|
||||
assertNotNull(resp.getMediaType());
|
||||
assertEquals("image/jpeg", resp.getMediaType().toString());
|
||||
assertTrue(resp.getHeaders().containsKey("Cache-Control"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) {
|
||||
StaticResource sr = new StaticResource();
|
||||
sr.dataDir = tempDir.toString();
|
||||
|
||||
Response resp = sr.getArtwork("../../etc/passwd");
|
||||
assertEquals(403, resp.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package org.dostalgia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ZipServiceTest {
|
||||
|
||||
private final ZipService zip = new ZipService();
|
||||
|
||||
// Wire the config dependency manually (no CDI)
|
||||
{
|
||||
zip.config = new ConfigBuilder();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_extractsFiles(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("game.exe"));
|
||||
zos.write("MZ".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("readme.txt"));
|
||||
zos.write("Hello".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
assertTrue(Files.exists(dest.resolve("game.exe")));
|
||||
assertTrue(Files.exists(dest.resolve("readme.txt")));
|
||||
assertEquals("MZ", Files.readString(dest.resolve("game.exe")));
|
||||
assertEquals("Hello", Files.readString(dest.resolve("readme.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("subdir/game.exe"));
|
||||
zos.write("MZ".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt"));
|
||||
zos.write("deep".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
assertTrue(Files.exists(dest.resolve("subdir/game.exe")));
|
||||
assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
Files.createDirectories(dest);
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("../evil.txt"));
|
||||
zos.write("malicious".getBytes());
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("good.txt"));
|
||||
zos.write("good".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
// Malicious entry should be skipped (path traversal)
|
||||
assertFalse(Files.exists(dir.resolve("evil.txt")));
|
||||
// Good entry should be extracted
|
||||
assertTrue(Files.exists(dest.resolve("good.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception {
|
||||
Path zipFile = dir.resolve("test.zip");
|
||||
Path dest = dir.resolve("output");
|
||||
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
zos.putNextEntry(new ZipEntry("adir/"));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("adir/file.txt"));
|
||||
zos.write("data".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
zip.unzip(zipFile, dest);
|
||||
assertTrue(Files.isDirectory(dest.resolve("adir")));
|
||||
assertTrue(Files.exists(dest.resolve("adir/file.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception {
|
||||
Files.createDirectories(dir.resolve("sub1"));
|
||||
Files.createDirectories(dir.resolve("sub2"));
|
||||
Files.writeString(dir.resolve("sub1/file.txt"), "data");
|
||||
|
||||
zip.flattenSingleDir(dir);
|
||||
assertTrue(Files.exists(dir.resolve("sub1/file.txt")));
|
||||
assertTrue(Files.isDirectory(dir.resolve("sub1")));
|
||||
assertTrue(Files.isDirectory(dir.resolve("sub2")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("file.txt"), "data");
|
||||
Files.writeString(dir.resolve("other.txt"), "data");
|
||||
|
||||
zip.flattenSingleDir(dir);
|
||||
assertTrue(Files.exists(dir.resolve("file.txt")));
|
||||
assertTrue(Files.exists(dir.resolve("other.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception {
|
||||
Path single = Files.createDirectories(dir.resolve("single"));
|
||||
Files.createDirectories(single.resolve("nested"));
|
||||
Files.writeString(single.resolve("file.txt"), "data");
|
||||
Files.writeString(single.resolve("nested/deep.txt"), "deep");
|
||||
|
||||
zip.flattenSingleDir(dir);
|
||||
// Files should now be directly under dir
|
||||
assertTrue(Files.exists(dir.resolve("file.txt")));
|
||||
assertTrue(Files.exists(dir.resolve("nested/deep.txt")));
|
||||
// The intermediate single dir should be gone
|
||||
assertFalse(Files.exists(single));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception {
|
||||
zip.flattenSingleDir(dir);
|
||||
assertTrue(Files.exists(dir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception {
|
||||
Path extractDir = dir.resolve("extract");
|
||||
Files.createDirectories(extractDir.resolve("sub"));
|
||||
Files.writeString(extractDir.resolve("game.exe"), "MZ");
|
||||
Files.writeString(extractDir.resolve("sub/data.bin"), "binary");
|
||||
Files.writeString(extractDir.resolve("readme.txt"), "info");
|
||||
|
||||
Path bundlePath = dir.resolve("output.jsdos");
|
||||
|
||||
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
|
||||
|
||||
assertTrue(Files.exists(bundlePath));
|
||||
assertTrue(Files.size(bundlePath) > 0);
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
boolean hasGameExe = false;
|
||||
boolean hasJsdosDir = false;
|
||||
boolean hasJsdosConf = false;
|
||||
boolean hasJsdosJson = false;
|
||||
boolean hasDataBin = false;
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if (name.equals("game.exe")) hasGameExe = true;
|
||||
if (name.startsWith(".jsdos/")) hasJsdosDir = true;
|
||||
if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
|
||||
if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true;
|
||||
if (name.equals("sub/data.bin")) hasDataBin = true;
|
||||
zis.closeEntry();
|
||||
}
|
||||
assertTrue(hasGameExe, "Should contain game.exe");
|
||||
assertTrue(hasJsdosDir, "Should contain .jsdos/ entries");
|
||||
assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf");
|
||||
assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json");
|
||||
assertTrue(hasDataBin, "Should contain sub/data.bin");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception {
|
||||
Path extractDir = dir.resolve("extract");
|
||||
Files.createDirectories(extractDir);
|
||||
Files.writeString(extractDir.resolve("game.exe"), "MZ");
|
||||
Files.writeString(extractDir.resolve("cdimage.nrg"), "image");
|
||||
Files.writeString(extractDir.resolve("cdimage.mdf"), "image");
|
||||
|
||||
Path bundlePath = dir.resolve("output.jsdos");
|
||||
|
||||
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
assertNotEquals("cdimage.nrg", name, "Should not contain .nrg");
|
||||
assertNotEquals("cdimage.mdf", name, "Should not contain .mdf");
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception {
|
||||
Path extractDir = dir.resolve("extract");
|
||||
Files.createDirectories(extractDir);
|
||||
Files.writeString(extractDir.resolve("somefile.bin"), "data");
|
||||
|
||||
Path bundlePath = dir.resolve("output.jsdos");
|
||||
|
||||
// null exePath triggers CD-only config generation
|
||||
zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath);
|
||||
|
||||
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
boolean hasJsdosConf = false;
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
|
||||
zis.closeEntry();
|
||||
}
|
||||
assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user