feat: stream setup .jsdos bundles on-the-fly instead of duplicating game data
Build & Deploy / build-and-deploy (push) Failing after 1m20s
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:
@@ -112,5 +112,5 @@ export function bundleUrl(game) {
|
|||||||
/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */
|
/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */
|
||||||
export function setupBundleUrl(game) {
|
export function setupBundleUrl(game) {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
return `/games/${game.id}.setup.jsdos`;
|
return `/api/games/${game.id}/setup-bundle`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
@GET
|
||||||
@Path("/{id}/download")
|
@Path("/{id}/download")
|
||||||
@Produces("application/zip")
|
@Produces("application/zip")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import jakarta.enterprise.context.ApplicationScoped;
|
|||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
import java.nio.file.attribute.BasicFileAttributes;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -282,5 +283,45 @@ public class GameService {
|
|||||||
return null;
|
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; }
|
public Path getGamesDir() { return gamesDir; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,30 +103,25 @@ public class UploadResource {
|
|||||||
// Detect platform (DOS vs Windows) by reading the main EXE header
|
// Detect platform (DOS vs Windows) by reading the main EXE header
|
||||||
String platform = detectPlatform(Path.of(mainExe));
|
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
|
// 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".
|
// game is DOS — that setup would fail with "This program cannot run in DOS mode".
|
||||||
boolean hasSetupBundle = false;
|
// The .setup.jsdos bundle is now generated on-the-fly — no disk duplication needed.
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save metadata
|
// Save metadata
|
||||||
Game game = new Game(gameId, title);
|
Game game = new Game(gameId, title);
|
||||||
game.setBundleFile(bundleFile);
|
game.setBundleFile(bundleFile);
|
||||||
game.setPlatform(platform);
|
game.setPlatform(platform);
|
||||||
// Store the selected executable and the full list for the UI picker
|
// 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.setExecutable(relMain);
|
||||||
game.setExecutables(findAllExecutables(extractDir));
|
game.setExecutables(findAllExecutables(extractDir));
|
||||||
if (hasSetupBundle) {
|
if (setupExe != null) {
|
||||||
|
String setupPlatform = detectPlatform(Path.of(setupExe));
|
||||||
|
if (!"windows".equals(setupPlatform)) {
|
||||||
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
game.setReady(true);
|
game.setReady(true);
|
||||||
svc.save(game);
|
svc.save(game);
|
||||||
|
|
||||||
@@ -254,7 +249,7 @@ public class UploadResource {
|
|||||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||||
String name = f.getFileName().toString().toLowerCase();
|
String name = f.getFileName().toString().toLowerCase();
|
||||||
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
|
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;
|
return FileVisitResult.CONTINUE;
|
||||||
}
|
}
|
||||||
@@ -433,7 +428,7 @@ public class UploadResource {
|
|||||||
var allDirs = new java.util.TreeSet<String>();
|
var allDirs = new java.util.TreeSet<String>();
|
||||||
try (var walk = Files.walk(extractDir)) {
|
try (var walk = Files.walk(extractDir)) {
|
||||||
walk.filter(Files::isRegularFile).forEach(f -> {
|
walk.filter(Files::isRegularFile).forEach(f -> {
|
||||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
String entryName = extractDir.relativize(f).toString().replace('\', '/');
|
||||||
int idx = entryName.lastIndexOf('/');
|
int idx = entryName.lastIndexOf('/');
|
||||||
while (idx >= 0) {
|
while (idx >= 0) {
|
||||||
allDirs.add(entryName.substring(0, idx + 1));
|
allDirs.add(entryName.substring(0, idx + 1));
|
||||||
@@ -454,7 +449,7 @@ public class UploadResource {
|
|||||||
.sorted()
|
.sorted()
|
||||||
.forEach(f -> {
|
.forEach(f -> {
|
||||||
try {
|
try {
|
||||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
String entryName = extractDir.relativize(f).toString().replace('\', '/');
|
||||||
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
||||||
Files.copy(f, zos);
|
Files.copy(f, zos);
|
||||||
zos.closeEntry();
|
zos.closeEntry();
|
||||||
@@ -476,7 +471,7 @@ public class UploadResource {
|
|||||||
.sorted()
|
.sorted()
|
||||||
.forEach(f -> {
|
.forEach(f -> {
|
||||||
try {
|
try {
|
||||||
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
|
String entryName = extractDir.relativize(f).toString().replace('\', '/');
|
||||||
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
|
||||||
Files.copy(f, zos);
|
Files.copy(f, zos);
|
||||||
zos.closeEntry();
|
zos.closeEntry();
|
||||||
@@ -501,7 +496,7 @@ public class UploadResource {
|
|||||||
* If relExe is just a filename, returns {null, relExe}.
|
* If relExe is just a filename, returns {null, relExe}.
|
||||||
*/
|
*/
|
||||||
private static String[] splitDirExe(String relExe) {
|
private static String[] splitDirExe(String relExe) {
|
||||||
int idx = relExe.replace('\\', '/').lastIndexOf('/');
|
int idx = relExe.replace('\', '/').lastIndexOf('/');
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)};
|
return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user