refactor: apply SOLID/KISS/YAGNI principles
Build & Deploy / build-and-deploy (push) Failing after 59s

Phase 1-2: Extract ConfigBuilder — single source of truth for all DOSBox config
  - Uses Java 21 text block templates (eliminates \n escaping bugs)
  - Deduplicated 6 config-building methods from GameService + UploadResource
  - CD mounting strategy: mount ALL images, dedup by parent dir, prefer data formats

Phase 1: Extract services from UploadResource monolith
  - PlatformDetector — PE/NE/MZ header analysis (was duplicated in 2 files)
  - CdImageService — CUE sheet repair + CD image discovery in dirs and ZIPs
  - ExecutableDetector — main exe / setup exe / self-extractor detection
  - ZipService — ZIP extraction, directory flattening, .jsdos bundle creation
  - ConfigPatcher — hardcoded C:\ path detection and rewriting

Phase 3 (YAGNI): Remove dead/misused code
  - Removed Game.platforms field (was storing genres, not platforms)
  - Fixed IgdbService that set platforms to genres list

Phase 4 (SOLID-D): Add GameStore interface
  - IgdbService now depends on GameStore, not concrete GameService
  - Enables testing with mocks

GameService.setExecutable now transactional:
  - Patches into temp file, detects platform from patched bundle
  - Atomic move on success, temp cleanup on failure

