Skip to content

Commit 13af02b

Browse files
author
Kiosk
committed
server-side save persistence, kiosk/remote screen detection
Save system: - GET/PUT/POST /api/saves/:core/:game for SRAM persistence on disk - On game start: fetch save from server, inject into emulator core - Every 30s: upload SRAM to server during gameplay - On menu exit/page unload: final save flush via fetch/sendBeacon - Bypasses unreliable IDBFS/IndexedDB for cross-session persistence Remote screen support: - isKiosk detection (localhost/127.0.0.1/file://) - Hide WiFi SSID, QR code, BT badges, home button on remote screens - Player status dots remain visible (remote desktops have controllers) - Fix updateNetworkStatus ReferenceError on remote screens
1 parent a24b600 commit 13af02b

4 files changed

Lines changed: 154 additions & 19 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ test-results/
66
retroarch.cfg
77
retroarch-core-options.cfg
88
test-ra.sh
9+
saves/

game-menu.html

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,16 @@
7575
this.menuTitle = this.shadowRoot.querySelector('.menu-title');
7676
this.homeBtn = this.shadowRoot.getElementById('homeBtn');
7777

78-
// Home button — navigate to kiosk dashboard (use same host as current page)
79-
this.homeBtn?.addEventListener('click', () => {
80-
const host = window.location.hostname || '127.0.0.1';
81-
window.location.href = 'http://' + host;
82-
});
78+
// Home button — only visible on local kiosk screen
79+
const isLocalKiosk = ['localhost', '127.0.0.1'].includes(window.location.hostname) || window.location.protocol === 'file:';
80+
if (isLocalKiosk) {
81+
this.homeBtn?.addEventListener('click', () => {
82+
const host = window.location.hostname || '127.0.0.1';
83+
window.location.href = 'http://' + host;
84+
});
85+
} else {
86+
this.homeBtn?.remove();
87+
}
8388

8489
// Remove cursor after typewriter animation
8590
this.menuTitle?.addEventListener('animationend', (e) => {

screen.html

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@
180180
</div>
181181
<div class="controllers" id="controllers"></div>
182182
<div class="bt-controllers" id="btControllers"></div>
183-
<div class="status connecting" id="status">Connecting...</div>
183+
<div class="status connecting hidden" id="status">Connecting...</div>
184184
<div id="pauseOverlay"></div>
185185
<game-menu id="gameMenu" mode="screen"></game-menu>
186186

@@ -206,6 +206,9 @@
206206
let isPaused = false;
207207
let gameMenu = null;
208208

209+
// Detect if this is the local kiosk screen vs a remote browser
210+
const isKiosk = ['localhost', '127.0.0.1'].includes(window.location.hostname) || window.location.protocol === 'file:';
211+
209212
let wsManager = null;
210213
let screenId = null;
211214
let loaderScript = null; // Track the loader script element
@@ -310,7 +313,7 @@
310313
}
311314
}
312315

313-
function showStatus() { document.getElementById('status').classList.remove('hidden'); }
316+
function showStatus() { if (isKiosk) document.getElementById('status').classList.remove('hidden'); }
314317
function hideStatus() { document.getElementById('status').classList.add('hidden'); }
315318

316319
// ============================================
@@ -336,28 +339,32 @@
336339
// WEBSOCKET
337340
// ============================================
338341
function updateStatus(status, text) {
342+
if (!isKiosk) return;
339343
const el = document.getElementById('status');
340344
el.className = 'status ' + status;
341345
el.textContent = text;
342346
}
343347

344348
// Network status: poll connection info and display WiFi SSID / Ethernet / Disconnected
349+
// Only on kiosk — remote screens don't need Pi's WiFi status
345350
let _networkPollInterval = null;
346351
function updateNetworkStatus() {
352+
if (!isKiosk) return;
347353
fetch('/api/network-info').then(r => r.json()).then(info => {
348354
const conn = info.connection;
349355
if (conn?.type === 'wifi') updateStatus('connected', '📶 ' + conn.name);
350356
else if (conn?.type === 'ethernet') updateStatus('connected', '🔌 Ethernet');
351357
else updateStatus('disconnected', '⚠ No Internet');
352358
}).catch(() => updateStatus('disconnected', '⚠ No Internet'));
353359
}
354-
// Poll network status every 10s to catch changes
355-
_networkPollInterval = setInterval(updateNetworkStatus, 10000);
356-
357-
// Click status label → open WiFi Manager
358-
document.getElementById('status').addEventListener('click', () => {
359-
window.location.href = 'http://' + window.location.hostname + ':3457';
360-
});
360+
if (isKiosk) {
361+
_networkPollInterval = setInterval(updateNetworkStatus, 10000);
362+
document.getElementById('status').addEventListener('click', () => {
363+
window.location.href = 'http://' + window.location.hostname + ':3457';
364+
});
365+
} else {
366+
document.getElementById('status').classList.add('hidden');
367+
}
361368

362369
function connectWebSocket() {
363370
updateStatus('connecting', 'Connecting...');
@@ -406,7 +413,7 @@
406413
break;
407414

408415
case 'btControllersUpdate':
409-
renderBtControllers(msg.controllers || []);
416+
if (isKiosk) renderBtControllers(msg.controllers || []);
410417
break;
411418

412419
case 'startGame':
@@ -959,7 +966,27 @@
959966
resizeTimeout = setTimeout(() => qrCodeGenerated && generateQRCode(true), 150);
960967
});
961968

