Add executable picker in game edit UI
Build & Deploy / build-and-deploy (push) Successful in 49s

- Added 'executables' (full list) and 'executable' (selected) fields to Game
- Upload now stores ALL discoverable .exe/.com/.bat files in game metadata
- Added setExecutable() to GameService that patches the .jsdos bundle
  (updates dosbox.conf + jsdos.json inside the ZIP) when changing executable
- GameResource PATCH handler delegates executable changes to bundle patching
- GameDetail edit mode shows a radio-button picker of all executables
- Heuristics remain as first-guess default, user can always override
- No re-upload needed to fix wrong executable selection
This commit is contained in:
Hermes Agent
2026-05-29 10:31:25 +02:00
parent e262a46487
commit 02d0c4f377
5 changed files with 251 additions and 1 deletions
+78
View File
@@ -17,6 +17,7 @@
let editPublisher = $state(""); let editPublisher = $state("");
let editDescription = $state(""); let editDescription = $state("");
let editPlatform = $state(""); let editPlatform = $state("");
let editExecutable = $state("");
// IGDB scrape state // IGDB scrape state
let scraping = $state(false); let scraping = $state(false);
@@ -65,6 +66,7 @@
editPublisher = game.publisher || ""; editPublisher = game.publisher || "";
editDescription = game.description || ""; editDescription = game.description || "";
editPlatform = game.platform || ""; editPlatform = game.platform || "";
editExecutable = game.executable || "";
editing = true; editing = true;
} }
@@ -79,6 +81,7 @@
publisher: editPublisher || undefined, publisher: editPublisher || undefined,
description: editDescription || undefined, description: editDescription || undefined,
platform: editPlatform || undefined, platform: editPlatform || undefined,
executable: editExecutable || undefined,
}); });
editing = false; editing = false;
} catch (e) { } catch (e) {
@@ -207,6 +210,25 @@
<option value="windows">Windows</option> <option value="windows">Windows</option>
</select> </select>
</div> </div>
{#if game.executables && game.executables.length > 0}
<div class="edit-exec-section">
<span class="edit-exec-label">Main executable:</span>
<div class="edit-exec-list">
{#each game.executables as exe}
<label class="edit-exec-option" class:selected={editExecutable === exe}>
<input
type="radio"
name="executable"
value={exe}
checked={editExecutable === exe}
onchange={() => editExecutable = exe}
/>
<span class="edit-exec-path">{exe}</span>
</label>
{/each}
</div>
</div>
{/if}
<div class="edit-actions"> <div class="edit-actions">
<button class="btn btn-primary" onclick={saveEdit} disabled={saving}> <button class="btn btn-primary" onclick={saveEdit} disabled={saving}>
{saving ? "Saving..." : "Save"} {saving ? "Saving..." : "Save"}
@@ -601,6 +623,62 @@
color: #66ff66; color: #66ff66;
} }
/* ─── Executable Picker ────────────────────── */
.edit-exec-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.edit-exec-label {
display: block;
font-size: 0.8rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.edit-exec-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
}
.edit-exec-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: var(--font-mono);
background: var(--surface);
border: 1px solid var(--border);
transition: border-color 0.15s, background 0.15s;
}
.edit-exec-option:hover {
border-color: var(--phosphor-dim);
background: var(--surface-hover);
}
.edit-exec-option.selected {
border-color: var(--phosphor);
background: var(--phosphor-burn);
}
.edit-exec-option input[type="radio"] {
accent-color: var(--phosphor);
margin: 0;
flex-shrink: 0;
}
.edit-exec-path {
word-break: break-all;
color: var(--text);
}
.edit-exec-option.selected .edit-exec-path {
color: var(--phosphor-glow);
}
/* ─── Responsive ────────────────────────────────── */ /* ─── Responsive ────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {
.detail { padding: 16px 0; } .detail { padding: 16px 0; }
+12
View File
@@ -23,6 +23,12 @@ public class Game {
private String setupExe; // relative path to SETUP.EXE (null if none) private String setupExe; // relative path to SETUP.EXE (null if none)
/** Currently selected main executable (relative path inside the bundle, e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE"). */
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+ (requires emulated Windows), null if unknown. */
private String platform; private String platform;
@@ -85,6 +91,12 @@ public class Game {
public String getSetupExe() { return setupExe; } public String getSetupExe() { return setupExe; }
public void setSetupExe(String setupExe) { this.setupExe = setupExe; } public void setSetupExe(String setupExe) { this.setupExe = setupExe; }
public String getExecutable() { return executable; }
public void setExecutable(String executable) { this.executable = executable; }
public List<String> getExecutables() { return executables; }
public void setExecutables(List<String> executables) { this.executables = executables; }
/** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */ /** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */
public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); } public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); }
@@ -84,7 +84,17 @@ public class GameResource {
if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating"))); if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating")));
if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform")); if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform"));
// Changing the executable requires patching the .jsdos bundle
if (updates.containsKey("executable")) {
String newExe = (String) updates.get("executable");
if (newExe != null && !newExe.isBlank()) {
svc.setExecutable(id, newExe);
}
// Reload after bundle patch to get updated metadata
game = svc.load(id);
} else {
svc.save(game); svc.save(game);
}
return Response.ok(game).build(); return Response.ok(game).build();
} catch (NoSuchFileException e) { } catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build(); return Response.status(404).entity(Map.of("error", "Game not found")).build();
@@ -146,5 +146,134 @@ public class GameService {
return Files.exists(p); return Files.exists(p);
} }
/** Patch the .jsdos bundle to use a different main executable. */
public void setExecutable(String id, String executable) throws IOException {
Game game = load(id);
String bundleFile = game.getBundleFile();
if (bundleFile == null) {
throw new IOException("Game has no bundle file");
}
Path bundlePath = gamesDir.resolve(bundleFile);
if (!Files.exists(bundlePath)) {
throw new NoSuchFileException("Bundle not found: " + bundleFile);
}
// Build new config entries
byte[] newDosboxConf = buildDosboxConfBytes(executable);
byte[] newJsdosJson = buildJsdosJsonBytes(executable);
// Patch the ZIP: read old, write new with modified config entries
Path tmpPath = bundlePath.resolveSibling(bundleFile + ".tmp");
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;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
if (".jsdos/dosbox.conf".equals(name)) {
zos.putNextEntry(new java.util.zip.ZipEntry(name));
zos.write(newDosboxConf);
zos.closeEntry();
} else if (".jsdos/jsdos.json".equals(name)) {
zos.putNextEntry(new java.util.zip.ZipEntry(name));
zos.write(newJsdosJson);
zos.closeEntry();
} else {
zos.putNextEntry(new java.util.zip.ZipEntry(name));
zis.transferTo(zos);
zos.closeEntry();
}
zis.closeEntry();
}
}
Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING);
// Update metadata
game.setExecutable(executable);
game.setPlatform(detectPlatformSimple(executable, bundlePath));
save(game);
}
/** Build a dosbox.conf for a given executable path. */
private byte[] buildDosboxConfBytes(String relExe) {
var parts = splitExePath(relExe);
String dir = parts[0];
String exe = parts[1];
StringBuilder sb = new StringBuilder();
sb.append("[sdl]\nautolock=true\nusescancodes=true\noutput=surface\n\n");
sb.append("[cpu]\ncore=auto\ncycles=auto\n\n");
sb.append("[mixer]\nnosound=false\nrate=44100\n\n");
sb.append("[sblaster]\nsbtype=sb16\nirq=7\ndma=1\nhdma=5\n\n");
sb.append("[dos]\nxms=true\nems=true\numb=true\n\n");
sb.append("[autoexec]\n@echo off\nmount c .\nc:\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. */
private byte[] buildJsdosJsonBytes(String relExe) {
var parts = splitExePath(relExe);
StringBuilder script = new StringBuilder();
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))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals(relExe.replace('\\', '/'))) {
byte[] buf = new byte[0x80];
int read = zis.readNBytes(buf, 0, buf.length);
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";
byte sig1 = buf[peOffset], sig2 = buf[peOffset + 1];
if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45))
return "windows";
return "dos";
}
zis.closeEntry();
}
}
return null;
}
public Path getGamesDir() { return gamesDir; } public Path getGamesDir() { return gamesDir; }
} }
@@ -120,6 +120,10 @@ public class UploadResource {
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
String relMain = extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/');
game.setExecutable(relMain);
game.setExecutables(findAllExecutables(extractDir));
if (hasSetupBundle) { if (hasSetupBundle) {
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString()); game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
} }
@@ -242,6 +246,23 @@ public class UploadResource {
// ─── Executable Detection ──────────────────────────────────── // ─── Executable Detection ────────────────────────────────────
/** Collect all discoverable executables (relative paths) from the extracted directory. */
private List<String> findAllExecutables(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;
}
private String findMainExe(Path dir) throws IOException { private String findMainExe(Path dir) throws IOException {
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {} record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
List<Candidate> candidates = new ArrayList<>(); List<Candidate> candidates = new ArrayList<>();