UploadResource: 1094 → 237 lines (-78%)
GameService: 412 → 290 lines (-30%)
Total: 2846 lines across 14 focused files (was 1723 lines in 3 monolithic files)
This commit is contained in:
David Alvarez
2026-06-09 12:54:07 +02:00
parent ab7a5ed8de
commit c3b6ea8567
11 changed files with 1000 additions and 1082 deletions
@@ -0,0 +1,106 @@
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;
/**
* CD image utilities: CUE sheet repair and CD image discovery in directories and ZIPs.
*/
@ApplicationScoped
public class CdImageService {
private static final Logger LOG = Logger.getLogger(CdImageService.class.getName());
private static final java.util.Set<String> CD_EXT = java.util.Set.of(
".iso", ".cue", ".img", ".ccd", ".bin"
);
/**
* Fix .cue files that reference non-existent data files.
* CloneCD rips often generate a .cue referencing a .bin, but the actual file is .img.
*/
public void fixCueReferences(Path extractDir) throws IOException {
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile)
.filter(f -> f.getFileName().toString().toLowerCase().endsWith(".cue"))
.forEach(f -> {
try { fixOneCue(f); }
catch (IOException e) {
LOG.warning("Failed to fix cue file " + f + ": " + e.getMessage());
}
});
}
}
private void fixOneCue(Path cueFile) throws IOException {
String content = Files.readString(cueFile);
Path dir = cueFile.getParent();
var m = java.util.regex.Pattern
.compile("FILE\\s+\"([^\"]+)\"", java.util.regex.Pattern.CASE_INSENSITIVE)
.matcher(content);
if (!m.find()) return;
String referenced = m.group(1);
Path refPath = dir.resolve(referenced);
if (Files.exists(refPath)) return;
Path replacement = findDataFile(dir);
if (replacement == null) return;
String actualName = replacement.getFileName().toString();
content = content.substring(0, m.start(1)) + actualName + content.substring(m.end(1));
Files.writeString(cueFile, content);
LOG.info("Fixed cue " + cueFile.getFileName() + ": '" + referenced + "' → '" + actualName + "'");
}
private Path findDataFile(Path dir) throws IOException {
try (var files = Files.list(dir)) {
return files.filter(Files::isRegularFile)
.filter(f -> {
String n = f.getFileName().toString().toLowerCase();
return n.endsWith(".img") || n.endsWith(".bin");
})
.findFirst()
.orElse(null);
}
}
/** Find mountable CD image files in a directory. Returns sorted relative paths. */
public java.util.List<String> findInDirectory(Path dir) throws IOException {
java.util.List<String> cds = new java.util.ArrayList<>();
try (var walk = Files.walk(dir)) {
walk.filter(Files::isRegularFile).forEach(f -> {
String name = f.getFileName().toString().toLowerCase();
int dot = name.lastIndexOf('.');
if (dot >= 0 && CD_EXT.contains(name.substring(dot))) {
cds.add(dir.relativize(f).toString().replace('\\', '/'));
}
});
}
cds.sort(String::compareTo);
return cds;
}
/** Find CD image files inside a .jsdos ZIP bundle. */
public java.util.List<String> findInBundle(Path bundlePath) throws IOException {
java.util.List<String> cds = new java.util.ArrayList<>();
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String name = entry.getName().toLowerCase();
int dot = name.lastIndexOf('.');
if (dot >= 0 && CD_EXT.contains(name.substring(dot))) {
cds.add(entry.getName());
}
zis.closeEntry();
}
}
cds.sort(String::compareTo);
return cds;
}
}
@@ -0,0 +1,249 @@
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* Single source of truth for all DOSBox configuration generation.
* Eliminates the duplication that existed between GameService and UploadResource.
*
* Uses Java 21 text blocks for readable, maintainable config templates.
* Adding a new config parameter requires a change in exactly ONE place.
*/
@ApplicationScoped
public class ConfigBuilder {
/** Priority: .cue (5) > .iso (4) > .ccd (3) > .img (2) > .bin (1) */
private static final Map<String, Integer> CD_FORMAT_PRIORITY = Map.of(
".cue", 5, ".iso", 4, ".ccd", 3, ".img", 2, ".bin", 1
);
// ─── Public API ───────────────────────────────────────────────
/**
* Build a dosbox.conf for a game with a known executable.
* @param relExe relative path to the executable inside the ZIP (e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE")
* @param cdImages relative paths to CD image files inside the ZIP
*/
public String buildDosboxConf(String relExe, List<String> cdImages) {
var parts = splitExePath(relExe);
String dir = parts.dir();
String exe = parts.exe();
return DOSBOX_CONF_TEMPLATE
.replace("${CD_MOUNTS}", buildCdMounts(cdImages))
.replace("${EXEC_PATH}", buildExecPath(dir, exe));
}
/** Build dosbox.conf as UTF-8 bytes (for ZIP stream operations). */
public byte[] buildDosboxConfBytes(String relExe, List<String> cdImages) {
return buildDosboxConf(relExe, cdImages).getBytes(StandardCharsets.UTF_8);
}
/**
* Build jsdos.json for js-dos v8 with the given executable path.
* js-dos v8 uses jsdos.json autoexec.script instead of the dosbox.conf [autoexec] section.
*/
public byte[] buildJsdosJson(String relExe, List<String> cdImages) {
var parts = splitExePath(relExe);
String script = buildAutoexecScript(parts.dir(), parts.exe(), cdImages);
return buildJsdosJsonRaw(script);
}
/** Build dosbox.conf for a CD-only game (no filesystem executables). */
public String buildCdOnlyDosboxConf(List<String> cdImages) {
return CD_ONLY_DOSBOX_CONF_TEMPLATE
.replace("${CD_MOUNTS}", buildCdMounts(cdImages));
}
/** Build jsdos.json for a CD-only game. */
public byte[] buildCdOnlyJsdosJson(List<String> cdImages) {
String script = buildCdOnlyAutoexecScript(cdImages);
return buildJsdosJsonRaw(script);
}
/** Split "dir/file.exe" into (dir, exe). Returns (null, file) if no directory separator. */
public static ExeParts splitExePath(String relExe) {
if (relExe == null || relExe.isBlank()) {
return new ExeParts(null, "");
}
int idx = relExe.replace('\\', '/').lastIndexOf('/');
if (idx >= 0) {
return new ExeParts(relExe.substring(0, idx), relExe.substring(idx + 1));
}
return new ExeParts(null, relExe);
}
/** Escape a string for embedding inside a JSON string value. */
public static String escapeJson(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
public record ExeParts(String dir, String exe) {}
// ─── Config Templates ────────────────────────────────────────
// language=ini
private static final String DOSBOX_CONF_TEMPLATE = """
[sdl]
autolock=true
usescancodes=true
output=surface
[cpu]
core=auto
cycles=auto
[mixer]
nosound=false
rate=44100
[sblaster]
sbtype=sb16
irq=7
dma=1
hdma=5
[dos]
xms=true
ems=true
umb=true
[dosbox]
memsize=64
[autoexec]
@echo off
mount c .
${CD_MOUNTS}c:
${EXEC_PATH}
""";
// language=ini
private static final String CD_ONLY_DOSBOX_CONF_TEMPLATE = """
[sdl]
autolock=true
usescancodes=true
output=surface
[cpu]
core=auto
cycles=auto
[mixer]
nosound=false
rate=44100
[sblaster]
sbtype=sb16
irq=7
dma=1
hdma=5
[dos]
xms=true
ems=true
umb=true
[dosbox]
memsize=64
[autoexec]
@echo off
mount c .
${CD_MOUNTS}c:
echo.
echo This game runs from the CD-ROM.
echo Type D: and press Enter, then look for the game executable (e.g. DOTT.EXE).
""";
// ─── Internal Helpers ────────────────────────────────────────
/**
* Build the CD mount lines for the autoexec section.
* Mounts ALL CD images, deduplicating by parent directory.
* Skips descriptor formats (.cue, .ccd) when a data format
* (.img, .iso, .bin) exists in the same directory.
*/
private static String buildCdMounts(List<String> cdImages) {
if (cdImages == null || cdImages.isEmpty()) return "";
// Deduplicate by parent directory — prefer data formats over descriptors
Set<String> seenDirs = new HashSet<>();
StringBuilder sb = new StringBuilder();
char driveLetter = 'D';
for (String img : cdImages) {
int slashIdx = img.lastIndexOf('/');
String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : "";
if (seenDirs.contains(parentDir)) continue;
String imgLower = img.toLowerCase();
// Skip descriptor formats if a data format exists in the same directory
if (imgLower.endsWith(".cue") || imgLower.endsWith(".ccd")) {
boolean hasData = cdImages.stream().anyMatch(i -> {
int is = i.lastIndexOf('/');
String ip = is >= 0 ? i.substring(0, is) : "";
String il = i.toLowerCase();
return ip.equals(parentDir)
&& (il.endsWith(".img") || il.endsWith(".iso") || il.endsWith(".bin"));
});
if (hasData) continue;
}
seenDirs.add(parentDir);
String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom";
sb.append("imgmount ").append(driveLetter).append(" \"")
.append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
return sb.toString();
}
private static String buildExecPath(String dir, String exe) {
if (dir == null) return exe + "\n";
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
}
private static String buildAutoexecScript(String dir, String exe, 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");
if (dir != null) {
script.append("path=c:\\\n");
script.append("cd ").append(dir).append("\n");
}
script.append(exe);
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) + "\"}}},"
+ "\"output\":{\"options\":{\"autolock\":{\"value\":true}}}}";
return json.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,152 @@
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
import java.util.regex.Pattern;
/**
* Detects and fixes hardcoded absolute DOS paths in game config files.
* Handles the common case where game archives contain paths like
* C:\\FALLOUT1\\MASTER.DAT that break after directory flattening.
*/
@ApplicationScoped
public class ConfigPatcher {
private static final Logger LOG = Logger.getLogger(ConfigPatcher.class.getName());
private static final Pattern DRIVE_PATH = Pattern.compile("[A-Za-z]:(\\\\|/)[^\\s\"\\r\\n]+");
/** Scan and fix absolute paths in all text files in the given directory. */
public void fixAbsolutePaths(Path extractDir) throws IOException {
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile)
.filter(f -> {
try { return isSmallTextFile(f); }
catch (IOException e) { return false; }
})
.forEach(f -> {
try {
String text = new String(Files.readAllBytes(f), StandardCharsets.ISO_8859_1);
String patched = patchText(text, extractDir);
if (!patched.equals(text)) {
Files.write(f, patched.getBytes(StandardCharsets.ISO_8859_1));
LOG.info("Patched absolute paths in " + f.getFileName());
}
} catch (IOException e) {
LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage());
}
});
}
}
private static boolean isSmallTextFile(Path f) throws IOException {
long size = Files.size(f);
if (size > 100 * 1024 || size == 0) return false;
try (var is = Files.newInputStream(f)) {
byte[] head = new byte[(int) Math.min(size, 4096)];
int read = is.readNBytes(head, 0, head.length);
for (int i = 0; i < read; i++) {
if (head[i] == 0) return false;
}
}
return true;
}
private static String patchText(String text, Path extractDir) {
var m = DRIVE_PATH.matcher(text);
var sb = new StringBuilder();
int lastEnd = 0;
Set<String> seenPrefixes = new HashSet<>();
while (m.find()) {
int start = m.start();
sb.append(text, lastEnd, start);
String fullPath = m.group();
String drive = fullPath.substring(0, 2);
if (!"C:".equalsIgnoreCase(drive)) {
sb.append(fullPath);
lastEnd = m.end();
continue;
}
String replacement = findReplacement(fullPath, extractDir, seenPrefixes);
sb.append(replacement);
lastEnd = m.end();
}
sb.append(text.substring(lastEnd));
return sb.toString();
}
private static String findReplacement(String fullPath, Path extractDir, Set<String> seenPrefixes) {
String drivePrefix = fullPath.substring(0, 2);
String sep = fullPath.substring(2, 3);
String relPath = fullPath.substring(3);
String splitter = "\\\\".equals(sep) ? "\\\\\\\\" : "/";
String[] parts = relPath.split(splitter, -1);
for (int skip = 0; skip < parts.length; skip++) {
StringBuilder suffix = new StringBuilder();
for (int j = skip; j < parts.length; j++) {
if (j > skip) suffix.append(sep);
suffix.append(parts[j]);
}
String suffixStr = suffix.toString();
if (suffixStr.isEmpty()) continue;
if (existsIgnoreCase(extractDir, suffixStr)) {
if (skip == 0) return fullPath;
StringBuilder stale = new StringBuilder(drivePrefix + sep);
for (int k = 0; k < skip; k++) {
if (k > 0) stale.append(sep);
stale.append(parts[k]);
}
stale.append(sep);
String staleStr = stale.toString();
if (seenPrefixes.contains(staleStr)) {
return "." + sep + suffixStr;
}
seenPrefixes.add(staleStr);
return "." + sep + suffixStr;
}
}
return fullPath;
}
private static boolean existsIgnoreCase(Path base, String relativePath) {
String sep = relativePath.contains("\\") ? "\\\\\\\\" : "/";
String[] parts = relativePath.split(sep, -1);
Path current = base;
try {
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if (part.isEmpty()) {
if (i == parts.length - 1) return Files.isDirectory(current);
continue;
}
Path found = null;
try (var dirStream = Files.list(current)) {
for (Path entry : dirStream.toList()) {
if (entry.getFileName().toString().equalsIgnoreCase(part)) {
found = entry;
break;
}
}
}
if (found == null) return false;
current = found;
}
} catch (IOException e) {
return false;
}
return true;
}
}
@@ -0,0 +1,143 @@
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* Detects game executables within extracted archives.
*/
@ApplicationScoped
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",
"uninst", "uninstall", "uninstaller",
"pksfx", "pkzip", "pkunzip", "sfx", "makesfx",
"unzip", "zip", "zip2exe", "arj", "arj32",
"lha", "lha32", "lzh", "rar", "unrar",
"ace", "unace", "zoo", "arc", "ha", "cab"
);
/** Collect all discoverable executables (relative paths) from the extracted directory. */
public List<String> findAll(Path dir) throws IOException {
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 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;
int dot = name.lastIndexOf('.');
String stem = dot >= 0 ? name.substring(0, dot) : name;
if (SKIP_EXE_NAMES.contains(stem))
return FileVisitResult.CONTINUE;
if (isLikelySelfExtractor(f))
return FileVisitResult.CONTINUE;
boolean installer = name.contains("install") || name.contains("setup") || name.contains("config");
int depth = f.getNameCount() - dir.getNameCount();
String detectedPlatform = platform.detect(f);
candidates.add(new Candidate(depth, installer, a.size(), f.toString(), detectedPlatform));
return FileVisitResult.CONTINUE;
}
});
if (candidates.isEmpty()) return null;
candidates.sort(Comparator
.comparingInt((Candidate c) -> c.isInstaller() ? 1 : 0)
.thenComparingInt(c -> {
if ("dos".equals(c.platform())) return 0;
if (c.platform() == null) return 1;
return 2;
})
.thenComparingLong(c -> -c.size())
.thenComparingInt(Candidate::depth));
return candidates.getFirst().path();
}
/** Find a setup/install/config executable. */
public String findSetup(Path dir) throws IOException {
record Candidate(int depth, long size, String path) {}
List<Candidate> candidates = new ArrayList<>();
List<String> patterns = List.of("setup", "install", "config", "custom");
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
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 + "-")) {
candidates.add(new Candidate(
f.getNameCount() - dir.getNameCount(), a.size(), f.toString()));
break;
}
}
return FileVisitResult.CONTINUE;
}
});
if (candidates.isEmpty()) return null;
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];
int read = fis.readNBytes(buf, 0, buf.length);
if (read < 0x40) return false;
if (buf[0] != 0x4D || buf[1] != 0x5A) return false;
String content = new String(buf, 0, read, StandardCharsets.ISO_8859_1);
if (content.contains("PKZIP") || content.contains("PKSFX")) return true;
for (int i = 0x40; i < read - 4; i++) {
if (buf[i] == 0x50 && (buf[i+1] & 0xFF) == 0x4B) {
int sig = (buf[i+2] & 0xFF) | ((buf[i+3] & 0xFF) << 8);
if (sig == 0x0403 || sig == 0x0201 || sig == 0x0605) return true;
}
}
return false;
} catch (IOException e) {
return false;
}
}
}
+2 -6
View File
@@ -17,7 +17,6 @@ public class Game {
private String publisher; private String publisher;
private String description; private String description;
private Double rating; private Double rating;
private List<String> platforms;
private String bundleFile; // relative path within game dir private String bundleFile; // relative path within game dir
@@ -26,13 +25,13 @@ public class Game {
/** Game bundle file size in bytes. Populated dynamically at load time (not persisted). */ /** Game bundle file size in bytes. Populated dynamically at load time (not persisted). */
private Long bundleSize; private Long bundleSize;
/** Currently selected main executable (relative path inside the bundle, e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE"). */ /** Currently selected main executable (relative path inside the bundle). */
private String executable; private String executable;
/** All discoverable executables in the bundle (for the UI picker). */ /** All discoverable executables in the bundle (for the UI picker). */
private List<String> executables; private List<String> executables;
/** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ (requires emulated Windows), null if unknown. */ /** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ */
private String platform; private String platform;
private Integer igdbId; private Integer igdbId;
@@ -85,9 +84,6 @@ public class Game {
public Double getRating() { return rating; } public Double getRating() { return rating; }
public void setRating(Double rating) { this.rating = 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 getBundleFile() { return bundleFile; } public String getBundleFile() { return bundleFile; }
public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; } public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
+60 -179
View File
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException; import java.io.IOException;
@@ -15,10 +16,13 @@ import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.logging.Logger;
/** Manages game metadata stored as JSON files on disk. */ /** Manages game metadata stored as JSON files on disk. */
@ApplicationScoped @ApplicationScoped
public class GameService { public class GameService implements GameStore {
private static final Logger LOG = Logger.getLogger(GameService.class.getName());
private final ObjectMapper mapper = new ObjectMapper() private final ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule()) .registerModule(new JavaTimeModule())
@@ -26,6 +30,12 @@ public class GameService {
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(SerializationFeature.INDENT_OUTPUT); .enable(SerializationFeature.INDENT_OUTPUT);
@Inject
ConfigBuilder config;
@Inject
CdImageService cdImageService;
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir; String dataDir;
@@ -111,7 +121,6 @@ public class GameService {
/** Delete a game and all its files. */ /** Delete a game and all its files. */
public boolean delete(String id) throws IOException { public boolean delete(String id) throws IOException {
// Remove game directory
Path dir = gameDir(id); Path dir = gameDir(id);
if (Files.exists(dir)) { if (Files.exists(dir)) {
Files.walkFileTree(dir, new SimpleFileVisitor<>() { Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@@ -148,12 +157,10 @@ public class GameService {
return result.isEmpty() ? "unknown" : result; return result.isEmpty() ? "unknown" : result;
} }
/** Check if a file exists. */ /**
public static boolean exists(Path p) { * Patch the .jsdos bundle to use a different main executable.
return Files.exists(p); * Transactional: patches into a temp file, then atomically replaces on success.
} */
/** Patch the .jsdos bundle to use a different main executable. */
public void setExecutable(String id, String executable) throws IOException { public void setExecutable(String id, String executable) throws IOException {
Game game = load(id); Game game = load(id);
String bundleFile = game.getBundleFile(); String bundleFile = game.getBundleFile();
@@ -167,181 +174,56 @@ public class GameService {
} }
// Build new config entries — preserve any CD images in the bundle // Build new config entries — preserve any CD images in the bundle
List<String> cdImages = findCdImagesInBundle(bundlePath); List<String> cdImages = cdImageService.findInBundle(bundlePath);
byte[] newDosboxConf = buildDosboxConfBytes(executable, cdImages); byte[] newDosboxConf = config.buildDosboxConfBytes(executable, cdImages);
byte[] newJsdosJson = buildJsdosJsonBytes(executable, cdImages); byte[] newJsdosJson = config.buildJsdosJson(executable, cdImages);
// Patch the ZIP: read old, write new with modified config entries // Transactional patch: write to temp, detect platform, then move
Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp"); Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp");
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); try {
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) { try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) {
java.util.zip.ZipEntry entry; java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) { while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName(); String name = entry.getName();
if (".jsdos/dosbox.conf".equals(name)) { if (".jsdos/dosbox.conf".equals(name)) {
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.putNextEntry(new java.util.zip.ZipEntry(name));
zos.write(newDosboxConf); zos.write(newDosboxConf);
zos.closeEntry(); zos.closeEntry();
} else if (".jsdos/jsdos.json".equals(name)) { } else if (".jsdos/jsdos.json".equals(name)) {
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.putNextEntry(new java.util.zip.ZipEntry(name));
zos.write(newJsdosJson); zos.write(newJsdosJson);
zos.closeEntry(); zos.closeEntry();
} else { } else {
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.putNextEntry(new java.util.zip.ZipEntry(name));
zis.transferTo(zos); zis.transferTo(zos);
zos.closeEntry(); zos.closeEntry();
}
zis.closeEntry();
} }
zis.closeEntry();
} }
// Detect platform from the new bundle before committing
String detectedPlatform = detectPlatformInBundle(executable, tmpPath);
// Atomic move on success
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
// Update metadata
game.setExecutable(executable);
game.setPlatform(detectedPlatform);
save(game);
} catch (Exception e) {
// Clean up temp file on failure — leave original untouched
try { Files.deleteIfExists(tmpPath); } catch (IOException ignored) {}
throw e instanceof IOException ioe ? ioe : new IOException(e);
} }
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING);
// Update metadata
game.setExecutable(executable);
game.setPlatform(detectPlatformSimple(executable, bundlePath));
save(game);
} }
/** Scan a .jsdos bundle for CD image files (preserves the case from the ZIP entry). */ /** Detect platform for an executable inside a .jsdos ZIP bundle. */
private List<String> findCdImagesInBundle(Path bundlePath) throws IOException { private String detectPlatformInBundle(String relExe, Path bundlePath) throws IOException {
Set<String> cdExt = Set.of(".iso", ".cue", ".img", ".ccd", ".bin");
List<String> cds = new ArrayList<>();
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String name = entry.getName().toLowerCase();
int dot = name.lastIndexOf('.');
if (dot >= 0 && cdExt.contains(name.substring(dot))) {
cds.add(entry.getName()); // preserve original filename casing
}
zis.closeEntry();
}
}
cds.sort(String::compareTo);
return cds;
}
/** Build a dosbox.conf for a given executable path and optional CD images. */
private byte[] buildDosboxConfBytes(String relExe, List<String> cdImages) {
var parts = splitExePath(relExe);
String dir = parts[0];
String exe = parts[1];
StringBuilder sb = new StringBuilder();
sb.append("[sdl]\n");
sb.append("autolock=true\n");
sb.append("usescancodes=true\n");
sb.append("output=surface\n");
sb.append("\n");
sb.append("[cpu]\n");
sb.append("core=auto\n");
sb.append("cycles=auto\n");
sb.append("\n");
sb.append("[mixer]\n");
sb.append("nosound=false\n");
sb.append("rate=44100\n");
sb.append("\n");
sb.append("[sblaster]\n");
sb.append("sbtype=sb16\n");
sb.append("irq=7\n");
sb.append("dma=1\n");
sb.append("hdma=5\n");
sb.append("\n");
sb.append("[dos]\n");
sb.append("xms=true\n");
sb.append("ems=true\n");
sb.append("umb=true\n");
sb.append("\n");
sb.append("[dosbox]\n");
sb.append("memsize=64\n");
sb.append("\n");
sb.append("[autoexec]\n");
sb.append("@echo off\n");
sb.append("mount c .\n");
// Mount CD images as D:, E:, … so games can find their CD.
// Deduplicate by parent directory — prefer data formats over descriptors.
Set<String> seenDirs = new java.util.HashSet<>();
char driveLetter = 'D';
for (String img : cdImages) {
int slashIdx = img.lastIndexOf('/');
String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : "";
if (seenDirs.contains(parentDir)) continue;
String imgLower = img.toLowerCase();
// Skip descriptor formats if a data format exists in the same directory
if (imgLower.endsWith(".cue") || imgLower.endsWith(".ccd")) {
boolean hasData = cdImages.stream().anyMatch(i -> {
int is = i.lastIndexOf('/');
String ip = is >= 0 ? i.substring(0, is) : "";
String il = i.toLowerCase();
return ip.equals(parentDir)
&& (il.endsWith(".img") || il.endsWith(".iso") || il.endsWith(".bin"));
});
if (hasData) continue;
}
seenDirs.add(parentDir);
String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom";
sb.append("imgmount ").append(driveLetter).append(" \"").append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
sb.append("c:\n");
if (dir != null) {
sb.append("path=c:\\\n");
sb.append("cd ").append(dir).append("\n");
}
sb.append(exe).append("\n");
return sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
/** Build jsdos.json for a given executable path and CD images. */
private byte[] buildJsdosJsonBytes(String relExe, List<String> cdImages) {
var parts = splitExePath(relExe);
StringBuilder script = new StringBuilder();
// js-dos v8 uses the jsdos.json autoexec.script instead of the
// dosbox.conf [autoexec] section, so mount/imgmount MUST be here.
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");
if (parts[0] != null) {
script.append("path=c:\\\n");
script.append("cd ").append(parts[0]).append("\n");
}
script.append(parts[1]);
String json = "{"
+ "\"autoexec\":{"
+ "\"options\":{\"script\":{\"value\":\"" + escapeJsonValue(script.toString()) + "\"}}"
+ "},"
+ "\"output\":{"
+ "\"options\":{\"autolock\":{\"value\":true}}}"
+ "}";
return json.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
/** Split "dir/file.exe" into ["dir", "file.exe"]. Returns [null, file] if no dir. */
private static String[] splitExePath(String relExe) {
int idx = relExe.replace('\\', '/').lastIndexOf('/');
if (idx >= 0) {
return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)};
}
return new String[]{null, relExe};
}
private static String escapeJsonValue(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
/** Quick platform detection for a given executable inside a .jsdos bundle. */
private String detectPlatformSimple(String relExe, Path bundlePath) throws IOException {
// Extract the executable from the ZIP and detect its platform
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) { try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
java.util.zip.ZipEntry entry; java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) { while ((entry = zis.getNextEntry()) != null) {
@@ -351,7 +233,6 @@ public class GameService {
if (read < 2) return null; if (read < 2) return null;
if (buf[0] != 0x4D || buf[1] != 0x5A) return null; if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
if (read < 0x40) return "dos"; if (read < 0x40) return "dos";
// Check for PE/NE at e_lfanew
int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8) int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8)
| ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24); | ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24);
if (peOffset < 0 || peOffset + 2 > read) return "dos"; if (peOffset < 0 || peOffset + 2 > read) return "dos";
@@ -384,9 +265,9 @@ public class GameService {
} }
// Build new config entries — preserve any CD images in the bundle // Build new config entries — preserve any CD images in the bundle
List<String> cdImages = findCdImagesInBundle(bundlePath); List<String> cdImages = cdImageService.findInBundle(bundlePath);
byte[] newDosboxConf = buildDosboxConfBytes(setupExe, cdImages); byte[] newDosboxConf = config.buildDosboxConfBytes(setupExe, cdImages);
byte[] newJsdosJson = buildJsdosJsonBytes(setupExe, cdImages); byte[] newJsdosJson = config.buildJsdosJson(setupExe, cdImages);
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
var zos = new java.util.zip.ZipOutputStream(out)) { var zos = new java.util.zip.ZipOutputStream(out)) {
@@ -0,0 +1,19 @@
package com.dostalgia;
import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
/**
* Contract for game metadata persistence.
* Decouples consumers (IgdbService, GameResource) from the concrete storage implementation.
*/
public interface GameStore {
Game load(String id) throws IOException, NoSuchFileException;
void save(Game game) throws IOException;
List<Game> list() throws IOException;
boolean delete(String id) throws IOException;
Path gameDir(String id);
Path gameJsonPath(String id);
}
+2 -3
View File
@@ -37,7 +37,7 @@ public class IgdbService {
Optional<String> clientSecret; Optional<String> clientSecret;
@Inject @Inject
GameService gameService; GameStore gameStore;
private String accessToken; private String accessToken;
private Instant tokenExpiry; private Instant tokenExpiry;
@@ -403,7 +403,6 @@ public class IgdbService {
if (!genres.isEmpty()) { if (!genres.isEmpty()) {
game.setGenre(genres.getFirst()); game.setGenre(genres.getFirst());
} }
game.setPlatforms(genres); // store all genres as platforms list too
} }
if (data.containsKey("igdb_id")) { if (data.containsKey("igdb_id")) {
game.setIgdbId((Integer) data.get("igdb_id")); game.setIgdbId((Integer) data.get("igdb_id"));
@@ -429,7 +428,7 @@ public class IgdbService {
.build(); .build();
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile( HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
gameService.gameDir(game.getId()).resolve("cover.jpg") gameStore.gameDir(game.getId()).resolve("cover.jpg")
)); ));
if (res.statusCode() == 200) { if (res.statusCode() == 200) {
@@ -0,0 +1,68 @@
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)) {
fis2.skip(peOffset);
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;
}
}
}
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.util.*;
import java.util.logging.Logger;
/**
* ZIP extraction, directory flattening, and .jsdos bundle creation.
*/
@ApplicationScoped
public class ZipService {
private static final Logger LOG = Logger.getLogger(ZipService.class.getName());
@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"
);
/** Unzip an archive to a destination directory (path traversal safe). */
public 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;
if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
}
zis.closeEntry();
}
}
}
/**
* 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; // multiple directories — abort
singleDir = entry;
}
}
}
if (singleDir == null) return;
LOG.info("Flattening root directory: " + singleDir.getFileName());
try (var walk = Files.walk(singleDir)) {
var list = walk.sorted(Comparator.reverseOrder()).toList();
for (Path f : list) {
if (f.equals(singleDir)) continue;
Path target = dir.resolve(singleDir.relativize(f));
if (Files.isDirectory(f)) {
Files.createDirectories(target);
Files.delete(f);
} else {
Files.createDirectories(target.getParent());
Files.move(f, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
Files.delete(singleDir);
LOG.info("Flattened " + singleDir.getFileName());
}
/**
* 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) {
String relExe = extractDir.relativize(Path.of(exePath)).toString();
Files.writeString(jsdos.resolve("dosbox.conf"), config.buildDosboxConf(relExe, cdImages));
Files.write(jsdos.resolve("jsdos.json"), config.buildJsdosJson(relExe, cdImages));
} else {
Files.writeString(jsdos.resolve("dosbox.conf"), config.buildCdOnlyDosboxConf(cdImages));
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
}
// 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>();
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile).forEach(f -> {
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
int idx = entryName.lastIndexOf('/');
while (idx >= 0) {
allDirs.add(entryName.substring(0, idx + 1));
idx = entryName.lastIndexOf('/', idx - 1);
}
});
}
// Phase 2: Write directory entries first (sorted → shallowest first)
for (String dir : allDirs) {
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
zos.closeEntry();
}
// Phase 3: .jsdos config files (must come before game files)
try (var walk = Files.walk(jsdos)) {
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)
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile)
.filter(f -> !f.startsWith(jsdos))
.filter(f -> {
String n = f.getFileName().toString().toLowerCase();
int dot = n.lastIndexOf('.');
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
})
.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);
}
});
}
}
// Clean up .jsdos config files from the extract dir
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
}