feat: stream setup .jsdos bundles on-the-fly instead of duplicating game data
Build & Deploy / build-and-deploy (push) Failing after 1m20s

- 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('\\', '/'))
This commit is contained in:
Hermes Agent
2026-05-29 18:08:04 +02:00
parent 5f69ea5ead
commit 9b95e5b994
4 changed files with 83 additions and 19 deletions
@@ -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")
@@ -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; }
}
+13 -18
View File
@@ -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<String>();
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)};
}