From 9b95e5b994571a8d7184deaab84492324e9fb1ac Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 29 May 2026 18:08:04 +0200 Subject: [PATCH] feat: stream setup .jsdos bundles on-the-fly instead of duplicating game data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add streamSetupBundle() to GameService — reads main bundle as zip, replaces config files (.jsdos/dosbox.conf, .jsdos/jsdos.json) with setup variants pointing to SETUP.EXE, streams directly to response - Add GET /api/games/{id}/setup-bundle endpoint that calls the above - Remove .setup.jsdos disk creation during upload — setupExe is still detected and stored in game metadata, but no duplicate bundle is saved - Update frontend api.js to point setupBundleUrl() to the new endpoint - Fix pre-existing backslash escaping bug in UploadResource.java path normalization (replace('\', '/') instead of replace('\\', '/')) --- frontend/src/lib/api.js | 2 +- src/main/java/com/dostalgia/GameResource.java | 28 +++++++++++++ src/main/java/com/dostalgia/GameService.java | 41 +++++++++++++++++++ .../java/com/dostalgia/UploadResource.java | 31 ++++++-------- 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 6ba1495..2cb803f 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -112,5 +112,5 @@ export function bundleUrl(game) { /** Get game bundle URL for setup mode (runs SETUP.EXE instead) */ export function setupBundleUrl(game) { if (!game) return null; - return `/games/${game.id}.setup.jsdos`; + return `/api/games/${game.id}/setup-bundle`; } diff --git a/src/main/java/com/dostalgia/GameResource.java b/src/main/java/com/dostalgia/GameResource.java index a10c23a..136819d 100644 --- a/src/main/java/com/dostalgia/GameResource.java +++ b/src/main/java/com/dostalgia/GameResource.java @@ -103,6 +103,34 @@ public class GameResource { } } + /** + * Stream a setup .jsdos bundle on-the-fly. No disk duplication — + * reads the main bundle and patches config files to point at the + * setup executable (SETUP.EXE, INSTALL.EXE, etc.). + */ + @GET + @Path("/{id}/setup-bundle") + @Produces("application/zip") + public Response setupBundle(@PathParam("id") String id) { + try { + var game = svc.load(id); + if (game.getSetupExe() == null || game.getSetupExe().isBlank()) { + return Response.status(404) + .entity(Map.of("error", "No setup executable configured for this game")) + .build(); + } + String filename = (game.getTitle() != null ? game.getTitle() : id) + ".setup.jsdos"; + StreamingOutput stream = output -> svc.streamSetupBundle(id, output); + return Response.ok(stream) + .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + .build(); + } catch (NoSuchFileException e) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + @GET @Path("/{id}/download") @Produces("application/zip") diff --git a/src/main/java/com/dostalgia/GameService.java b/src/main/java/com/dostalgia/GameService.java index 7f3d550..da78a70 100644 --- a/src/main/java/com/dostalgia/GameService.java +++ b/src/main/java/com/dostalgia/GameService.java @@ -10,6 +10,7 @@ import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.time.Instant; @@ -282,5 +283,45 @@ public class GameService { return null; } + /** + * Stream a setup .jsdos bundle on-the-fly by reading the main bundle + * and replacing config files to point at the setup executable. + * No data is written to disk — the modified ZIP is streamed directly. + */ + public void streamSetupBundle(String id, OutputStream out) throws IOException { + Game game = load(id); + String setupExe = game.getSetupExe(); + if (setupExe == null || setupExe.isBlank()) { + throw new IOException("No setup executable configured for this game"); + } + + Path bundlePath = gamesDir.resolve(game.getBundleFile()); + if (!Files.exists(bundlePath)) { + throw new NoSuchFileException("Bundle not found: " + game.getBundleFile()); + } + + byte[] newDosboxConf = buildDosboxConfBytes(setupExe); + byte[] newJsdosJson = buildJsdosJsonBytes(setupExe); + + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); + var zos = new java.util.zip.ZipOutputStream(out)) { + + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + if (".jsdos/dosbox.conf".equals(name)) { + zos.write(newDosboxConf); + } else if (".jsdos/jsdos.json".equals(name)) { + zos.write(newJsdosJson); + } else { + zis.transferTo(zos); + } + zos.closeEntry(); + zis.closeEntry(); + } + } + } + public Path getGamesDir() { return gamesDir; } } diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java index 6e3cdb6..17a5f31 100644 --- a/src/main/java/com/dostalgia/UploadResource.java +++ b/src/main/java/com/dostalgia/UploadResource.java @@ -103,29 +103,24 @@ public class UploadResource { // Detect platform (DOS vs Windows) by reading the main EXE header String platform = detectPlatform(Path.of(mainExe)); - // Create setup bundle only if the setup executable is itself DOS-compatible. + // Store setup executable path if found and DOS-compatible. // Some games (e.g. Fallout) ship a Windows SETUP.EXE even though the main // game is DOS — that setup would fail with "This program cannot run in DOS mode". - boolean hasSetupBundle = false; - if (setupExe != null) { - String setupPlatform = detectPlatform(Path.of(setupExe)); - if (!"windows".equals(setupPlatform)) { - Path setupBundlePath = svc.getGamesDir().resolve(gameId + ".setup.jsdos"); - createBundle(extractDir, setupExe, setupBundlePath); - hasSetupBundle = true; - } - } + // The .setup.jsdos bundle is now generated on-the-fly — no disk duplication needed. // Save metadata Game game = new Game(gameId, title); game.setBundleFile(bundleFile); game.setPlatform(platform); // Store the selected executable and the full list for the UI picker - String relMain = extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/'); + String relMain = extractDir.relativize(Path.of(mainExe)).toString().replace('\', '/'); game.setExecutable(relMain); game.setExecutables(findAllExecutables(extractDir)); - if (hasSetupBundle) { - game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString()); + if (setupExe != null) { + String setupPlatform = detectPlatform(Path.of(setupExe)); + if (!"windows".equals(setupPlatform)) { + game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString()); + } } game.setReady(true); svc.save(game); @@ -254,7 +249,7 @@ public class UploadResource { 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('\\', '/')); + result.add(dir.relativize(f).toString().replace('\', '/')); } return FileVisitResult.CONTINUE; } @@ -433,7 +428,7 @@ public class UploadResource { var allDirs = new java.util.TreeSet(); try (var walk = Files.walk(extractDir)) { walk.filter(Files::isRegularFile).forEach(f -> { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + String entryName = extractDir.relativize(f).toString().replace('\', '/'); int idx = entryName.lastIndexOf('/'); while (idx >= 0) { allDirs.add(entryName.substring(0, idx + 1)); @@ -454,7 +449,7 @@ public class UploadResource { .sorted() .forEach(f -> { try { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + String entryName = extractDir.relativize(f).toString().replace('\', '/'); zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); Files.copy(f, zos); zos.closeEntry(); @@ -476,7 +471,7 @@ public class UploadResource { .sorted() .forEach(f -> { try { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + String entryName = extractDir.relativize(f).toString().replace('\', '/'); zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); Files.copy(f, zos); zos.closeEntry(); @@ -501,7 +496,7 @@ public class UploadResource { * If relExe is just a filename, returns {null, relExe}. */ private static String[] splitDirExe(String relExe) { - int idx = relExe.replace('\\', '/').lastIndexOf('/'); + int idx = relExe.replace('\', '/').lastIndexOf('/'); if (idx >= 0) { return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)}; }