From aa42df4a0ebf93f4b8f829948dedf08009c7635a Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Mon, 17 Mar 2025 21:10:33 -0500 Subject: [PATCH] Saving, technically done --- public/emulator.js | 17 +++++----- src/components/Emulator.tsx | 32 +++++++++++++++---- src/pages/_app.tsx | 5 ++- src/pages/api/v1/save.ts | 50 ++++++++++++++++++++++++++--- src/providers/APIProvider.tsx | 60 +++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 src/providers/APIProvider.tsx diff --git a/public/emulator.js b/public/emulator.js index de9812d..8ee2d3f 100644 --- a/public/emulator.js +++ b/public/emulator.js @@ -63,7 +63,6 @@ class HomeplayEmulator { try { this.saveUpdates = await this.hasSaveUpdates(); if(this.saveUpdates) { - console.log('Saving'); await this.save(); } } catch(e) { @@ -111,7 +110,9 @@ class HomeplayEmulator { setTimeout(() => { window.EJS_emulator.gameManager.saveSaveFiles(); - this.gameStarted = true; + setTimeout(() => { + this.gameStarted = true; + }, 1000); }, 1000) } @@ -145,12 +146,8 @@ class HomeplayEmulator { window.EJS_volume = data.volume; break; - case 'save': - window.EJS_emulator.saveSaveFiles(); - break; - default: - console.error('Unknown message', message); + console.error('Website sent invalid message', data); } } @@ -184,19 +181,21 @@ class HomeplayEmulator { } getSaveFiles = () => { - return [ 'rom.srm' ]; + return [ 'rom.srm', 'rom.rtc' ]; } hasSaveUpdates = async () => { if(!window.EJS_emulator) return false; if(!window.EJS_emulator.gameManager) return false; if(!this.gameStarted) return false; + // At least 20 seconds must've passed. + if(window.EJS_emulator.gameManager.getFrameNum() < 20 * 60) return false; const saveFiles = this.getSaveFiles(); const hashes = await Promise.all(saveFiles.map(f => this.getFileMD5(`/homeplay/saves/${f}`))); window.EJS_emulator.gameManager.saveSaveFiles(); - + const newHashes = await Promise.all(saveFiles.map(f => this.getFileMD5(`/homeplay/saves/${f}`))); const changedFiles = saveFiles.filter((f, i) => hashes[i] !== newHashes[i]); diff --git a/src/components/Emulator.tsx b/src/components/Emulator.tsx index 4f00e49..9928020 100644 --- a/src/components/Emulator.tsx +++ b/src/components/Emulator.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import styles from './Emulator.module.scss'; import { GAME_SYSTEM_CORES, GameSystem, GameSystemCore } from '@/lib/game'; import { useLanguage } from '@/providers/LanguageProvider'; +import { useAPI } from '@/providers/APIProvider'; type SendEmulatorMessageInit = { message:'init'; @@ -18,8 +19,7 @@ type SendEmulatorMessageVolume = { type SendEmulatorMessage = ( SendEmulatorMessageInit | - SendEmulatorMessageVolume | - { message: 'save' } + SendEmulatorMessageVolume ); type ReceiveEmulatorMessage = ( @@ -41,11 +41,32 @@ export const Emulator:React.FC = props => { const iframeRef = useRef(null); const [ hasInit, setHasInit ] = useState(false); const { t } = useLanguage(); + const [ saving, setSaving ] = useState(false); + const [ savingError, setSavingError ] = useState(null); + const { saveUpdate } = useAPI(); const send = (msg:SendEmulatorMessage) => { iframeRef.current?.contentWindow?.postMessage(msg, '*'); }; + const save = async (data:string) => { + if(saving) return; + if(!data) return; + setSaving(true); + + try { + const res = await saveUpdate({ + gameId: props.gameId, + data + }); + console.log('res', res); + } catch(e) { + console.error(e); + } finally { + setSaving(false); + } + } + useEffect(() => { if(hasInit) return; setHasInit(true); @@ -70,11 +91,7 @@ export const Emulator:React.FC = props => { break; case 'save': - // Download save data - const a = document.createElement('a'); - a.href = `data:application/zip;base64,${msg.data}`; - a.download = `${props.gameId}.zip`; - a.click(); + save(msg.data).catch(console.error); break; case 'start': @@ -82,6 +99,7 @@ export const Emulator:React.FC = props => { case 'load_state': case 'save_state': break; + default: console.error('Invalid message received:', msg); break diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 849395f..0474de8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -2,12 +2,15 @@ import React from 'react'; import { AppProps } from 'next/app'; import './globals.scss'; import { LanguageProvider } from '@/providers/LanguageProvider'; +import { APIProvider } from '@/providers/APIProvider'; const RootLayout:React.FC = ({ Component, pageProps }) => { return ( <> - + + + ); diff --git a/src/pages/api/v1/save.ts b/src/pages/api/v1/save.ts index 4d2f8bc..5a0046f 100644 --- a/src/pages/api/v1/save.ts +++ b/src/pages/api/v1/save.ts @@ -4,13 +4,41 @@ import * as path from 'path'; const PATH_SAVES = path.resolve('.', 'data', 'saves'); -const handler:NextApiHandler = async (req, res) => { - if (req.method !== 'GET') { - res.setHeader('Allow', ['GET']); - res.status(405).end(`Method Not Allowed`); +const handlerPut:NextApiHandler = async (req, res) => { + if(!req.body || typeof req.body !== 'object') { + res.status(400).end(`Bad Request`); return; } + if(!req.body.gameId || typeof req.body.gameId !== 'string') { + res.status(400).end(`Bad Request`); + } + + if(!req.body.data || typeof req.body.data !== 'string') { + res.status(400).end(`Bad Request`); + } + + // Check the length of the data. + if(req.body.data.length > 1024 * 1024) {// 1MB + res.status(413).end(`Payload Too Large`); + return; + } + + // Data is a base64 string, convert it to a binary zip file. + const pathSave = path.resolve(PATH_SAVES, `${req.body.gameId}.zip`); + try { + const data = Buffer.from(req.body.data, 'base64'); + fs.promises.writeFile(pathSave, data); + } catch (error) { + console.error(error); + res.status(500).end(`Internal Server Error`); + return; + } + + res.status(200).end(`true`); +} + +const handlerGet:NextApiHandler = async (req, res) => { // ID query param if(!req.query.id || typeof req.query.id !== 'string') { res.status(404).end(`Not Found`); @@ -39,6 +67,20 @@ const handler:NextApiHandler = async (req, res) => { } catch (error) { res.status(500).end(`Internal Server Error`); } +} + +const handler:NextApiHandler = async (req, res) => { + switch(req.method) { + case 'POST': + case 'PUT': + return handlerPut(req, res); + + case 'GET': + return handlerGet(req, res); + + default: + res.status(405).end(`Method Not Allowed`); + } }; export default handler; \ No newline at end of file diff --git a/src/providers/APIProvider.tsx b/src/providers/APIProvider.tsx new file mode 100644 index 0000000..947319e --- /dev/null +++ b/src/providers/APIProvider.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useContext, ReactNode } from 'react'; + +type APIContextType = { + saveUpdate:(p:{ gameId:string, data:string }) => Promise; +}; + +const APIContext = createContext(undefined); + +export const APIProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const apiSend = async (p:{ + path:string; + method:'POST'|'GET'|'PUT'|'DELETE'; + body?:any; + query?:Record; + }) => { + const query = p.query ? '?' + Object.entries(p.query).map(([k, v]) => { + return `${encodeURIComponent(k)}=${encodeURIComponent(v)}`; + }).join('&') : ''; + + const res = await fetch(`/api/v1/${p.path}${query}`, { + method: p.method, + body: p.body ? JSON.stringify(p.body) : undefined, + headers: { + 'Content-Type': 'application/json', + }, + }); + + if(!res.ok) { + throw new Error(`API Error: ${res.status}`); + } + + return res.json(); + } + + // Methods + const saveUpdate:APIContextType['saveUpdate'] = async p => { + return await apiSend({ + path: 'save', + method: 'PUT', + body: p + }); + }; + + // Provider + return ( + + {children} + + ); +}; + +export const useAPI = (): APIContextType => { + const context = useContext(APIContext); + if (!context) { + throw new Error('useAPI must be used within an APIProvider'); + } + return context; +};