feat: SETUP.EXE detection + separate setup .jsdos bundle
Build & Deploy / build-and-deploy (push) Successful in 1m1s

When a game archive contains SETUP.EXE, INSTALL.EXE, CONFIG.EXE or
CUSTOM.EXE (or .com/.bat variants), the upload now:

- Detects and stores the path in game metadata (setup_exe / has_setup)
- Generates a second .jsdos bundle with SETUP.EXE as autoexec:
  - Standard mode: {gameId}.setup.jsdos (full game + setup autoexec)
  - Sockdrive mode: .jsdos-setup/ directory alongside .jsdos/
- Both bundles share the same game files; only the autoexec differs

Frontend changes:
- Router strips query params from hash so ?setup=1 doesn't leak into id
- GameDetail shows a "Setup" button between Play and Edit (only when
  game.has_setup is true), navigating to /play/{id}?setup=1
- Play.svelte detects ?setup=1, loads setupBundleUrl() instead, and
  shows setup-specific overlay text

Persistence is handled automatically by js-dos v8 via the built-in
auto-save mechanism (ci.persist() → browser OPFS), so any config
changes made via SETUP.EXE survive across sessions.
This commit is contained in:
David Alvarez
2026-05-27 16:49:15 +02:00
parent 00267da3dd
commit 5f5cb8411f
7 changed files with 185 additions and 8 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
function parseHash() { function parseHash() {
const hash = window.location.hash.slice(1) || "/"; const hash = window.location.hash.slice(1) || "/";
const parts = hash.split("/").filter(Boolean); const [pathPart] = hash.split("?"); // strip query params
const parts = pathPart.split("/").filter(Boolean);
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) { if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
route = "/"; route = "/";
gameId = null; gameId = null;
+10 -1
View File
@@ -102,7 +102,7 @@ export function artworkUrl(path) {
return `/artwork/${path}`; return `/artwork/${path}`;
} }
/** Get game bundle URL */ /** Get game bundle URL for normal play */
export function bundleUrl(game) { export function bundleUrl(game) {
if (!game) return null; if (!game) return null;
if (game.bundle_type === "sockdrive") { if (game.bundle_type === "sockdrive") {
@@ -110,3 +110,12 @@ export function bundleUrl(game) {
} }
return `/games/${game.id}.jsdos`; return `/games/${game.id}.jsdos`;
} }
/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */
export function setupBundleUrl(game) {
if (!game) return null;
if (game.bundle_type === "sockdrive") {
return `/api/sockdrive/${game.id}/file?path=.jsdos-setup/dosbox.conf`;
}
return `/games/${game.id}.setup.jsdos`;
}
+16 -1
View File
@@ -1,5 +1,5 @@
<script> <script>
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, downloadGame, bundleUrl, artworkUrl } from "../lib/api.js"; import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, downloadGame, bundleUrl, setupBundleUrl, artworkUrl } from "../lib/api.js";
import { push } from "../lib/router.js"; import { push } from "../lib/router.js";
let { id } = $props(); let { id } = $props();
@@ -98,6 +98,10 @@
push(`/play/${id}`); push(`/play/${id}`);
} }
function handleSetup() {
push(`/play/${id}?setup=1`);
}
// ─── IGDB Scrape ─────────────────────────────────────────── // ─── IGDB Scrape ───────────────────────────────────────────
async function handleSearchIGDB() { async function handleSearchIGDB() {
@@ -205,6 +209,9 @@
{#if game.ready} {#if game.ready}
<button class="btn btn-primary" onclick={handlePlay}> Play</button> <button class="btn btn-primary" onclick={handlePlay}> Play</button>
{/if} {/if}
{#if game.has_setup}
<button class="btn btn-setup" onclick={handleSetup}>🛠 Setup</button>
{/if}
<button class="btn" onclick={startEdit}> Edit</button> <button class="btn" onclick={startEdit}> Edit</button>
<button class="btn" onclick={handleDownload}> Download</button> <button class="btn" onclick={handleDownload}> Download</button>
<button class="btn btn-danger" onclick={handleDelete}> Delete</button> <button class="btn btn-danger" onclick={handleDelete}> Delete</button>
@@ -564,4 +571,12 @@
.edit-row { flex-direction: column; } .edit-row { flex-direction: column; }
.edit-row input { width: 100% !important; } .edit-row input { width: 100% !important; }
} }
.btn-setup {
border-color: var(--phosphor-dark);
color: var(--phosphor);
}
.btn-setup:hover {
background: var(--phosphor-burn);
box-shadow: 0 0 12px var(--phosphor-dark);
}
</style> </style>
+19 -3
View File
@@ -1,9 +1,12 @@
<script> <script>
import { getGame, bundleUrl } from "../lib/api.js"; import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js";
import { push } from "../lib/router.js"; import { push } from "../lib/router.js";
let { id } = $props(); let { id } = $props();
// Read query params from the hash — window.location is available after mount
let isSetup = $state(false);
let game = $state(null); let game = $state(null);
let loading = $state(true); let loading = $state(true);
let error = $state(""); let error = $state("");
@@ -16,6 +19,8 @@
async function load() { async function load() {
loading = true; loading = true;
// Detect ?setup=1 from the hash-based URL
isSetup = window.location.hash.includes("setup=1");
try { try {
game = await getGame(id); game = await getGame(id);
} catch (e) { } catch (e) {
@@ -54,7 +59,7 @@
// Start the DOS emulator // Start the DOS emulator
try { try {
const url = bundleUrl(game); const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle"); if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
dosCI = await window.Dos(dosContainer, { url }); dosCI = await window.Dos(dosContainer, { url });
running = true; running = true;
@@ -71,6 +76,10 @@
window.location.reload(); window.location.reload();
} }
function goBack() {
push(`/game/${id}`);
}
$effect(() => { load(); }); $effect(() => { load(); });
// Auto-start when both game data and the container exist // Auto-start when both game data and the container exist
@@ -98,7 +107,9 @@
<!-- Top bar: shown when game is loaded --> <!-- Top bar: shown when game is loaded -->
<div class="play-header" class:active={game}> <div class="play-header" class:active={game}>
{#if game} {#if game}
<button class="link-button" onclick={stopEmulator}> {game.title}</button> <button class="link-button" onclick={stopEmulator}>
{isSetup ? "Back" : game.title}
</button>
{#if running} {#if running}
<button class="btn stop-btn" onclick={stopEmulator}> Stop</button> <button class="btn stop-btn" onclick={stopEmulator}> Stop</button>
{/if} {/if}
@@ -119,8 +130,13 @@
{:else if booting} {:else if booting}
<div class="overlay-icon">⚙️</div> <div class="overlay-icon">⚙️</div>
<h2>{game?.title || ""}</h2> <h2>{game?.title || ""}</h2>
{#if isSetup}
<p>Starting setup utility...</p>
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
{:else}
<p>Starting emulator...</p> <p>Starting emulator...</p>
<p class="save-hint">💾 Game saves are stored automatically</p> <p class="save-hint">💾 Game saves are stored automatically</p>
{/if}
{:else if error} {:else if error}
<div class="overlay-icon">⚠️</div> <div class="overlay-icon">⚠️</div>
<h2>Failed to start</h2> <h2>Failed to start</h2>
+8
View File
@@ -22,6 +22,8 @@ public class Game {
private String bundleType; // "standard" or "sockdrive" private String bundleType; // "standard" or "sockdrive"
private String bundleFile; // relative path within game dir private String bundleFile; // relative path within game dir
private String setupExe; // relative path to SETUP.EXE (null if none)
private Integer igdbId; private Integer igdbId;
private String igdbCoverId; private String igdbCoverId;
@@ -81,6 +83,12 @@ public class Game {
public String getBundleFile() { return bundleFile; } public String getBundleFile() { return bundleFile; }
public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; } public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
public String getSetupExe() { return setupExe; }
public void setSetupExe(String setupExe) { this.setupExe = setupExe; }
/** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */
public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); }
public Integer getIgdbId() { return igdbId; } public Integer getIgdbId() { return igdbId; }
public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; } public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
@@ -121,6 +121,24 @@ public class GameService {
} }
// Remove flat .jsdos bundle // Remove flat .jsdos bundle
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos")); Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
// Remove flat setup .jsdos bundle (if any)
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
// Remove .jsdos-setup dir (sockdrive mode, if any)
Path setupDir = dir.resolve(".jsdos-setup");
if (Files.exists(setupDir)) {
Files.walkFileTree(setupDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
}
return true; return true;
} }
@@ -73,6 +73,9 @@ public class UploadResource {
.build(); .build();
} }
// Find setup executable (SETUP.EXE, INSTALL.EXE, CONFIG.EXE, etc.)
String setupExe = findSetupExe(extractDir);
// Determine bundle strategy // Determine bundle strategy
double sizeMb = dirSizeMB(extractDir); double sizeMb = dirSizeMB(extractDir);
String bundleType = sizeMb > SOCKDRIVE_THRESHOLD_MB ? "sockdrive" : "standard"; String bundleType = sizeMb > SOCKDRIVE_THRESHOLD_MB ? "sockdrive" : "standard";
@@ -92,17 +95,33 @@ public class UploadResource {
Files.writeString(jsdos.resolve("dosbox.conf"), buildDosboxConf(relExe)); Files.writeString(jsdos.resolve("dosbox.conf"), buildDosboxConf(relExe));
Files.write(jsdos.resolve("jsdos.json"), buildJsdosJson(relExe)); Files.write(jsdos.resolve("jsdos.json"), buildJsdosJson(relExe));
bundleFile = ".jsdos/dosbox.conf"; bundleFile = ".jsdos/dosbox.conf";
// Write setup config if a setup executable was found
if (setupExe != null) {
Path setupDir = gameDir.resolve(".jsdos-setup");
Files.createDirectories(setupDir);
String relSetup = extractDir.relativize(Path.of(setupExe)).toString();
Files.writeString(setupDir.resolve("dosbox.conf"), buildSetupDosboxConf(relSetup));
Files.write(setupDir.resolve("jsdos.json"), buildSetupJsdosJson(relSetup));
}
} else { } else {
// Create .jsdos bundle (flat in games dir) // Create .jsdos bundle (flat in games dir)
bundleFile = gameId + ".jsdos"; bundleFile = gameId + ".jsdos";
Path bundlePath = svc.getGamesDir().resolve(bundleFile); Path bundlePath = svc.getGamesDir().resolve(bundleFile);
createBundle(extractDir, mainExe, bundlePath); createBundle(extractDir, mainExe, bundlePath);
// Create setup bundle if a setup executable was found
if (setupExe != null) {
Path setupBundlePath = svc.getGamesDir().resolve(gameId + ".setup.jsdos");
createBundle(extractDir, setupExe, setupBundlePath);
}
} }
// Save metadata // Save metadata
Game game = new Game(gameId, title); Game game = new Game(gameId, title);
game.setBundleType(bundleType); game.setBundleType(bundleType);
game.setBundleFile(bundleFile); game.setBundleFile(bundleFile);
game.setSetupExe(setupExe != null ? extractDir.relativize(Path.of(setupExe)).toString() : null);
game.setReady(true); game.setReady(true);
svc.save(game); svc.save(game);
@@ -236,6 +255,46 @@ public class UploadResource {
return candidates.getFirst().path(); return candidates.getFirst().path();
} }
// ─── Setup Executable Detection ─────────────────────────────
/**
* Find a setup/install/config executable in the extracted game directory.
* Looks for files named setup.exe/com/bat, install.exe/com/bat,
* config.exe/com, customize.exe/com etc.
*/
private String findSetupExe(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 + "-")) {
int depth = f.getNameCount() - dir.getNameCount();
candidates.add(new Candidate(depth, a.size(), f.toString()));
break;
}
}
return FileVisitResult.CONTINUE;
}
});
if (candidates.isEmpty()) return null;
// Prefer shallow (root-level) files, then smaller files (setup utilities tend to be small)
candidates.sort(Comparator
.comparingInt(Candidate::depth)
.thenComparingLong(Candidate::size));
return candidates.getFirst().path();
}
// ─── Bundle Creation ───────────────────────────────────────── // ─── Bundle Creation ─────────────────────────────────────────
private void createBundle(Path extractDir, String mainExe, Path bundlePath) throws IOException { private void createBundle(Path extractDir, String mainExe, Path bundlePath) throws IOException {
@@ -321,6 +380,57 @@ public class UploadResource {
return s.replace("\\", "\\\\").replace("\"", "\\\""); return s.replace("\\", "\\\\").replace("\"", "\\\"");
} }
// ─── Setup Config Builders ───────────────────────────────────
private byte[] buildSetupJsdosJson(String relSetup) {
String json = "{"
+ "\"autoexec\":{"
+ "\"options\":{\"script\":{\"value\":\"" + escapeJson(relSetup) + "\"}}"
+ "},"
+ "\"output\":{"
+ "\"options\":{\"autolock\":{\"value\":true}}}"
+ "}";
return json.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private String buildSetupDosboxConf(String relSetup) {
return "[sdl]\n"
+ "autolock=true\n"
+ "usescancodes=true\n"
+ "output=surface\n"
+ "\n"
+ "[cpu]\n"
+ "core=auto\n"
+ "cycles=auto\n"
+ "\n"
+ "[mixer]\n"
+ "nosound=false\n"
+ "rate=44100\n"
+ "\n"
+ "[sblaster]\n"
+ "sbtype=sb16\n"
+ "irq=7\n"
+ "dma=1\n"
+ "hdma=5\n"
+ "\n"
+ "[dos]\n"
+ "xms=true\n"
+ "ems=true\n"
+ "umb=true\n"
+ "\n"
+ "[autoexec]\n"
+ "@echo off\n"
+ "mount c .\n"
+ "c:\n"
+ "cls\n"
+ "echo DOS Setup Utility\n"
+ "echo -----------------\n"
+ "echo Configure controls, sound, and other settings.\n"
+ "echo When done, exit to return here. Changes are saved automatically.\n"
+ "echo.\n"
+ relSetup + "\n";
}
// ─── File Utilities ────────────────────────────────────────── // ─── File Utilities ──────────────────────────────────────────
private double dirSizeMB(Path dir) throws IOException { private double dirSizeMB(Path dir) throws IOException {