feat: SETUP.EXE detection + separate setup .jsdos bundle
Build & Deploy / build-and-deploy (push) Successful in 1m1s
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:
@@ -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
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user