Migrate backend from Go to Quarkus (Java 21 / JAX-RS)

- Replaced Go stdlib server with Quarkus (RESTEasy Reactive + Jackson)
- 7 Java classes: Game POJO, GameService (JSON file I/O), 4 REST resources, static file serving
- Same JSON-per-game storage, same ZIP upload + bundle creation
- Same Svelte frontend (unchanged), built into the JAR via maven-resources-plugin
- Multi-stage Dockerfile: Node → Maven → JRE runtime
This commit is contained in:
David Alvarez
2026-05-24 15:41:51 +02:00
parent 7f17f4b31e
commit 28ea711b24
18 changed files with 986 additions and 933 deletions
+106
View File
@@ -0,0 +1,106 @@
package com.dostalgia;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;
/** Represents a single DOS game, stored as data/games/{id}/game.json */
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Game {
private String id;
private String title;
private Integer year;
private String genre;
private String developer;
private String publisher;
private String description;
private Double rating;
private List<String> platforms;
private String bundleType; // "standard" or "sockdrive"
private String bundleFile; // relative path within game dir
private Integer igdbId;
private String igdbCoverId;
private boolean hasCover;
private boolean hasScreenshots;
private List<String> screenshots;
private boolean ready;
private Instant createdAt;
private Instant updatedAt;
// ─── Constructors ────────────────────────────────────────────
public Game() {}
public Game(String id, String title) {
this.id = id;
this.title = title;
this.ready = false;
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
// ─── Getters & Setters ──────────────────────────────────────
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Integer getYear() { return year; }
public void setYear(Integer year) { this.year = year; }
public String getGenre() { return genre; }
public void setGenre(String genre) { this.genre = genre; }
public String getDeveloper() { return developer; }
public void setDeveloper(String developer) { this.developer = developer; }
public String getPublisher() { return publisher; }
public void setPublisher(String publisher) { this.publisher = publisher; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Double getRating() { return rating; }
public void setRating(Double rating) { this.rating = rating; }
public List<String> getPlatforms() { return platforms; }
public void setPlatforms(List<String> platforms) { this.platforms = platforms; }
public String getBundleType() { return bundleType; }
public void setBundleType(String bundleType) { this.bundleType = bundleType; }
public String getBundleFile() { return bundleFile; }
public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
public Integer getIgdbId() { return igdbId; }
public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
public String getIgdbCoverId() { return igdbCoverId; }
public void setIgdbCoverId(String igdbCoverId) { this.igdbCoverId = igdbCoverId; }
public boolean isHasCover() { return hasCover; }
public void setHasCover(boolean hasCover) { this.hasCover = hasCover; }
public boolean isHasScreenshots() { return hasScreenshots; }
public void setHasScreenshots(boolean hasScreenshots) { this.hasScreenshots = hasScreenshots; }
public List<String> getScreenshots() { return screenshots; }
public void setScreenshots(List<String> screenshots) { this.screenshots = screenshots; }
public boolean isReady() { return ready; }
public void setReady(boolean ready) { this.ready = ready; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,113 @@
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.nio.file.NoSuchFileException;
import java.util.*;
@Path("/api/games")
@Produces(MediaType.APPLICATION_JSON)
public class GameResource {
@Inject
GameService svc;
@GET
public Response list(
@QueryParam("search") String search,
@QueryParam("genre") String genre,
@QueryParam("year") Integer year,
@QueryParam("limit") @DefaultValue("50") int limit,
@QueryParam("offset") @DefaultValue("0") int offset
) {
try {
var all = svc.list();
// Client-side filtering (small dataset, fine for a personal library)
if (search != null && !search.isBlank()) {
all = all.stream()
.filter(g -> g.getTitle().toLowerCase().contains(search.toLowerCase()))
.toList();
}
if (genre != null && !genre.isBlank()) {
all = all.stream()
.filter(g -> genre.equalsIgnoreCase(g.getGenre()))
.toList();
}
if (year != null) {
all = all.stream()
.filter(g -> year.equals(g.getYear()))
.toList();
}
int total = all.size();
int from = Math.min(offset, total);
int to = Math.min(offset + limit, total);
var page = all.subList(from, to);
return Response.ok(Map.of("games", page, "total", total)).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@GET
@Path("/{id}")
public Response get(@PathParam("id") String id) {
try {
var game = svc.load(id);
return Response.ok(game).build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@PATCH
@Path("/{id}")
@Consumes(MediaType.APPLICATION_JSON)
public Response update(@PathParam("id") String id, Map<String, Object> updates) {
try {
var game = svc.load(id);
if (updates.containsKey("title")) game.setTitle((String) updates.get("title"));
if (updates.containsKey("year")) game.setYear(asInt(updates.get("year")));
if (updates.containsKey("genre")) game.setGenre((String) updates.get("genre"));
if (updates.containsKey("developer")) game.setDeveloper((String) updates.get("developer"));
if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher"));
if (updates.containsKey("description")) game.setDescription((String) updates.get("description"));
if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating")));
svc.save(game);
return Response.ok(game).build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") String id) {
try {
svc.delete(id);
return Response.ok(Map.of("status", "deleted")).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
private Integer asInt(Object v) {
if (v instanceof Number n) return n.intValue();
return null;
}
private Double asDouble(Object v) {
if (v instanceof Number n) return n.doubleValue();
return null;
}
}
@@ -0,0 +1,146 @@
package com.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.*;
/** Manages game metadata stored as JSON files on disk. */
@ApplicationScoped
public class GameService {
private final ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(SerializationFeature.INDENT_OUTPUT);
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir;
Path gamesDir;
@PostConstruct
void init() {
gamesDir = Path.of(dataDir, "games");
try {
Files.createDirectories(gamesDir);
Files.createDirectories(Path.of(dataDir, "artwork"));
Files.createDirectories(Path.of(dataDir, "saves"));
} catch (IOException e) {
throw new RuntimeException("Cannot create data directories", e);
}
}
// ─── Game Directory Helpers ──────────────────────────────────
Path gameDir(String id) {
return gamesDir.resolve(id);
}
Path gameJsonPath(String id) {
return gameDir(id).resolve("game.json");
}
// ─── CRUD ────────────────────────────────────────────────────
/** Load a game by ID. Throws if not found. */
public Game load(String id) throws IOException {
Path path = gameJsonPath(id);
if (!Files.exists(path)) {
throw new NoSuchFileException("game.json not found for: " + id);
}
Game game = mapper.readValue(path.toFile(), Game.class);
// Detect cover file
game.setHasCover(
Files.exists(gameDir(id).resolve("cover.jpg")) ||
Files.exists(gameDir(id).resolve("cover.png")) ||
Files.exists(gameDir(id).resolve("cover.webp"))
);
return game;
}
/** Save game metadata to disk. */
public void save(Game game) throws IOException {
game.setUpdatedAt(Instant.now());
if (game.getCreatedAt() == null) {
game.setCreatedAt(Instant.now());
}
Files.createDirectories(gameDir(game.getId()));
mapper.writeValue(gameJsonPath(game.getId()).toFile(), game);
}
/** List all games, sorted by title. */
public List<Game> list() throws IOException {
List<Game> games = new ArrayList<>();
if (!Files.isDirectory(gamesDir)) return games;
try (var stream = Files.list(gamesDir)) {
for (Path dir : (Iterable<Path>) stream::iterator) {
if (!Files.isDirectory(dir)) continue;
try {
Game g = load(dir.getFileName().toString());
games.add(g);
} catch (Exception ignored) {
// skip broken entries
}
}
}
games.sort(Comparator.comparing(Game::getTitle));
return games;
}
/** Delete a game and all its files. */
public boolean delete(String id) throws IOException {
// Remove game directory
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;
}
});
}
// Remove flat .jsdos bundle
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
return true;
}
// ─── Utility ─────────────────────────────────────────────────
/** Sanitize a string for use as a game directory ID. */
public static String sanitizeId(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (c >= 'a' && c <= 'z') sb.append(c);
else if (c >= 'A' && c <= 'Z') sb.append((char)(c + 32));
else if (c >= '0' && c <= '9') sb.append(c);
else if (c == ' ' || c == '-' || c == '_') sb.append('-');
}
String result = sb.toString().replaceAll("^-+|-+$", "");
return result.isEmpty() ? "unknown" : result;
}
/** Check if a file exists. */
public static boolean exists(Path p) {
return Files.exists(p);
}
public Path getGamesDir() { return gamesDir; }
}
@@ -0,0 +1,31 @@
package com.dostalgia;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
/** IGDB integration placeholder. Enable by setting TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET. */
@Path("/api/igdb")
@Produces(MediaType.APPLICATION_JSON)
public class IgdbResource {
@GET
@Path("/search")
public Response search(@QueryParam("q") @DefaultValue("") String query) {
if (query.isBlank()) {
return Response.status(400).entity(Map.of("error", "Query parameter 'q' required")).build();
}
// TODO: Implement real IGDB search via Twitch OAuth2 + IGDB API
// 1. POST https://id.twitch.tv/oauth2/token → access_token
// 2. POST https://api.igdb.com/v4/games with search body
// 3. Return matches with cover URLs
return Response.ok(java.util.List.of()).build();
}
@GET
@Path("/covers/{igdbId}")
public Response covers(@PathParam("igdbId") int igdbId) {
return Response.ok(java.util.List.of()).build();
}
}
@@ -0,0 +1,74 @@
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/** Serves game files for sockdrive streaming mode. */
@Path("/api/sockdrive")
@Produces(MediaType.APPLICATION_JSON)
public class SockdriveResource {
@Inject
GameService svc;
@GET
@Path("/{slug}/files")
public Response listFiles(@PathParam("slug") String slug) {
Path gameDir = svc.gameDir(slug);
if (!Files.isDirectory(gameDir)) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
}
try {
var files = new ArrayList<Map<String, Object>>();
Files.walk(gameDir).filter(Files::isRegularFile).forEach(f -> {
String rel = gameDir.relativize(f).toString().replace('\\', '/');
try {
files.add(Map.of("path", rel, "size", Files.size(f)));
} catch (IOException ignored) {}
});
return Response.ok(Map.of("files", files, "total", files.size())).build();
} catch (IOException e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@GET
@Path("/{slug}/file")
public Response getFile(
@PathParam("slug") String slug,
@QueryParam("path") String path
) {
if (path == null || path.isBlank()) {
return Response.status(400).entity(Map.of("error", "path parameter required")).build();
}
Path resolved = svc.gameDir(slug).resolve(path).normalize();
if (!resolved.startsWith(svc.gameDir(slug).normalize())) {
return Response.status(403).build();
}
if (!Files.exists(resolved) || Files.isDirectory(resolved)) {
return Response.status(404).build();
}
// Determine content type
String contentType = MediaType.APPLICATION_OCTET_STREAM;
String name = resolved.getFileName().toString().toLowerCase();
if (name.endsWith(".conf") || name.endsWith(".txt")) contentType = "text/plain";
else if (name.endsWith(".json")) contentType = "application/json";
else if (name.endsWith(".html")) contentType = "text/html";
else if (name.endsWith(".js")) contentType = "application/javascript";
final String ct = contentType;
StreamingOutput stream = output -> Files.copy(resolved, output);
return Response.ok(stream).type(ct).header("Accept-Ranges", "bytes").build();
}
}
@@ -0,0 +1,89 @@
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.*;
import java.nio.file.*;
import java.util.Map;
/** Serves game bundles (.jsdos) and artwork from the external data directory. */
@Path("/")
public class StaticResource {
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir;
@GET
@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());
return Response.ok(new FileStream(file))
.type(contentType)
.header("Accept-Ranges", "bytes")
.build();
}
@GET
@Path("/artwork/{path: .*}")
public Response getArtwork(@PathParam("path") String path) {
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
@Path("/api/health")
public Response health() {
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
}
private String guessContentType(String name) {
String n = name.toLowerCase();
if (n.endsWith(".jsdos")) return "application/zip";
if (n.endsWith(".zip")) return "application/zip";
if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return "image/jpeg";
if (n.endsWith(".png")) return "image/png";
if (n.endsWith(".webp")) return "image/webp";
if (n.endsWith(".gif")) return "image/gif";
if (n.endsWith(".conf") || n.endsWith(".txt")) return "text/plain";
if (n.endsWith(".json")) return "application/json";
if (n.endsWith(".html")) return "text/html";
if (n.endsWith(".js")) return "application/javascript";
if (n.endsWith(".css")) return "text/css";
return MediaType.APPLICATION_OCTET_STREAM;
}
/** Streaming file output that avoids loading the whole file into memory. */
private record FileStream(Path file) implements StreamingOutput {
@Override
public void write(OutputStream output) throws IOException {
Files.copy(file, output);
}
}
}
@@ -0,0 +1,261 @@
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
@Path("/api/upload")
@Produces(MediaType.APPLICATION_JSON)
public class UploadResource {
private static final double SOCKDRIVE_THRESHOLD_MB = 80.0;
@Inject
GameService svc;
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response upload(
@RestForm("file") FileUpload upload,
@RestForm("title") String title
) throws Exception {
if (upload == null) {
return Response.status(400).entity(Map.of("error", "No file provided")).build();
}
String filename = upload.fileName();
if (title == null || title.isBlank()) {
title = filename.endsWith(".zip") ? filename.substring(0, filename.length() - 4) : filename;
}
String gameId = GameService.sanitizeId(title);
// Ensure unique ID
String baseId = gameId;
int counter = 2;
while (Files.exists(svc.gameJsonPath(gameId))) {
gameId = baseId + "-" + counter++;
}
// Extract ZIP
Path tmpDir = Files.createTempDirectory("dostalgia-");
try {
Path zipPath = tmpDir.resolve(filename);
Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING);
Path extractDir = tmpDir.resolve("extracted");
unzip(zipPath, extractDir);
// Find main executable
String mainExe = findMainExe(extractDir);
if (mainExe == null) {
return Response.status(400)
.entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive"))
.build();
}
// Determine bundle strategy
double sizeMb = dirSizeMB(extractDir);
String bundleType = sizeMb > SOCKDRIVE_THRESHOLD_MB ? "sockdrive" : "standard";
// Create game directory
Path gameDir = svc.gameDir(gameId);
Files.createDirectories(gameDir);
String bundleFile;
if ("sockdrive".equals(bundleType)) {
// Copy files loose
copyDir(extractDir, gameDir);
// Write dosbox.conf
Path jsdos = gameDir.resolve(".jsdos");
Files.createDirectories(jsdos);
String relExe = Path.of(mainExe).getFileName().toString();
String conf = "[autoexec]\n@echo off\nmount c .\nc:\ncls\n" + relExe + "\n";
Files.writeString(jsdos.resolve("dosbox.conf"), conf);
bundleFile = ".jsdos/dosbox.conf";
} else {
// Create .jsdos bundle (flat in games dir)
bundleFile = gameId + ".jsdos";
Path bundlePath = svc.getGamesDir().resolve(bundleFile);
createBundle(extractDir, mainExe, bundlePath);
}
// Save metadata
Game game = new Game(gameId, title);
game.setBundleType(bundleType);
game.setBundleFile(bundleFile);
game.setReady(true);
svc.save(game);
return Response.status(201).entity(game).build();
} finally {
deleteDir(tmpDir);
}
}
@POST
@Path("/{id}/cover")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response uploadCover(@PathParam("id") String id, @RestForm("file") FileUpload upload) throws Exception {
if (upload == null) {
return Response.status(400).entity(Map.of("error", "No file provided")).build();
}
if (!Files.exists(svc.gameJsonPath(id))) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
}
String fname = upload.fileName().toLowerCase();
String ext = "";
if (fname.endsWith(".jpg") || fname.endsWith(".jpeg")) ext = ".jpg";
else if (fname.endsWith(".png")) ext = ".png";
else if (fname.endsWith(".webp")) ext = ".webp";
else {
return Response.status(400).entity(Map.of("error", "Only JPG, PNG, or WebP images accepted")).build();
}
Path gameDir = svc.gameDir(id);
// Remove old covers
for (String old : new String[]{".jpg", ".jpeg", ".png", ".webp"}) {
Files.deleteIfExists(gameDir.resolve("cover" + old));
}
Files.copy(upload.uploadedFile(), gameDir.resolve("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
return Response.ok(Map.of("status", "ok")).build();
}
// ─── ZIP Handling ────────────────────────────────────────────
private void unzip(Path zipPath, Path dest) throws IOException {
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path target = dest.resolve(entry.getName()).normalize();
if (!target.startsWith(dest)) continue; // prevent traversal
if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
}
zis.closeEntry();
}
}
}
// ─── Executable Detection ────────────────────────────────────
private String findMainExe(Path dir) throws IOException {
record Candidate(int depth, boolean isInstaller, long size, String path) {}
List<Candidate> candidates = 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"))
return FileVisitResult.CONTINUE;
boolean installer = name.contains("install") || name.contains("setup") || name.contains("config");
int depth = f.getNameCount() - dir.getNameCount();
candidates.add(new Candidate(depth, installer, a.size(), f.toString()));
return FileVisitResult.CONTINUE;
}
});
if (candidates.isEmpty()) return null;
candidates.sort(Comparator
.comparingInt(Candidate::depth)
.thenComparingInt(c -> c.isInstaller() ? 1 : 0)
.thenComparingLong(c -> -c.size())); // larger files first
return candidates.getFirst().path();
}
// ─── Bundle Creation ─────────────────────────────────────────
private void createBundle(Path extractDir, String mainExe, Path bundlePath) throws IOException {
// Create temporary bundle dir
Path tmpBundle = Files.createTempDirectory("dostalgia-bundle-");
try {
copyDir(extractDir, tmpBundle);
// Create .jsdos config
Path jsdos = tmpBundle.resolve(".jsdos");
Files.createDirectories(jsdos);
String relExe = Path.of(mainExe).getFileName().toString();
String conf = "[autoexec]\n@echo off\nmount c .\nc:\ncls\n" + relExe + "\n";
Files.writeString(jsdos.resolve("dosbox.conf"), conf);
// ZIP it up
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
Files.walk(tmpBundle).filter(Files::isRegularFile).forEach(f -> {
try {
String entryName = tmpBundle.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);
}
});
}
} finally {
deleteDir(tmpBundle);
}
}
// ─── File Utilities ──────────────────────────────────────────
private double dirSizeMB(Path dir) throws IOException {
long[] total = {0};
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
total[0] += a.size();
return FileVisitResult.CONTINUE;
}
});
return total[0] / (1024.0 * 1024.0);
}
private void copyDir(Path src, Path dst) throws IOException {
Files.walkFileTree(src, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes a) throws IOException {
Files.createDirectories(dst.resolve(src.relativize(d)));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.copy(f, dst.resolve(src.relativize(f)), StandardCopyOption.REPLACE_EXISTING);
return FileVisitResult.CONTINUE;
}
});
}
private void deleteDir(Path dir) throws IOException {
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;
}
});
}
}
+10
View File
@@ -0,0 +1,10 @@
# ─── Server ──────────────────────────────────────
quarkus.http.port=8765
quarkus.http.host=0.0.0.0
quarkus.http.cors=true
# ─── Static resources (frontend SPA from META-INF/resources/) ──
quarkus.http.static-resources.enable=true
# ─── Data directory (game files, artwork) ────────
dostalgia.data.dir=/data