refactor: apply SOLID/KISS/YAGNI principles
Build & Deploy / build-and-deploy (push) Failing after 59s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ public class Game {
|
||||
private String publisher;
|
||||
private String description;
|
||||
private Double rating;
|
||||
private List<String> platforms;
|
||||
|
||||
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). */
|
||||
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;
|
||||
|
||||
/** All discoverable executables in the bundle (for the UI picker). */
|
||||
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 Integer igdbId;
|
||||
@@ -85,9 +84,6 @@ public class Game {
|
||||
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 getBundleFile() { return bundleFile; }
|
||||
public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -15,10 +16,13 @@ import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Manages game metadata stored as JSON files on disk. */
|
||||
@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()
|
||||
.registerModule(new JavaTimeModule())
|
||||
@@ -26,6 +30,12 @@ public class GameService {
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
@Inject
|
||||
ConfigBuilder config;
|
||||
|
||||
@Inject
|
||||
CdImageService cdImageService;
|
||||
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
@@ -111,7 +121,6 @@ public class GameService {
|
||||
|
||||
/** 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<>() {
|
||||
@@ -148,12 +157,10 @@ public class GameService {
|
||||
return result.isEmpty() ? "unknown" : result;
|
||||
}
|
||||
|
||||
/** Check if a file exists. */
|
||||
public static boolean exists(Path p) {
|
||||
return Files.exists(p);
|
||||
}
|
||||
|
||||
/** Patch the .jsdos bundle to use a different main executable. */
|
||||
/**
|
||||
* Patch the .jsdos bundle to use a different main executable.
|
||||
* Transactional: patches into a temp file, then atomically replaces on success.
|
||||
*/
|
||||
public void setExecutable(String id, String executable) throws IOException {
|
||||
Game game = load(id);
|
||||
String bundleFile = game.getBundleFile();
|
||||
@@ -167,12 +174,13 @@ public class GameService {
|
||||
}
|
||||
|
||||
// Build new config entries — preserve any CD images in the bundle
|
||||
List<String> cdImages = findCdImagesInBundle(bundlePath);
|
||||
byte[] newDosboxConf = buildDosboxConfBytes(executable, cdImages);
|
||||
byte[] newJsdosJson = buildJsdosJsonBytes(executable, cdImages);
|
||||
List<String> cdImages = cdImageService.findInBundle(bundlePath);
|
||||
byte[] newDosboxConf = config.buildDosboxConfBytes(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");
|
||||
try {
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
|
||||
var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) {
|
||||
|
||||
@@ -196,152 +204,26 @@ public class GameService {
|
||||
}
|
||||
}
|
||||
|
||||
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
// 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(detectPlatformSimple(executable, bundlePath));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan a .jsdos bundle for CD image files (preserves the case from the ZIP entry). */
|
||||
private List<String> findCdImagesInBundle(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
|
||||
/** Detect platform for an executable inside a .jsdos ZIP bundle. */
|
||||
private String detectPlatformInBundle(String relExe, Path bundlePath) throws IOException {
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
@@ -351,7 +233,6 @@ public class GameService {
|
||||
if (read < 2) return null;
|
||||
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
|
||||
if (read < 0x40) return "dos";
|
||||
// Check for PE/NE at e_lfanew
|
||||
int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8)
|
||||
| ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24);
|
||||
if (peOffset < 0 || peOffset + 2 > read) return "dos";
|
||||
@@ -384,9 +265,9 @@ public class GameService {
|
||||
}
|
||||
|
||||
// Build new config entries — preserve any CD images in the bundle
|
||||
List<String> cdImages = findCdImagesInBundle(bundlePath);
|
||||
byte[] newDosboxConf = buildDosboxConfBytes(setupExe, cdImages);
|
||||
byte[] newJsdosJson = buildJsdosJsonBytes(setupExe, cdImages);
|
||||
List<String> cdImages = cdImageService.findInBundle(bundlePath);
|
||||
byte[] newDosboxConf = config.buildDosboxConfBytes(setupExe, cdImages);
|
||||
byte[] newJsdosJson = config.buildJsdosJson(setupExe, cdImages);
|
||||
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath));
|
||||
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);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public class IgdbService {
|
||||
Optional<String> clientSecret;
|
||||
|
||||
@Inject
|
||||
GameService gameService;
|
||||
GameStore gameStore;
|
||||
|
||||
private String accessToken;
|
||||
private Instant tokenExpiry;
|
||||
@@ -403,7 +403,6 @@ public class IgdbService {
|
||||
if (!genres.isEmpty()) {
|
||||
game.setGenre(genres.getFirst());
|
||||
}
|
||||
game.setPlatforms(genres); // store all genres as platforms list too
|
||||
}
|
||||
if (data.containsKey("igdb_id")) {
|
||||
game.setIgdbId((Integer) data.get("igdb_id"));
|
||||
@@ -429,7 +428,7 @@ public class IgdbService {
|
||||
.build();
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user