962-
function showQRCode() { document.getElementById('qrContainer').classList.remove('hidden'); }
969+
// Flush in-game saves on page unload (refresh/navigation)
970+
window.addEventListener('beforeunload', () => {
971+
try {
972+
const gm = window.EJS_emulator?.gameManager;
973+
if (!gm) return;
974+
gm.saveSaveFiles?.();
975+
// Upload save via sendBeacon (works in beforeunload unlike fetch)
976+
const savePath = gm.getSaveFilePath?.();
977+
if (savePath && gm.FS?.analyzePath?.(savePath)?.exists) {
978+
const data = gm.FS.readFile(savePath);
979+
if (data?.length) {
980+
const parts = savePath.split('/');
981+
const coreName = parts[parts.length - 2];
982+
const fileName = parts[parts.length - 1].replace(/\.[^.]+$/, '');
983+
navigator.sendBeacon('/api/saves/' + encodeURIComponent(coreName) + '/' + encodeURIComponent(fileName), new Blob([data]));
984+
}
985+
}
986+
} catch {}
987+
});
988+
989+
function showQRCode() { if (isKiosk) document.getElementById('qrContainer').classList.remove('hidden'); }
963990
function hideQRCode() { document.getElementById('qrContainer').classList.add('hidden'); }
964991

965992
// Z-index management for QR and game menu overlap on mobile
@@ -1023,10 +1050,41 @@
10231050
initializeEmulator();
10241051
}
10251052

