Initial commit: Dostalgia — DOS game hub

- Go backend (single binary) with embedded Svelte frontend
- JSON-per-game storage (no database)
- .jsdos bundle creation on upload
- Dark phosphor-green theme (#33FF33)
- js-dos v8 emulator integration
- Sockdrive support for games >80MB
- IGDB placeholder for future artwork scraping
- Docker deployment ready
This commit is contained in:
David Alvarez
2026-05-23 11:18:41 +02:00
parent c3c154ec65
commit 1efd10f81f
24 changed files with 3314 additions and 2 deletions
+19
View File
@@ -0,0 +1,19 @@
# Dependencies
node_modules/
frontend/dist/
frontend/node_modules/
# Runtime data
data/games/*
data/artwork/*
data/saves/*
!data/games/.gitkeep
!data/artwork/.gitkeep
!data/saves/.gitkeep
# Binary
dostalgia
# IDE
.vscode/
.idea/
+28
View File
@@ -0,0 +1,28 @@
# Dostalgia — Dockerized DOS game hub
# ─── Build frontend ───────────────────────────────
FROM node:20-alpine AS frontend-builder
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY frontend/ ./
RUN npm run build
# ─── Build Go binary ─────────────────────────────
FROM golang:1.23-alpine AS go-builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download 2>/dev/null || true
COPY *.go ./
COPY --from=frontend-builder /src/dist ./frontend/dist/
RUN CGO_ENABLED=0 go build -o /dostalgia .
# ─── Runtime ─────────────────────────────────────
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=go-builder /dostalgia /dostalgia
VOLUME /data
EXPOSE 8765
ENV DOSTALGIA_DATA=/data
ENV PORT=8765
ENTRYPOINT ["/dostalgia"]
+60 -2
View File
@@ -1,3 +1,61 @@
# dostalgia
# Dostalgia
A nostalgic DOS game hub — upload, scrape artwork, and play your classic games in the browser
A nostalgic DOS game hub — upload, scrape artwork, and play classic DOS games directly in the browser via js-dos.
## Quick Start (Docker)
```bash
docker build -t dostalgia .
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
```
## Development
Terminal 1 — Go API:
```bash
DOSTALGIA_DEV=1 go run .
```
Terminal 2 — Frontend dev server:
```bash
cd frontend && npm install && npm run dev
```
Open http://localhost:5173
## Architecture
- **Backend**: Single Go binary. No database — each game is a directory with a `game.json` file.
- **Frontend**: Svelte SPA, embedded in the Go binary at build time.
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly.
- **Storage**: `/data/games/{id}/` — contains `game.json`, `.jsdos` bundle, and cover art.
## 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",
"publisher": "id Software",
"description": "The iconic first-person shooter...",
"bundle_type": "standard",
"bundle_file": "doom.jsdos",
"has_cover": true,
"ready": true
}
```
+15
View File
@@ -0,0 +1,15 @@
services:
dostalgia:
build: .
container_name: dostalgia
ports:
- "8765:8765"
volumes:
- ./data:/data
environment:
- DOSTALGIA_DATA=/data
- PORT=8765
# IGDB credentials (optional):
# - TWITCH_CLIENT_ID=your_client_id
# - TWITCH_CLIENT_SECRET=your_client_secret
restart: unless-stopped
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1216
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "dostalgia-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"vite": "^5.0.0"
}
}
+63
View File
@@ -0,0 +1,63 @@
<script>
import "./app.css";
import Header from "./lib/Header.svelte";
import Library from "./pages/Library.svelte";
import GameDetail from "./pages/GameDetail.svelte";
import Play from "./pages/Play.svelte";
let route = $state("/");
let gameId = $state(null);
function parseHash() {
const hash = window.location.hash.slice(1) || "/";
const parts = hash.split("/").filter(Boolean);
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
route = "/";
gameId = null;
} else if (parts[0] === "game" && parts[1]) {
route = "/game";
gameId = parts[1];
} else if (parts[0] === "play" && parts[1]) {
route = "/play";
gameId = parts[1];
} else {
route = "/";
gameId = null;
}
}
// Listen for hash changes
$effect(() => {
parseHash();
const handler = () => parseHash();
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
});
</script>
<Header />
<main class="container">
{#if route === "/"}
<Library />
{:else if route === "/game" && gameId}
<GameDetail id={gameId} />
{:else if route === "/play" && gameId}
<Play id={gameId} />
{:else}
<div class="empty">
<h2>Page not found</h2>
<a href="#/">Back to Library</a>
</div>
{/if}
</main>
<style>
.empty {
text-align: center;
padding: 80px 20px;
}
.empty h2 {
margin-bottom: 16px;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
/* ═══════════════════════════════════════════════════════════════
DOSTALGIA — Phosphor Dark Theme
Inspired by classic green-screen CRT monitors (IBM 5151)
═══════════════════════════════════════════════════════════════ */
:root {
/* Phosphor greens — from brightest glow to darkest trace */
--phosphor: #33ff33;
--phosphor-glow: #66ff66;
--phosphor-dim: #22aa22;
--phosphor-dark: #116611;
--phosphor-burn: #0a330a;
/* Backgrounds */
--bg: #0a0a0a;
--bg-elevated: #111111;
--surface: #1a1a1a;
--surface-hover: #222222;
--surface-active: #2a2a2a;
/* Borders */
--border: #2a2a2a;
--border-glow: #33ff3340;
/* Text */
--text: #cccccc;
--text-bright: #e8e8e8;
--text-dim: #777777;
/* Sizing */
--max-width: 1200px;
--header-height: 64px;
--radius: 8px;
--radius-sm: 4px;
/* Typography */
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
/* ═══════════════════════════════════════════════════════════════
RESET & BASE
═══════════════════════════════════════════════════════════════ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
a {
color: var(--phosphor);
text-decoration: none;
}
a:hover {
color: var(--phosphor-glow);
text-shadow: 0 0 8px var(--phosphor-glow);
}
img {
max-width: 100%;
display: block;
}
/* ═══════════════════════════════════════════════════════════════
TYPOGRAPHY
═══════════════════════════════════════════════════════════════ */
h1, h2, h3, h4 {
color: var(--text-bright);
font-weight: 700;
line-height: 1.3;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.2rem; }
/* ═══════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: 1px solid var(--phosphor-dim);
border-radius: var(--radius-sm);
background: transparent;
color: var(--phosphor);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:hover {
background: var(--phosphor-burn);
border-color: var(--phosphor);
box-shadow: 0 0 12px var(--phosphor-dark);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--phosphor-dark);
border-color: var(--phosphor);
color: var(--phosphor-glow);
}
.btn-primary:hover {
background: var(--phosphor-dim);
color: var(--bg);
}
.btn-danger {
border-color: #cc3333;
color: #cc3333;
}
.btn-danger:hover {
background: #331111;
border-color: #ff4444;
color: #ff4444;
box-shadow: 0 0 12px #331111;
}
/* ═══════════════════════════════════════════════════════════════
INPUTS
═══════════════════════════════════════════════════════════════ */
input,
textarea,
select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 10px 14px;
font-family: var(--font-sans);
font-size: 0.9rem;
transition: border-color 0.15s;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--phosphor-dim);
box-shadow: 0 0 0 3px var(--border-glow);
}
input::placeholder {
color: var(--text-dim);
}
/* ═══════════════════════════════════════════════════════════════
LAYOUT UTILITIES
═══════════════════════════════════════════════════════════════ */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 24px;
}
/* ═══════════════════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════════════════ */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--surface-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--phosphor-dark);
}
/* ═══════════════════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════════════════ */
@keyframes scanline {
0% { transform: translateY(0); }
100% { transform: translateY(4px); }
}
@keyframes flicker {
0%, 100% { opacity: 1; }
50% { opacity: 0.97; }
}
@keyframes phosphor-pulse {
0%, 100% { text-shadow: 0 0 4px var(--phosphor-dim); }
50% { text-shadow: 0 0 12px var(--phosphor), 0 0 24px var(--phosphor-dark); }
}
/* Subtle CRT effect on hover cards */
.crt-hover {
position: relative;
}
.crt-hover::after {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(51, 255, 51, 0.015) 2px,
rgba(51, 255, 51, 0.015) 4px
);
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.crt-hover:hover::after {
opacity: 1;
}
+108
View File
@@ -0,0 +1,108 @@
<script>
import { push } from "../lib/router.js";
let { game } = $props();
function navigate() {
push(`/game/${game.id}`);
}
</script>
<button class="card crt-hover" onclick={navigate}>
<div class="cover">
{#if game.has_cover}
<img src={`/games/${game.id}/cover.jpg`} alt={game.title} loading="lazy" />
{:else}
<div class="cover-placeholder">
<span class="cover-icon">💾</span>
</div>
{/if}
</div>
<div class="info">
<h3>{game.title}</h3>
{#if game.year}
<span class="year">{game.year}</span>
{/if}
{#if game.genre}
<span class="genre">{game.genre}</span>
{/if}
</div>
{#if game.ready}
<div class="badge">Ready</div>
{/if}
</button>
<style>
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
position: relative;
width: 100%;
}
.card:hover {
border-color: var(--phosphor-dim);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(51, 255, 51, 0.1);
}
.cover {
aspect-ratio: 3/4;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--text-dim);
}
.cover-icon {
font-size: 2.5rem;
}
.info {
padding: 12px;
flex: 1;
}
.info h3 {
font-size: 0.95rem;
margin-bottom: 4px;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.year, .genre {
font-size: 0.8rem;
color: var(--text-dim);
display: inline-block;
margin-right: 8px;
}
.badge {
position: absolute;
top: 8px;
right: 8px;
background: var(--phosphor-dark);
color: var(--phosphor);
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.05em;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script>
import { push } from "./router.js";
</script>
<header class="header">
<div class="header-inner">
<a href="#/" class="logo" onclick={(e) => { e.preventDefault(); push("/"); }}>
<svg class="logo-icon" width="36" height="36" viewBox="0 0 36 36" fill="none">
<rect x="4" y="6" width="28" height="22" rx="2" fill="#33FF33" opacity="0.15"/>
<rect x="6" y="8" width="24" height="16" fill="#0A0A0A"/>
<rect x="8" y="10" width="20" height="10" fill="#33FF33" opacity="0.3"/>
<rect x="10" y="12" width="6" height="4" fill="#33FF33" opacity="0.6"/>
<rect x="18" y="12" width="6" height="4" fill="#33FF33" opacity="0.4"/>
<rect x="10" y="24" width="16" height="3" fill="#33FF33" opacity="0.2"/>
<rect x="12" y="29" width="12" height="2" fill="#33FF33" opacity="0.1"/>
</svg>
<span class="logo-text">DOSTALGIA</span>
</a>
<nav>
<button class="btn" onclick={() => push("/")}>Library</button>
</nav>
</div>
</header>
<style>
.header {
height: var(--header-height);
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 24px;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
text-decoration: none;
}
.logo-icon {
flex-shrink: 0;
}
.logo-text {
font-family: var(--font-mono);
font-size: 1.4rem;
font-weight: 700;
color: var(--phosphor);
letter-spacing: 0.15em;
animation: phosphor-pulse 3s ease-in-out infinite;
}
nav {
display: flex;
gap: 8px;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
/** API client for Dostalgia backend. */
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json", ...options.headers },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
/** Fetch game list */
export async function listGames(params = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.genre) qs.set("genre", params.genre);
if (params.limit) qs.set("limit", params.limit);
if (params.offset) qs.set("offset", params.offset);
return request(`/api/games?${qs}`);
}
/** Fetch single game */
export async function getGame(id) {
return request(`/api/games/${id}`);
}
/** Update game metadata */
export async function updateGame(id, data) {
return request(`/api/games/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
/** Delete a game */
export async function deleteGame(id) {
return request(`/api/games/${id}`, { method: "DELETE" });
}
/** Upload a game ZIP */
export async function uploadGame(file, title) {
const form = new FormData();
form.append("file", file);
if (title) form.append("title", title);
const res = await fetch(`${BASE}/api/upload`, {
method: "POST",
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
/** Search IGDB (placeholder) */
export async function searchIGDB(query) {
return request(`/api/igdb/search?query=${encodeURIComponent(query)}`);
}
/** Get artwork URL for a path — returns proxy URL or placeholder */
export function artworkUrl(path) {
if (!path) return null;
if (path.startsWith("http")) return path;
return `/artwork/${path}`;
}
/** Get game bundle URL */
export function bundleUrl(game) {
if (!game) return null;
if (game.bundle_type === "sockdrive") {
return `/api/sockdrive/${game.slug}/file?path=.jsdos/dosbox.conf`;
}
return `/games/${game.slug}.jsdos`;
}
+9
View File
@@ -0,0 +1,9 @@
/** Simple hash-based router for SPA. */
export function push(path) {
window.location.hash = "#" + path;
}
export function replace(path) {
window.location.replace("#" + path);
}
+8
View File
@@ -0,0 +1,8 @@
import App from "./App.svelte";
import { mount } from "svelte";
const app = mount(App, {
target: document.getElementById("app"),
});
export default app;
+162
View File
@@ -0,0 +1,162 @@
<script>
import { getGame, deleteGame, updateGame, bundleUrl, artworkUrl } from "../lib/api.js";
import { push } from "../lib/router.js";
let { id } = $props();
let game = $state(null);
let loading = $state(true);
let editing = $state(false);
let saving = $state(false);
let error = $state("");
let editTitle = $state("");
let editYear = $state(0);
let editGenre = $state("");
let editDeveloper = $state("");
let editPublisher = $state("");
let editDescription = $state("");
async function load() {
loading = true;
try {
game = await getGame(id);
} catch (e) {
error = "Game not found";
}
loading = false;
}
function startEdit() {
editTitle = game.title;
editYear = game.year || 0;
editGenre = game.genre || "";
editDeveloper = game.developer || "";
editPublisher = game.publisher || "";
editDescription = game.description || "";
editing = true;
}
async function saveEdit() {
saving = true;
try {
game = await updateGame(id, {
title: editTitle,
year: editYear || undefined,
genre: editGenre || undefined,
developer: editDeveloper || undefined,
publisher: editPublisher || undefined,
description: editDescription || undefined,
});
editing = false;
} catch (e) {
error = e.message;
}
saving = false;
}
async function handleDelete() {
if (!confirm(`Delete "${game.title}"? This cannot be undone.`)) return;
try {
await deleteGame(id);
push("/");
} catch (e) {
error = e.message;
}
}
function handlePlay() {
push(`/play/${id}`);
}
$effect(() => { load(); });
</script>
{#if loading}
<div class="loading">Loading...</div>
{:else if error && !game}
<div class="error">{error}</div>
<a href="#/" class="back-link">← Back to Library</a>
{:else if game}
<div class="detail">
<a href="#/" class="back-link">← Back to Library</a>
<div class="detail-layout">
<!-- Cover -->
<div class="cover-section">
{#if game.has_cover}
<img src={`/games/${game.id}/cover.jpg`} alt={game.title} class="cover-img" />
{:else}
<div class="cover-placeholder">💾</div>
{/if}
</div>
<!-- Info -->
<div class="info-section">
{#if editing}
<input type="text" bind:value={editTitle} placeholder="Title" class="edit-title" />
<div class="edit-row">
<input type="number" bind:value={editYear} placeholder="Year" style="width:100px" />
<input type="text" bind:value={editGenre} placeholder="Genre" style="flex:1" />
</div>
<div class="edit-row">
<input type="text" bind:value={editDeveloper} placeholder="Developer" style="flex:1" />
<input type="text" bind:value={editPublisher} placeholder="Publisher" style="flex:1" />
</div>
<textarea bind:value={editDescription} placeholder="Description" rows="4"></textarea>
<div class="edit-actions">
<button class="btn btn-primary" onclick={saveEdit} disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
<button class="btn" onclick={() => editing = false}>Cancel</button>
</div>
{:else}
<h1>{game.title}</h1>
{#if game.year || game.genre}
<div class="meta">
{#if game.year}<span class="meta-item">{game.year}</span>{/if}
{#if game.genre}<span class="meta-item">{game.genre}</span>{/if}
</div>
{/if}
{#if game.developer}
<p class="dev">by {game.developer}{game.publisher ? ` · ${game.publisher}` : ""}</p>
{/if}
{#if game.description}
<p class="description">{game.description}</p>
{/if}
<div class="actions">
{#if game.ready}
<button class="btn btn-primary" onclick={handlePlay}> Play</button>
{/if}
<button class="btn" onclick={startEdit}> Edit</button>
<button class="btn btn-danger" onclick={handleDelete}> Delete</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.detail { padding: 32px 0; }
.loading { text-align: center; padding: 60px; color: var(--text-dim); }
.error { background: #331111; border: 1px solid #cc3333; color: #ff6666; padding: 12px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.back-link { display: inline-block; margin-bottom: 24px; color: var(--text-dim); }
.back-link:hover { color: var(--phosphor); }
.detail-layout { display: grid; grid-template-columns: 300px 1fr; gap: 32px; align-items: start; }
@media (max-width: 640px) { .detail-layout { grid-template-columns: 1fr; } }
.cover-section { border-radius: var(--radius); overflow: hidden; }
.cover-img { width: 100%; border-radius: var(--radius); }
.cover-placeholder { aspect-ratio: 3/4; background: var(--surface); display: flex; align-items: center; justify-content: center; font-size: 4rem; border-radius: var(--radius); }
.info-section h1 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; }
.meta { display: flex; gap: 8px; margin-bottom: 8px; }
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
.dev { color: var(--text-dim); margin-bottom: 16px; }
.description { color: var(--text); line-height: 1.7; margin-bottom: 24px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.edit-title { font-size: 1.5rem; margin-bottom: 12px; width: 100%; }
.edit-row { display: flex; gap: 8px; margin-bottom: 8px; }
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
textarea { width: 100%; min-height: 100px; }
</style>
+165
View File
@@ -0,0 +1,165 @@
<script>
import { listGames, uploadGame } from "../lib/api.js";
import GameCard from "../lib/GameCard.svelte";
let games = $state([]);
let loading = $state(true);
let search = $state("");
let uploading = $state(false);
let uploadError = $state("");
let fileInput;
async function loadGames() {
loading = true;
try {
const data = await listGames(search ? { search } : {});
games = data.games || [];
} catch (e) {
console.error("Failed to load games:", e);
}
loading = false;
}
async function handleUpload(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading = true;
uploadError = "";
try {
await uploadGame(file);
await loadGames();
fileInput.value = "";
} catch (err) {
uploadError = err.message;
}
uploading = false;
}
$effect(() => {
loadGames();
});
</script>
<div class="library">
<div class="toolbar">
<h1>Library</h1>
<div class="toolbar-actions">
<input
type="text"
placeholder="Search games..."
bind:value={search}
oninput={() => loadGames()}
/>
<label class="btn btn-primary upload-label">
{uploading ? "Uploading..." : "+ Upload Game"}
<input
bind:this={fileInput}
type="file"
accept=".zip"
class="hidden-input"
onchange={handleUpload}
disabled={uploading}
/>
</label>
</div>
</div>
{#if uploadError}
<div class="error">{uploadError}</div>
{/if}
{#if loading}
<div class="loading">Scanning library...</div>
{:else if games.length === 0}
<div class="empty-state">
<div class="empty-icon">💾</div>
<h2>No games yet</h2>
<p>Upload a DOS game ZIP to get started</p>
<label class="btn btn-primary" style="margin-top: 16px;">
Upload your first game
<input type="file" accept=".zip" class="hidden-input" onchange={handleUpload} />
</label>
</div>
{:else}
<div class="grid">
{#each games as game (game.id)}
<GameCard {game} />
{/each}
</div>
<div class="count">{games.length} game{games.length !== 1 ? "s" : ""}</div>
{/if}
</div>
<style>
.library {
padding: 32px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.toolbar h1 {
color: var(--phosphor);
font-family: var(--font-mono);
animation: phosphor-pulse 3s ease-in-out infinite;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-actions input {
min-width: 200px;
}
.hidden-input {
display: none;
}
.upload-label {
cursor: pointer;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.error {
background: #331111;
border: 1px solid #cc3333;
color: #ff6666;
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.loading {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
font-style: italic;
}
.empty-state {
text-align: center;
padding: 80px 20px;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.empty-state h2 {
margin-bottom: 8px;
}
.empty-state p {
color: var(--text-dim);
}
.count {
margin-top: 24px;
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script>
import { getGame, bundleUrl } from "../lib/api.js";
import { push } from "../lib/router.js";
let { id } = $props();
let game = $state(null);
let loading = $state(true);
let error = $state("");
let emulatorReady = $state(false);
let dosContainer;
async function load() {
loading = true;
try {
game = await getGame(id);
} catch (e) {
error = "Game not found";
}
loading = false;
}
function startEmulator() {
if (!game || !game.ready) return;
// Load js-dos from CDN dynamically
const css = document.createElement("link");
css.rel = "stylesheet";
css.href = "https://v8.js-dos.com/latest/js-dos.css";
document.head.appendChild(css);
const script = document.createElement("script");
script.src = "https://v8.js-dos.com/latest/js-dos.js";
script.onload = () => {
const url = bundleUrl(game);
if (window.Dos && dosContainer && url) {
window.Dos(dosContainer, { url });
emulatorReady = true;
}
};
document.head.appendChild(script);
emulatorReady = true;
}
$effect(() => { load(); });
// Cleanup on unmount
$effect(() => {
return () => {
// js-dos handles its own cleanup
};
});
</script>
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error">{error}</div>
<a href="#/" class="back-link">← Back to Library</a>
{:else if game}
<div class="play-page">
<div class="play-header">
<a href={`#/game/${game.id}`} class="back-link">{game.title}</a>
{#if !emulatorReady}
<button class="btn btn-primary" onclick={startEmulator}>
▶ Launch Emulator
</button>
{/if}
</div>
{#if emulatorReady}
<div class="emulator-wrapper">
<div bind:this={dosContainer} class="dos-container"></div>
</div>
{:else}
<div class="launch-screen">
<div class="launch-icon">🕹️</div>
<h2>{game.title}</h2>
<p class="launch-info">
{game.year ? `${game.year} · ` : ""}{game.genre || ""}
</p>
<p class="launch-hint">Click "Launch Emulator" above to start playing</p>
<p class="save-hint">💾 Game saves are stored automatically in your browser</p>
</div>
{/if}
</div>
{/if}
<style>
.play-page { padding: 16px 0; }
.loading { text-align: center; padding: 60px; color: var(--text-dim); }
.error { background: #331111; border: 1px solid #cc3333; color: #ff6666; padding: 12px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.back-link { display: inline-block; color: var(--text-dim); }
.back-link:hover { color: var(--phosphor); }
.play-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.emulator-wrapper {
background: #000;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
.dos-container {
width: 100%;
aspect-ratio: 4/3;
max-height: 80vh;
}
.dos-container :global(canvas) {
width: 100% !important;
height: 100% !important;
}
.launch-screen {
text-align: center;
padding: 80px 20px;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.launch-icon { font-size: 4rem; margin-bottom: 16px; }
.launch-screen h2 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; }
.launch-info { color: var(--text-dim); margin-bottom: 24px; }
.launch-hint { color: var(--text-dim); margin-bottom: 8px; }
.save-hint {
color: var(--phosphor-dim);
font-size: 0.85rem;
margin-top: 16px;
background: var(--phosphor-burn);
display: inline-block;
padding: 6px 16px;
border-radius: 12px;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
export default {
compilerOptions: {
runes: true,
},
};
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8765",
"/games": "http://localhost:8765",
"/artwork": "http://localhost:8765",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
// Game represents the metadata for a single DOS game.
// Stored as game.json in data/games/{id}/
type Game struct {
ID string `json:"id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Genre string `json:"genre,omitempty"`
Developer string `json:"developer,omitempty"`
Publisher string `json:"publisher,omitempty"`
Description string `json:"description,omitempty"`
Rating float64 `json:"rating,omitempty"`
Platforms []string `json:"platforms,omitempty"`
// Bundle info
BundleType string `json:"bundle_type,omitempty"` // "standard" or "sockdrive"
BundleFile string `json:"bundle_file,omitempty"` // path relative to game dir
// IGDB
IGDBID int `json:"igdb_id,omitempty"`
IGDBCoverID string `json:"igdb_cover_id,omitempty"`
// Local file hints (cover exists if cover.jpg/png/webp is present)
HasCover bool `json:"has_cover"`
HasScreenshot bool `json:"has_screenshots"`
Screenshots []string `json:"screenshots,omitempty"`
Ready bool `json:"ready"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// gameDir returns the directory for a game by ID.
func gameDir(id string) string {
return filepath.Join(dataDir, "games", id)
}
// gameJSONPath returns the path to a game's game.json.
func gameJSONPath(id string) string {
return filepath.Join(gameDir(id), "game.json")
}
// loadGame reads a game.json file and returns the Game.
func loadGame(id string) (*Game, error) {
path := gameJSONPath(id)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
g := &Game{}
if err := json.Unmarshal(data, g); err != nil {
return nil, err
}
// Detect if cover file exists
g.HasCover = fileExists(filepath.Join(gameDir(id), "cover.jpg")) ||
fileExists(filepath.Join(gameDir(id), "cover.png")) ||
fileExists(filepath.Join(gameDir(id), "cover.webp"))
return g, nil
}
// saveGame writes a game's metadata to its game.json.
func saveGame(g *Game) error {
g.UpdatedAt = time.Now()
if g.CreatedAt.IsZero() {
g.CreatedAt = time.Now()
}
dir := gameDir(g.ID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
return os.WriteFile(gameJSONPath(g.ID), data, 0644)
}
// listGames scans the games directory and returns all games, sorted by title.
func listGames() ([]*Game, error) {
gamesDir := filepath.Join(dataDir, "games")
entries, err := os.ReadDir(gamesDir)
if err != nil {
if os.IsNotExist(err) {
return []*Game{}, nil
}
return nil, err
}
var games []*Game
if len(entries) > 0 {
games = make([]*Game, 0, len(entries))
} else {
games = make([]*Game, 0)
}
for _, e := range entries {
if !e.IsDir() {
continue
}
g, err := loadGame(e.Name())
if err != nil {
continue // skip broken entries
}
games = append(games, g)
}
sort.Slice(games, func(i, j int) bool {
return games[i].Title < games[j].Title
})
return games, nil
}
// fileExists checks if a file exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// sanitizeID cleans a string for use as a game directory name.
func sanitizeID(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
result = append(result, c)
} else if c >= 'A' && c <= 'Z' {
result = append(result, c+32) // lowercase
} else if c == ' ' || c == '-' || c == '_' {
result = append(result, '-')
}
}
// Trim leading/trailing hyphens
for len(result) > 0 && result[0] == '-' {
result = result[1:]
}
for len(result) > 0 && result[len(result)-1] == '-' {
result = result[:len(result)-1]
}
if len(result) == 0 {
return "unknown"
}
return string(result)
}
+3
View File
@@ -0,0 +1,3 @@
module dostalgia
go 1.23.9
+29
View File
@@ -0,0 +1,29 @@
package main
import "net/http"
// handleIGDBSearch handles GET /api/igdb/search — placeholder.
// Once IGDB credentials are configured, this will query the Twitch API.
func handleIGDBSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Query parameter 'q' required", http.StatusBadRequest)
return
}
// TODO: Implement real IGDB search.
// Requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET environment variables.
//
// Flow:
// 1. POST https://id.twitch.tv/oauth2/token for access token
// 2. POST https://api.igdb.com/v4/games with body: search "{query}"; fields name,cover.*,genres.*,screenshots.*;
// 3. Resolve cover URLs → https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg
// 4. Return matches
writeJSON(w, http.StatusOK, []map[string]string{
{
"message": "IGDB integration not yet configured. Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars.",
"query": query,
},
})
}
+310
View File
@@ -0,0 +1,310 @@
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
//go:embed all:frontend/dist
var frontendFS embed.FS
var (
dataDir string
devMode bool
)
func main() {
// Determine data directory
dataDir = os.Getenv("DOSTALGIA_DATA")
if dataDir == "" {
dataDir = "data"
}
devMode = os.Getenv("DOSTALGIA_DEV") == "1"
// Ensure data directories exist
for _, d := range []string{"games", "artwork", "saves"} {
os.MkdirAll(filepath.Join(dataDir, d), 0755)
}
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/health", handleHealth)
mux.HandleFunc("/api/games", handleGames)
mux.HandleFunc("/api/games/", handleGameByID)
mux.HandleFunc("/api/upload", handleUploadGame)
mux.HandleFunc("/api/igdb/search", handleIGDBSearch)
// Serve game bundles and artwork as static files
gameFS := http.FileServer(http.Dir(filepath.Join(dataDir, "games")))
mux.Handle("/games/", http.StripPrefix("/games/", gameFS))
artFS := http.FileServer(http.Dir(filepath.Join(dataDir, "artwork")))
mux.Handle("/artwork/", http.StripPrefix("/artwork/", artFS))
// Sockdrive file server
mux.HandleFunc("/api/sockdrive/", handleSockdrive)
// Frontend SPA — serve embedded files, fall back to index.html
mux.HandleFunc("/", handleFrontend)
port := os.Getenv("PORT")
if port == "" {
port = "8765"
}
log.Printf("🎮 Dostalgia starting on :%s (data: %s)", port, dataDir)
if devMode {
log.Println("⚠️ DEV mode — run Vite dev server alongside for hot reload")
}
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
// ─── API Handlers ────────────────────────────────────────────────
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"version": "0.1.0",
})
}
func handleGames(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
games, err := listGames()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"games": games,
"total": len(games),
})
case http.MethodPost:
// Create a game manually (without upload)
var g Game
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if g.Title == "" {
http.Error(w, "Title is required", http.StatusBadRequest)
return
}
if g.ID == "" {
g.ID = sanitizeID(g.Title)
}
if err := saveGame(&g); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, g)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleGameByID(w http.ResponseWriter, r *http.Request) {
// Extract ID from /api/games/{id} or /api/games/{id}/...
path := strings.TrimPrefix(r.URL.Path, "/api/games/")
parts := strings.SplitN(path, "/", 2)
id := parts[0]
if id == "" {
http.Error(w, "Game ID required", http.StatusBadRequest)
return
}
// Cover upload sub-endpoint
if len(parts) > 1 && parts[1] == "cover" {
handleUploadCover(w, r)
return
}
switch r.Method {
case http.MethodGet:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, g)
case http.MethodPatch:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Apply simple string/number fields
if v, ok := updates["title"].(string); ok {
g.Title = v
}
if v, ok := updates["year"].(float64); ok {
g.Year = int(v)
}
if v, ok := updates["genre"].(string); ok {
g.Genre = v
}
if v, ok := updates["developer"].(string); ok {
g.Developer = v
}
if v, ok := updates["publisher"].(string); ok {
g.Publisher = v
}
if v, ok := updates["description"].(string); ok {
g.Description = v
}
if v, ok := updates["rating"].(float64); ok {
g.Rating = v
}
if err := saveGame(g); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, g)
case http.MethodDelete:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
// Remove the entire game directory
if err := os.RemoveAll(gameDir(g.ID)); err != nil {
http.Error(w, "Failed to delete game", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ─── Sockdrive File Server ──────────────────────────────────────
func handleSockdrive(w http.ResponseWriter, r *http.Request) {
// /api/sockdrive/{slug}/file?path=...
path := strings.TrimPrefix(r.URL.Path, "/api/sockdrive/")
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
slug := parts[0]
if parts[1] == "file" {
filePath := r.URL.Query().Get("path")
if filePath == "" {
http.Error(w, "path query parameter required", http.StatusBadRequest)
return
}
fullPath := filepath.Join(gameDir(slug), filePath)
// Prevent traversal
fullPath = filepath.Clean(fullPath)
if !strings.HasPrefix(fullPath, filepath.Clean(gameDir(slug))) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, fullPath)
return
}
if parts[1] == "files" {
// List all files in game directory
var files []map[string]interface{}
gameDirPath := gameDir(slug)
filepath.WalkDir(gameDirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, _ := d.Info()
rel, _ := filepath.Rel(gameDirPath, path)
files = append(files, map[string]interface{}{
"path": strings.ReplaceAll(rel, "\\", "/"),
"size": info.Size(),
})
return nil
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"files": files,
"total": len(files),
})
return
}
http.Error(w, "Not found", http.StatusNotFound)
}
// ─── Frontend SPA ───────────────────────────────────────────────
func handleFrontend(w http.ResponseWriter, r *http.Request) {
// Don't intercept API or static routes
if strings.HasPrefix(r.URL.Path, "/api/") ||
strings.HasPrefix(r.URL.Path, "/games/") ||
strings.HasPrefix(r.URL.Path, "/artwork/") {
http.NotFound(w, r)
return
}
var content fs.FS
var err error
if devMode {
content = os.DirFS("frontend/dist")
} else {
content, err = fs.Sub(frontendFS, "frontend/dist")
if err != nil {
http.Error(w, "Frontend not built", http.StatusServiceUnavailable)
return
}
}
// Try to serve the exact file
filePath := strings.TrimPrefix(r.URL.Path, "/")
if filePath == "" {
filePath = "index.html"
}
f, err := content.Open(filePath)
if err == nil {
f.Close()
http.FileServer(http.FS(content)).ServeHTTP(w, r)
return
}
// Fallback to index.html for SPA routing
indexData, err := fs.ReadFile(content, "index.html")
if err != nil {
http.Error(w, "Frontend not built. Run 'cd frontend && npm run build'", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexData)
}
// ─── Helpers ────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
+399
View File
@@ -0,0 +1,399 @@
package main
import (
"archive/zip"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const dosboxConfTemplate = `[autoexec]
@echo off
mount c .
c:
cls
%s
`
// handleUploadGame handles POST /api/upload — receives a ZIP, creates a .jsdos bundle.
func handleUploadGame(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse multipart form (max 500MB)
if err := r.ParseMultipartForm(500 << 20); err != nil {
http.Error(w, "Failed to parse upload: "+err.Error(), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
title := r.FormValue("title")
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
gameID := sanitizeID(title)
// Ensure uniqueness
baseID := gameID
counter := 2
for {
if _, err := loadGame(gameID); err != nil {
break // not found, we can use this ID
}
gameID = fmt.Sprintf("%s-%d", baseID, counter)
counter++
}
// Save uploaded ZIP to temp file
tmpDir, err := os.MkdirTemp("", "dostalgia-upload-")
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
defer os.RemoveAll(tmpDir)
zipPath := filepath.Join(tmpDir, header.Filename)
dst, err := os.Create(zipPath)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
http.Error(w, "Failed to save upload", http.StatusInternalServerError)
return
}
dst.Close()
// Extract ZIP
extractDir := filepath.Join(tmpDir, "extracted")
if err := os.MkdirAll(extractDir, 0755); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if err := extractZip(zipPath, extractDir); err != nil {
http.Error(w, "Invalid ZIP file: "+err.Error(), http.StatusBadRequest)
return
}
// Find main executable
mainExe := findMainExe(extractDir)
if mainExe == "" {
http.Error(w, "No executable (.exe/.com/.bat) found in the archive", http.StatusBadRequest)
return
}
// Determine size and strategy
sizeMB := dirSizeMB(extractDir)
bundleType := "standard"
if sizeMB > 80 {
bundleType = "sockdrive"
}
// Set up game directory
gameDirPath := gameDir(gameID)
if err := os.MkdirAll(gameDirPath, 0755); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
var bundleFile string
if bundleType == "sockdrive" {
// Copy all files loose
if err := copyDir(extractDir, gameDirPath); err != nil {
http.Error(w, "Failed to store game files", http.StatusInternalServerError)
return
}
// Create dosbox.conf
confDir := filepath.Join(gameDirPath, ".jsdos")
os.MkdirAll(confDir, 0755)
relExe, _ := filepath.Rel(extractDir, mainExe)
os.WriteFile(filepath.Join(confDir, "dosbox.conf"),
[]byte(fmt.Sprintf(dosboxConfTemplate, relExe)), 0644)
bundleFile = ".jsdos/dosbox.conf"
} else {
// Create .jsdos ZIP bundle
bundleFile = gameID + ".jsdos"
bundlePath := filepath.Join(gameDirPath, bundleFile)
if err := createJSDOSBundle(extractDir, mainExe, bundlePath); err != nil {
http.Error(w, "Failed to create bundle: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Save metadata
game := &Game{
ID: gameID,
Title: title,
BundleType: bundleType,
BundleFile: bundleFile,
Ready: true,
}
if err := saveGame(game); err != nil {
http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, game)
}
// extractZip extracts a ZIP file to the given directory.
func extractZip(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
// Prevent path traversal
target := filepath.Join(dest, f.Name)
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(target, 0755)
continue
}
os.MkdirAll(filepath.Dir(target), 0755)
src, err := f.Open()
if err != nil {
return err
}
dst, err := os.Create(target)
if err != nil {
src.Close()
return err
}
_, err = io.Copy(dst, src)
src.Close()
dst.Close()
if err != nil {
return err
}
}
return nil
}
// mainExeCandidate is used for sorting executables by relevance.
type mainExeCandidate struct {
depth int
isInstaller bool
size int64
name string
path string
}
// findMainExe finds the most likely main executable in an extracted game directory.
func findMainExe(dir string) string {
var candidates []mainExeCandidate
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(d.Name()))
if ext != ".exe" && ext != ".com" && ext != ".bat" {
return nil
}
info, _ := d.Info()
name := strings.ToLower(d.Name())
isInstaller := strings.Contains(name, "install") ||
strings.Contains(name, "setup") ||
strings.Contains(name, "config")
depth := strings.Count(path[len(dir):], string(os.PathSeparator))
candidates = append(candidates, mainExeCandidate{
depth: depth,
isInstaller: isInstaller,
size: info.Size(),
name: name,
path: path,
})
return nil
})
if len(candidates) == 0 {
return ""
}
// Sort: shallow depth, non-installer, larger files first
sortCandidates(candidates)
return candidates[0].path
}
func sortCandidates(c []mainExeCandidate) {
for i := 0; i < len(c); i++ {
for j := i + 1; j < len(c); j++ {
// Compare: depth (lower first), installer (false first), size (larger first)
swap := false
if c[i].depth != c[j].depth {
swap = c[i].depth > c[j].depth
} else if c[i].isInstaller != c[j].isInstaller {
swap = c[i].isInstaller
} else {
swap = c[i].size < c[j].size
}
if swap {
c[i], c[j] = c[j], c[i]
}
}
}
}
// dirSizeMB returns the total size of a directory in megabytes.
func dirSizeMB(dir string) float64 {
var total int64
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, _ := d.Info()
total += info.Size()
return nil
})
return float64(total) / (1024 * 1024)
}
// copyDir copies a directory recursively.
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, path)
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0644)
})
}
// createJSDOSBundle creates a .jsdos bundle ZIP from extracted game files.
func createJSDOSBundle(extractDir, mainExe, bundlePath string) error {
// Create a temp directory for bundling
tmpDir, err := os.MkdirTemp("", "dostalgia-bundle-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
// Copy game files
if err := copyDir(extractDir, tmpDir); err != nil {
return err
}
// Create .jsdos config
jsdosDir := filepath.Join(tmpDir, ".jsdos")
if err := os.MkdirAll(jsdosDir, 0755); err != nil {
return err
}
relExe, _ := filepath.Rel(extractDir, mainExe)
conf := fmt.Sprintf(dosboxConfTemplate, relExe)
if err := os.WriteFile(filepath.Join(jsdosDir, "dosbox.conf"), []byte(conf), 0644); err != nil {
return err
}
// Create the ZIP
zf, err := os.Create(bundlePath)
if err != nil {
return err
}
defer zf.Close()
zw := zip.NewWriter(zf)
defer zw.Close()
return filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(tmpDir, path)
w, err := zw.Create(rel)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = w.Write(data)
return err
})
}
// handleUploadCover handles POST /api/games/{id}/cover — upload cover art.
func handleUploadCover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
id := r.PathValue("id")
if _, err := loadGame(id); err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
// Determine extension
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
http.Error(w, "Only JPG, PNG, or WebP images accepted", http.StatusBadRequest)
return
}
coverPath := filepath.Join(gameDir(id), "cover"+ext)
// Remove old cover files
for _, oldExt := range []string{".jpg", ".jpeg", ".png", ".webp"} {
os.Remove(filepath.Join(gameDir(id), "cover"+oldExt))
}
dst, err := os.Create(coverPath)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "Failed to save cover", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "path": coverPath})
}
var _ = time.Now // keep import