1026-
function resetToGameMenu() {
1027-
saveState(0);
1053+
async function uploadSaveToServer(gm) {
1054+
try {
1055+
const savePath = gm?.getSaveFilePath?.();
1056+
if (!savePath || !gm.FS?.analyzePath?.(savePath)?.exists) return;
1057+
const data = gm.FS.readFile(savePath);
1058+
if (!data || data.length === 0) return;
1059+
const parts = savePath.split('/');
1060+
const coreName = parts[parts.length - 2];
1061+
const fileName = parts[parts.length - 1].replace(/\.[^.]+$/, '');
1062+
const resp = await fetch('/api/saves/' + encodeURIComponent(coreName) + '/' + encodeURIComponent(fileName), {
1063+
method: 'PUT', body: data,
1064+
headers: { 'Content-Type': 'application/octet-stream' }
1065+
});
1066+
if (resp.ok) console.log('[save] Uploaded to server:', data.length, 'bytes');
1067+
else console.warn('[save] Server upload failed:', resp.status);
1068+
} catch (e) { console.warn('[save] Upload error:', e); }
1069+
}
1070+
1071+
async function resetToGameMenu() {
1072+
// Stop server save interval
1073+
if (window._serverSaveInterval) { clearInterval(window._serverSaveInterval); window._serverSaveInterval = null; }
1074+
1075+
// Flush in-game saves to server
1076+
try {
1077+
const gm = window.EJS_emulator?.gameManager;
1078+
if (gm) {
1079+
gm.saveSaveFiles?.();
1080+
await uploadSaveToServer(gm);
1081+
}
1082+
} catch (e) { console.warn('[save] flush error:', e); }
10281083

1029-
// Wait for save to complete before cleanup
1084+
// Save emulator state
1085+
await saveState(0);
1086+
1087+
// syncfs complete — now safe to clean up
10301088
setTimeout(() => {
10311089
// Clean up emulator if it exists
10321090
try {
@@ -1179,6 +1237,7 @@
11791237
window.EJS_backgroundBlur = false;
11801238
window.EJS_backgroundColor = '#1a1a1a';
11811239
window.EJS_defaultOptions = { "save-state-location": "browser", "fps": "hide" };
1240+
window.EJS_fixedSaveInterval = 15000; // Auto-save SRAM to IndexedDB every 15s
11821241

11831242
// N64 performance optimizations (mupen64plus_next)
11841243
if (pendingROM?.core === 'n64' || pendingROM?.core === 'mupen64plus_next') {
@@ -1339,6 +1398,45 @@
13391398
else showAudioIndicator();
13401399
}
13411400
}, 500);
1401+
1402+
// Periodic server save sync (every 30s)
1403+
if (window._serverSaveInterval) clearInterval(window._serverSaveInterval);
1404+
window._serverSaveInterval = setInterval(() => {
1405+
const gm = window.EJS_emulator?.gameManager;
1406+
if (gm && currentState === State.PLAYING) {
1407+
gm.saveSaveFiles?.();
1408+
uploadSaveToServer(gm);
1409+
}
1410+
}, 30000);
1411+
1412+
// Load server-side save file into emulator
1413+
(async () => {
1414+
try {
1415+
const gm = window.EJS_emulator?.gameManager;
1416+
if (!gm) return;
1417+
const savePath = gm.getSaveFilePath?.();
1418+
if (!savePath) return;
1419+
// Extract core and game name from path: /data/saves/CoreName/GameName.srm
1420+
const parts = savePath.split('/');
1421+
const coreName = parts[parts.length - 2];
1422+
const fileName = parts[parts.length - 1].replace(/\.[^.]+$/, '');
1423+
console.log('[save] Checking server for save:', coreName, fileName);
1424+
const resp = await fetch('/api/saves/' + encodeURIComponent(coreName) + '/' + encodeURIComponent(fileName));
1425+
if (resp.ok) {
1426+
const data = new Uint8Array(await resp.arrayBuffer());
1427+
console.log('[save] Loaded server save:', data.length, 'bytes');
1428+
// Write to WASM FS and tell core to reload
1429+
const dir = savePath.substring(0, savePath.lastIndexOf('/'));
1430+
try { gm.FS.mkdirTree(dir); } catch {}
1431+
gm.FS.writeFile(savePath, data);
1432+
gm.loadSaveFiles?.();
1433+
console.log('[save] Injected save into emulator');
1434+
} else {
1435+
console.log('[save] No server save found (new game)');
1436+
}
1437+
} catch (e) { console.warn('[save] Server load error:', e); }
1438+
})();
1439+
13421440
// Auto-load previous save state if "Load on start" setting is enabled
13431441
if (localStorage.getItem('retrobox_loadOnStart') === 'true') {
13441442
setTimeout(() => {

server.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,37 @@ const serverConfig = {
562562
}
563563
}
564564

565+
// ── Save File API (server-side persistence) ────────────────────────────
566+
const savesDir = join(ROOT_DIR, "saves");
567+
const saveMatch = pathname.match(/^\/api\/saves\/([^/]+)\/(.+)$/);
568+
if (saveMatch) {
569+
const [, core, game] = saveMatch;
570+
const safeCore = core.replace(/[^a-zA-Z0-9_-]/g, '');
571+
const safeGame = decodeURIComponent(game).replace(/[/\\]/g, '_');
572+
const saveDir = join(savesDir, safeCore);
573+
const savePath = join(saveDir, safeGame + '.srm');
574+
575+
if (req.method === "GET") {
576+
try {
577+
const data = await Bun.file(savePath).arrayBuffer();
578+
return new Response(data, { headers: { "Content-Type": "application/octet-stream", ...getHeaders(req) } });
579+
} catch {
580+
return new Response('', { status: 404, headers: getHeaders(req) });
581+
}
582+
}
583+
584+
if (req.method === "PUT" || req.method === "POST") {
585+
try {
586+
await mkdir(saveDir, { recursive: true });
587+
const data = await req.arrayBuffer();
588+
await Bun.write(savePath, data);
589+
return new Response(JSON.stringify({ ok: true, size: data.byteLength }), { headers: { "Content-Type": "application/json", ...getHeaders(req) } });
590+
} catch (e: any) {
591+
return new Response(JSON.stringify({ ok: false, error: e.message }), { status: 500, headers: { "Content-Type": "application/json", ...getHeaders(req) } });
592+
}
593+
}
594+
}
595+
565596
// ── Native RetroArch API ──────────────────────────────────────────────
566597
if (pathname === "/api/native/status") {
567598
if (!native) return new Response(JSON.stringify({ state: "idle", supported: false, cores: {} }), { headers: { "Content-Type": "application/json", ...getHeaders(req) } });

0 commit comments

Comments
 (0)