diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 0000000..3dac663 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,26 @@ +name: ESLint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + eslint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npx eslint . diff --git a/.gitignore b/.gitignore index 953f7fc..fae91c1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,10 @@ data/cores/* !data/cores/package.json !data/cores/.npmignore .DS_Store +.hintrc .vscode/* +!.vscode/settings.json +!.vscode/extensions.json *.tgz dist/ jsdoc/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..12ffc20 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6a91024 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "diffEditor.ignoreTrimWhitespace": false, + "editor.formatOnPaste": false, + "editor.formatOnSave": false, + "editor.formatOnSaveMode": "modifications", + "eslint.enable": true, + "eslint.lintTask.enable": true +} diff --git a/build.js b/build.js index 668e6d4..7a01391 100644 --- a/build.js +++ b/build.js @@ -61,9 +61,9 @@ if (!build_type) { Seven.add(`dist/${version}.7z`, './', { $raw: ['-xr@dist/.ignore'], $progress: true - }).on('progress', function (progress) { + }).on('progress', (progress) => { progressData['7z'] = progress.percent; - }).on('end', function() { + }).on('end', () => { progressData['7z'] = 100; }); @@ -71,9 +71,9 @@ if (!build_type) { Seven.add(`dist/${version}.zip`, './', { $raw: ['-xr@dist/.ignore'], $progress: true - }).on('progress', function (progress) { + }).on('progress', (progress) => { progressData['zip'] = progress.percent; - }).on('end', function() { + }).on('end', () => { progressData['zip'] = 100; }); } else if (build_type !== "emulatorjs" && build_type !== "cores" && build_type !== "get-cores") { @@ -99,14 +99,15 @@ if (!build_type) { return JSON.parse(fs.readFileSync(coresJsonPath, 'utf8')); }; - if (build_type === "emulatorjs") { - console.log(`Current EmulatorJS Version: ${version}`); - removeLogo(); - console.log("Ready to build EmulatorJS!"); - } else if (build_type === "get-cores") { - const cores = await getCores(); - console.log(JSON.stringify(cores.map(coreName => coreName.name))); - } else if (build_type === "cores") { + const main = async () => { + if (build_type === "emulatorjs") { + console.log(`Current EmulatorJS Version: ${version}`); + removeLogo(); + console.log("Ready to build EmulatorJS!"); + } else if (build_type === "get-cores") { + const cores = await getCores(); + console.log(JSON.stringify(cores.map(coreName => coreName.name))); + } else if (build_type === "cores") { console.log(`Current EmulatorJS Version: ${version}`); console.log("Building cores..."); const allCores = await getCores(); @@ -177,7 +178,10 @@ if (!build_type) { packageJson.dependencies[`@emulatorjs/core-${core}`] = "latest"; fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 4), 'utf8'); } - console.log("EmulatorJS cores built successfully!"); - console.log("Ready to build EmulatorJS!"); - } + console.log("EmulatorJS cores built successfully!"); + console.log("Ready to build EmulatorJS!"); + } + }; + + main(); } diff --git a/data/loader.js b/data/loader.js index 2baadfe..afd51bc 100644 --- a/data/loader.js +++ b/data/loader.js @@ -4,6 +4,8 @@ "nipplejs.js", "shaders.js", "storage.js", + "utils.js", + "cache.js", "gamepad.js", "GameManager.js", "socket.io.min.js", @@ -98,7 +100,7 @@ config.fullscreenOnLoad = window.EJS_fullscreenOnLoaded; config.filePaths = window.EJS_paths; config.loadState = window.EJS_loadStateURL; - config.cacheLimit = window.EJS_CacheLimit; + config.cacheConfig = window.EJS_cacheConfig; config.cheats = window.EJS_cheats; config.defaultOptions = window.EJS_defaultOptions; config.gamePatchUrl = window.EJS_gamePatchUrl; @@ -118,7 +120,6 @@ config.externalFiles = window.EJS_externalFiles; config.dontExtractRom = window.EJS_dontExtractRom; config.dontExtractBIOS = window.EJS_dontExtractBIOS; - config.disableDatabases = window.EJS_disableDatabases; config.disableLocalStorage = window.EJS_disableLocalStorage; config.forceLegacyCores = window.EJS_forceLegacyCores; config.noAutoFocus = window.EJS_noAutoFocus; diff --git a/data/src/GameManager.js b/data/src/GameManager.js index b589ee9..1ffb4d6 100644 --- a/data/src/GameManager.js +++ b/data/src/GameManager.js @@ -87,34 +87,29 @@ class EJS_GameManager { return new Promise(async (resolve, reject) => { if (this.EJS.config.externalFiles && this.EJS.config.externalFiles.constructor.name === "Object") { for (const key in this.EJS.config.externalFiles) { - await new Promise(done => { - this.EJS.downloadFile(this.EJS.config.externalFiles[key], null, true, { responseType: "arraybuffer", method: "GET" }).then(async (res) => { - if (res === -1) { - if (this.EJS.debug) console.warn("Failed to fetch file from '" + this.EJS.config.externalFiles[key] + "'. Make sure the file exists."); - return done(); - } + await new Promise(async (done) => { + try { + const url = this.EJS.config.externalFiles[key]; + const cacheItem = await this.EJS.downloadFile(url, this.EJS.downloadType.support.name, "GET", {}, null, null, null, 30000, "arraybuffer", false, this.EJS.downloadType.support.dontCache); + let path = key; if (key.trim().endsWith("/")) { - const invalidCharacters = /[#<$+%>!`&*'|{}/\\?"=@:^\r\n]/ig; - let name = this.EJS.config.externalFiles[key].split("/").pop().split("#")[0].split("?")[0].replace(invalidCharacters, "").trim(); - if (!name) return done(); - const files = await this.EJS.checkCompression(new Uint8Array(res.data), this.EJS.localization("Decompress Game Assets")); - if (files["!!notCompressedData"]) { - path += name; - } else { - for (const k in files) { - this.writeFile(path + k, files[k]); - } - return done(); + // Extract to directory + for (let i = 0; i < cacheItem.files.length; i++) { + const file = cacheItem.files[i]; + this.writeFile(path + file.filename, file.bytes); + } + } else { + // Write single file (or first file from archive) + if (cacheItem.files.length > 0) { + this.writeFile(path, cacheItem.files[0].bytes); } } - try { - this.writeFile(path, new Uint8Array(res.data)); - } catch(e) { - if (this.EJS.debug) console.warn("Failed to write file to '" + path + "'. Make sure there are no conflicting files."); - } done(); - }); + } catch (e) { + if (this.EJS.debug) console.warn("Failed to fetch file from '" + this.EJS.config.externalFiles[key] + "'. Make sure the file exists.", e); + done(); + } }) } } @@ -350,35 +345,36 @@ IF EXIST AUTORUN.BAT AUTORUN.BAT return (fileNames.length === 1) ? baseFileName + "-0.cue" : baseFileName + ".m3u"; } loadPpssppAssets() { - return new Promise(resolve => { - this.EJS.downloadFile("cores/ppsspp-assets.zip", null, false, { responseType: "arraybuffer", method: "GET" }).then((res) => { - this.EJS.checkCompression(new Uint8Array(res.data), this.EJS.localization("Decompress Game Data")).then((pspassets) => { - if (pspassets === -1) { - this.EJS.textElem.innerText = this.localization("Network Error"); - this.EJS.textElem.style.color = "red"; - return; - } - this.mkdir("/PPSSPP"); + return new Promise(async (resolve, reject) => { + try { + const cacheItem = await this.EJS.downloader.downloadFile("data/cores/ppsspp-assets.zip", this.EJS.downloadType.core.name, "GET", {}, null, null, null, 30000, "arraybuffer", false, this.EJS.downloadType.core.dontCache); - for (const file in pspassets) { - const data = pspassets[file]; - const path = "/PPSSPP/" + file; - const paths = path.split("/"); - let cp = ""; - for (let i = 0; i < paths.length - 1; i++) { - if (paths[i] === "") continue; - cp += "/" + paths[i]; - if (!this.FS.analyzePath(cp).exists) { - this.FS.mkdir(cp); - } - } - if (!path.endsWith("/")) { - this.FS.writeFile(path, data); + console.log(cacheItem); + + this.mkdir("/PPSSPP"); + + for (let i = 0; i < cacheItem.files.length; i++) { + const file = cacheItem.files[i]; + const path = "/PPSSPP/" + file.filename; + const paths = path.split("/"); + let cp = ""; + for (let j = 0; j < paths.length - 1; j++) { + if (paths[j] === "") continue; + cp += "/" + paths[j]; + if (!this.FS.analyzePath(cp).exists) { + this.FS.mkdir(cp); } } - resolve(); - }) - }); + if (!path.endsWith("/")) { + this.FS.writeFile(path, file.bytes); + } + } + resolve(); + } catch (error) { + this.EJS.textElem.innerText = this.EJS.localization("Network Error"); + this.EJS.textElem.style.color = "red"; + reject(error); + } }) } setVSync(enabled) { @@ -467,6 +463,24 @@ IF EXIST AUTORUN.BAT AUTORUN.BAT setAltKeyEnabled(enabled) { this.functions.setKeyboardEnabled(enabled === true ? 3 : 2); } + listDir(path, indent = "") { + try { + const entries = this.FS.readdir(path); + for (const entry of entries) { + if (entry === "." || entry === "..") continue; + const fullPath = path === "/" ? `/${entry}` : `${path}/${entry}`; + const stat = this.FS.stat(fullPath); + if (this.FS.isDir(stat.mode)) { + console.log(`${indent}[DIR] ${fullPath}`); + this.listDir(fullPath, indent + " "); + } else { + console.log(`${indent}${fullPath}`); + } + } + } catch (e) { + console.warn("Error reading directory:", path, e); + } + } } window.EJS_GameManager = EJS_GameManager; diff --git a/data/src/cache.js b/data/src/cache.js new file mode 100644 index 0000000..a48e202 --- /dev/null +++ b/data/src/cache.js @@ -0,0 +1,635 @@ +/** + * EJS Download Manager + * Downloads files from a given URL when a download is requested. + * The file is checked against the cache to avoid re-downloading files unnecessarily. + * The following rules are tested when checking for an update: + * 1. The URL is checked against the cache - if it doesn't exist, download it + * 2. The cacheExpiry property is checked - if it exists and is in the future, use the cached version. Note: the cacheExpiry property is sent by the server in the Cache-Control or Expires headers. If these headers are not present, the cacheExpiry property will be set to 5 days in the future by default. + * 3. If the cacheExpiry property is in the past or doesn't exist, a HEAD request is made to check the Last-Modified header against the cached version's added date. Falling back to downloading if Last-Modified is not present. + * 4. If the Last-Modified date is newer than the cached version's added date, download the new version. + * 5. If none of the above conditions are met, use the cached version. + */ +class EJS_Download { + /** + * Creates an instance of EJS_Download. + * @param {EJS_Cache} storageCache - The cache instance to use for storing downloaded files. + * @param {Object} EJS - The main EmulatorJS instance. + */ + constructor(storageCache = null, EJS = null) { + this.storageCache = storageCache; + this.EJS = EJS; + } + + /** + * Handles downloading non-http(s) URLs (blob:, data:, file:, etc.) + * @param {string} url - The non-http(s) URL to fetch + * @param {string} type - The type of the file + * @param {string} method - The HTTP method (HEAD returns empty, others fetch) + * @param {string} responseType - The response type ("arraybuffer" or "text") + * @returns {Promise} - The fetched data as a cache item, or null for HEAD requests + */ + async handleNonHttpUrl(url, type, method = "GET", responseType = "arraybuffer") { + console.log("[EJS Download] Handling non-http(s) URL:", url); + + if (method === "HEAD") { + // HEAD requests just return empty for non-http URLs + return null; + } + + try { + let res = await fetch(url); + let data; + + if (responseType === "arraybuffer" || !responseType) { + data = await res.arrayBuffer(); + data = new Uint8Array(data); + } else { + data = await res.text(); + // Try to parse as JSON if it looks like JSON + try { data = JSON.parse(data) } catch(e) {} + } + + // Clean up blob URLs to free memory + if (url.startsWith("blob:")) { + URL.revokeObjectURL(url); + } + + // Create a cache item for consistency + const filename = url.split("/").pop() || "downloaded.bin"; + const now = Date.now(); + + // Ensure data is Uint8Array for file item + let fileData; + if (data instanceof Uint8Array) { + fileData = data; + } else if (typeof data === "string") { + const encoder = new TextEncoder(); + fileData = encoder.encode(data); + } else if (data instanceof ArrayBuffer) { + fileData = new Uint8Array(data); + } else { + const encoder = new TextEncoder(); + fileData = encoder.encode(String(data)); + } + + const files = [new EJS_FileItem(filename, fileData)]; + const key = this.storageCache ? this.storageCache.generateCacheKey(fileData) : "temp-" + Date.now(); + + // Don't cache non-http URLs (they're typically temporary or special) + return new EJS_CacheItem(key, files, now, type, responseType, filename, url, null); + } catch(e) { + console.error("[EJS Download] Failed to fetch non-http URL:", url, e); + throw new Error(`Failed to fetch non-http URL: ${e}`); + } + } + + /** + * Downloads a file from the given URL with the specified options. + * Automatically detects and handles both http(s) and non-http(s) URLs (blob:, data:, etc.) + * @param {string} url - The URL to download the file from. + * @param {string} type - The type of the file to download (e.g. "ROM", "CORE", "BIOS", etc). + * @param {string} method - The HTTP method to use (default is "GET"). + * @param {Array} headers - An array of headers to include in the request. + * @param {*} body - The body of the request (for POST/PUT requests). + * @param {*} onProgress - Callback function for progress updates - returns status(downloading or decompressing), percentage, loaded bytes, total bytes. + * @param {*} onComplete - Callback function when download is complete - returns success boolean, response data or error message. + * @param {Number} timeout - Timeout in milliseconds (default is 30000ms). + * @param {string} responseType - The response type (default is "arraybuffer"). + * @param {boolean} forceExtract - Whether to force extraction of compressed files regardless of extension (default is false). + * @param {boolean} dontCache - If true, the downloaded file will not be cached (default is false). + * @returns {Promise} - The downloaded file as an EJS_CacheItem. + */ + downloadFile(url, type, method = "GET", headers = {}, body = null, onProgress = null, onComplete = null, timeout = 30000, responseType = "arraybuffer", forceExtract = false, dontCache = false) { + let cacheActiveText = " (cache usage requested)" + if (dontCache) { + cacheActiveText = ""; + } + console.log("[EJS Download] Downloading " + responseType + " file: " + url + cacheActiveText); + return new Promise(async (resolve, reject) => { + try { + // Check if this is a non-http(s) URL (blob:, data:, file:, etc.) + let urlObj; + try { urlObj = new URL(url) } catch(e) {}; + + if (urlObj && !["http:", "https:"].includes(urlObj.protocol)) { + // Handle non-http(s) URLs directly + const result = await this.handleNonHttpUrl(url, type, method, responseType); + resolve(result); + return; + } + + // Use the provided storageCache or create a temporary one + if (!this.storageCache) { + console.warn("No storageCache provided to EJS_Download, downloads will not be cached"); + } + + let cached = null; + if (this.storageCache) { + cached = await this.storageCache.get(url, false, "url"); + } + const now = Date.now(); + if (cached) { + if (cached.cacheExpiry && cached.cacheExpiry > now) { + if (this.debug) console.log("Using cached version of", url); + resolve(cached); + return; + } + let lastModified = null; + try { + const headResp = await fetch(url, { method: "HEAD", headers }); + lastModified = headResp.headers.get("Last-Modified"); + } catch (e) { } + if (lastModified) { + const lastModTime = Date.parse(lastModified); + if (!isNaN(lastModTime) && lastModTime <= cached.added) { + if (this.debug) console.log("Using cached version of", url); + resolve(cached); + return; + } + } else { + if (this.debug) console.log("Using cached version of", url); + resolve(cached); + return; + } + } + + if (onProgress) onProgress("downloading", 0, 0, 0); + let controller = new AbortController(); + let timer = setTimeout(() => controller.abort(), timeout); + let resp, data, filename = url.split("/").pop() || "downloaded.bin"; + let cacheExpiry = null; + try { + resp = await fetch(url, { + method, + headers, + body, + signal: controller.signal + }); + clearTimeout(timer); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const cd = resp.headers.get("Content-Disposition"); + if (cd) { + const match = cd.match(/filename="?([^";]+)"?/); + if (match) filename = match[1]; + } + const cacheControl = resp.headers.get("Cache-Control"); + const expires = resp.headers.get("Expires"); + if (cacheControl && /max-age=(\d+)/.test(cacheControl)) { + const maxAge = parseInt(cacheControl.match(/max-age=(\d+)/)[1]); + cacheExpiry = now + maxAge * 1000; + } else if (expires) { + const exp = Date.parse(expires); + if (!isNaN(exp)) cacheExpiry = exp; + } else { + // default to 5 days if no cache headers present + cacheExpiry = now + 5 * 24 * 60 * 60 * 1000; + } + if (responseType === "arraybuffer") { + let contentLength = 0; + if (resp.headers.get("Content-Length")) { + try { + const parsedContentLength = parseInt(resp.headers.get("Content-Length")); + if (!isNaN(parsedContentLength) && parsedContentLength > 0) { + contentLength = parsedContentLength; + } + } catch (e) { + // swallow any errors parsing content length + } + } + const reader = resp.body.getReader(); + let received = 0; + let chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (onProgress && contentLength) { + onProgress("downloading", Math.floor(received / contentLength * 100), received, contentLength); + } + } + + const blob = new Blob(chunks); + const ab = await blob.arrayBuffer(); + data = new Uint8Array(ab); + } else { + data = await resp[responseType](); + } + } catch (err) { + clearTimeout(timer); + reject(`Download failed: ${err}`); + return; + } + + let files = []; + const ext = filename.toLowerCase().split('.').pop(); + if (responseType === "arraybuffer") { + if (["zip", "7z", "rar"].includes(ext) || forceExtract) { + if (onProgress) onProgress("decompressing", 0, 0, 0); + try { + const compression = new window.EJS_COMPRESSION(this.EJS); + await compression.decompress(data, (msg, isProgress) => { + if (onProgress && isProgress) { + const percent = parseInt(msg); + onProgress("decompressing", isNaN(percent) ? 0 : percent, 0, 0); + } + }, (fname, fileData) => { + files.push(new EJS_FileItem(fname, fileData instanceof Uint8Array ? fileData : new Uint8Array(fileData))); + }); + } catch (e) { + reject(`Decompression failed: ${e}`); + return; + } + } else { + files = [new EJS_FileItem(filename, data instanceof Uint8Array ? data : new Uint8Array(data))]; + } + } else { + // for non-arraybuffer types, just store the raw data as a single file + files = [new EJS_FileItem(filename, data)]; + if (typeof data === "string") { + // Encode string as UTF-8 Uint8Array + const encoder = new TextEncoder(); + files = [new EJS_FileItem(filename, encoder.encode(data))]; + } else if (data instanceof Uint8Array) { + files = [new EJS_FileItem(filename, data)]; + } else if (data instanceof ArrayBuffer) { + files = [new EJS_FileItem(filename, new Uint8Array(data))]; + } else { + // Fallback: try to convert to string then encode + const encoder = new TextEncoder(); + files = [new EJS_FileItem(filename, encoder.encode(String(data)))]; + } + } + + if (onProgress) onProgress("complete", 100, data.byteLength || 0, data.byteLength || 0); + + // Store in cache if available + if (this.storageCache) { + const key = this.storageCache.generateCacheKey(files[0].bytes); + const cacheItem = new EJS_CacheItem(key, files, now, type, responseType, filename, url, cacheExpiry); + if (dontCache === false) { + await this.storageCache.put(cacheItem); + } + resolve(cacheItem); + } else { + // Return a temporary cache item if no cache available + const key = "temp-" + Date.now(); + const cacheItem = new EJS_CacheItem(key, files, now, type, responseType, filename, url, cacheExpiry); + resolve(cacheItem); + } + } catch (err) { + reject(err.toString()); + } + }); + } +} + +/** + * EJS_Cache + * Manages a cache of files using IndexedDB for storage. + */ +class EJS_Cache { + /** + * Creates an instance of EJS_Cache. + * @param {boolean} enabled - Whether caching is enabled. + * @param {string} databaseName - Name of the IndexedDB database to use for caching. + * @param {number} maxSizeMB - Maximum size of the cache in megabytes. + * @param {number} maxAgeMins - Maximum age of items (in minutes) before they are cleaned up. + */ + constructor(enabled = true, databaseName, maxSizeMB = 4096, maxAgeMins = 7200, debug = false) { + this.enabled = enabled; + this.databaseName = databaseName; + this.maxSizeMB = maxSizeMB; + this.maxAgeMins = maxAgeMins; + this.minAgeMins = Math.max(60, maxAgeMins * 0.1); // Minimum 1 hour, or 10% of max age + this.debug = debug; + + this.utils = new EJS_UTILS(); + + /** + * Indicates whether the startup cleanup has been completed. + */ + this.startupCleanupCompleted = false; + + if (this.debug) { + console.log("Initialized EJS_Cache with settings:", { + enabled: this.enabled, + databaseName: this.databaseName, + maxSizeMB: this.maxSizeMB, + maxAgeMins: this.maxAgeMins, + minAgeMins: this.minAgeMins + }); + } + } + + /** + * Initializes the IndexedDB database and object stores. + * @returns {Promise} + */ + async createCacheDatabase() { + if (!this.enabled) return; + + if (this.storage && this.blobStorage) return; + + return new Promise((resolve, reject) => { + const indexes = ["type", "url"]; + const request = window.indexedDB.open(this.databaseName, 1); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + // Create metadata stores + const cacheStore = db.createObjectStore("cache"); + // Create indexes for cache store if indexes array is present + if (Array.isArray(indexes)) { + for (const idx of indexes) { + if (!cacheStore.indexNames.contains(idx)) { + cacheStore.createIndex(idx, idx, { unique: false }); + } + } + } + // Create blobs store + db.createObjectStore("blobs"); + }; + + request.onsuccess = (event) => { + this.storage = new EJS_STORAGE(this.databaseName, "cache", indexes); + this.blobStorage = new EJS_STORAGE(this.databaseName, "blobs"); + resolve(); + }; + + request.onerror = (event) => { + console.error("Error creating cache database:", event); + reject(event); + }; + }); + } + + /** + * Generates a cache key for the given data array. + * @param {Uint8Array} dataArray + * @returns {string} The generated cache key. + */ + generateCacheKey(dataArray) { + let hash = this.utils.simpleHash(dataArray); + const compressionCacheKey = "Obj-" + hash + "-" + dataArray.length; + return compressionCacheKey; + } + + /** + * Retrieves an item from the cache. + * @param {*} key - The unique key identifying the cached item. + * @param {boolean} [metadataOnly=false] - If true, only retrieves metadata without file data. + * @param {string|null} indexName - Optional index name to search by (e.g., 'url') - leave null to search by primary key. + * @returns {Promise} - The cached item or null if not found. + */ + async get(key, metadataOnly = false, indexName = null) { + if (!this.enabled) return null; + + // ensure database is created + await this.createCacheDatabase(); + + // clean up cache on first get if not already done + if (!this.startupCleanupCompleted) { + await this.cleanup(); + this.startupCleanupCompleted = true; + } + + const item = await this.storage.get(key, indexName); + // if the item exists, update its lastAccessed time and return cache item + if (item) { + item.lastAccessed = Date.now(); + await this.storage.put(item.key, item); + + if (!metadataOnly) { + // get the blob from cache-blobs + item.files = await this.blobStorage.get(item.key); + } + } + + if (item) { + const cacheItem = new EJS_CacheItem(item.key, item.files, item.added, item.type, item.responseType, item.filename, item.url, item.cacheExpiry, item.lastAccessed); + cacheItem.source = "cache"; + return cacheItem; + } + return null; + } + + /** + * Stores an item in the cache. + * @param {EJS_CacheItem} item - The cache item to store. + */ + async put(item) { + if (!this.enabled) return; + + // ensure database is created + await this.createCacheDatabase(); + + // before putting, ensure item is of type EJS_CacheItem + if (!(item instanceof EJS_CacheItem)) { + throw new Error("Item must be an instance of EJS_CacheItem"); + } + + // check if the item exists, if so remove the existing item + const existingItem = await this.get(item.key); + if (existingItem) { + await this.delete(item.key); + } + + // add file size attribute + item.fileSize = item.size(); + + // check that the size of item.files does not cause the cache to exceed maxSizeMB + let currentSize = 0; + const allItems = await this.storage.getAll(); + for (let i = 0; i < allItems.length; i++) { + if (allItems[i]) { + currentSize += allItems[i].fileSize || 0; + } + } + if ((currentSize + item.fileSize) > (this.maxSizeMB * 1024 * 1024)) { + // exceeded max size, keep removing oldest items until we are under maxSizeMB + the size of the new item + const itemsToRemove = []; + let sizeToFree = (currentSize + item.fileSize) - (this.maxSizeMB * 1024 * 1024); + for (let i = 0; i < allItems.length; i++) { + if (allItems[i]) { + itemsToRemove.push({ item: allItems[i], size: allItems[i].fileSize || 0 }); + } + } + itemsToRemove.sort((a, b) => a.item.lastAccessed - b.item.lastAccessed); // oldest first + for (let i = 0; i < itemsToRemove.length; i++) { + if (sizeToFree <= 0) break; + await this.delete(itemsToRemove[i].item.key); + sizeToFree -= itemsToRemove[i].size; + } + } + + // store the metadata in cache + await this.storage.put(item.key, { + key: item.key, + fileSize: item.fileSize, + added: item.added, + lastAccessed: item.lastAccessed, + type: item.type, + responseType: item.responseType, + filename: item.filename, + url: item.url, + cacheExpiry: item.cacheExpiry + }); + + // store the files in cache-blobs + await this.blobStorage.put(item.key, item.files); + } + + /** + * Deletes an item from the cache. + * @param {string} key - The unique key identifying the cached item to delete. + */ + async delete(key) { + // ensure database is created + await this.createCacheDatabase(); + + // fail silently if the key does not exist + try { + await this.storage.remove(key); + await this.blobStorage.remove(key); + } catch (e) { + console.error("Failed to delete cache item:", e); + } + } + + /** + * Clears all items from the cache. + */ + async clear() { + // ensure database is created + await this.createCacheDatabase(); + + const allItems = await this.storage.getAll(); + for (let i = 0; i < allItems.length; i++) { + await this.delete(allItems[i].key); + } + } + + /** + * Cleans up the cache by removing old or excess items based on size and age constraints. + */ + async cleanup() { + if (!this.enabled) return; + + // ensure database is created + await this.createCacheDatabase(); + + if (this.debug) console.log("[EJS Cache] Starting cache cleanup..."); + const cleanupStartTime = performance.now(); + + // get all items + const allItems = await this.storage.getAll(); + const now = Date.now(); + + // sort items by lastAccessed (oldest first) + allItems.sort((a, b) => a.lastAccessed - b.lastAccessed); + + let currentSize = 0; + let totalItems = allItems.length; + const itemsToRemove = []; + + // Calculate current total size + for (let i = 0; i < allItems.length; i++) { + const item = allItems[i]; + const itemSize = item.fileSize || 0; + currentSize += itemSize; + const ageMins = (now - item.lastAccessed) / (1000 * 60); + + // Remove if too old OR if cache is over size limit and item is old enough + if (ageMins > this.maxAgeMins || (currentSize > this.maxSizeMB * 1024 * 1024 && ageMins > this.minAgeMins)) { + itemsToRemove.push({ key: item.key, size: itemSize, age: ageMins }); + currentSize -= itemSize; + } + } + + // remove items from storage + for (const item of itemsToRemove) { + await this.delete(item.key); + } + + // remove orphaned blobs in blobStorage - here as a failsafe in case of previous incomplete deletions + const blobKeys = await this.blobStorage.getKeys(); + for (const blobKey of blobKeys) { + const existsInStorage = allItems.find(item => item.key === blobKey); + if (!existsInStorage) { + await this.blobStorage.remove(blobKey); + } + } + + const cleanupTime = performance.now() - cleanupStartTime; + const currentSizeMB = (currentSize / (1024 * 1024)).toFixed(2); + const removedSizeMB = (itemsToRemove.reduce((sum, item) => sum + item.size, 0) / (1024 * 1024)).toFixed(2); + + if (this.debug) console.log("[EJS Cache] Cleanup complete in " + cleanupTime.toFixed(2) + "ms - Removed " + itemsToRemove.length + "/" + totalItems + " items (" + removedSizeMB + "MB), " + currentSizeMB + "MB remaining"); + } +} + +/** + * EJS_CacheItem + * Represents a single cached item in the EJS_Cache system. + * Contains metadata about the cached item. This class is an internal structure used by EJS_Cache. + */ +class EJS_CacheItem { + /** + * Creates an instance of EJS_CacheItem. + * @param {string} key - Unique identifier for the cached item. + * @param {EJS_FileItem[]} files - array of EJS_FileItem objects representing the files associated with this cache item. + * @param {number} added - Timestamp (in milliseconds) when the item was added to the cache. + * @param {string} type - The type of cached content (e.g., 'core', 'ROM', 'BIOS', 'decompressed'). + * @param {string} responseType - The response type used when downloading the content (e.g., 'arraybuffer', 'blob', 'text'). + * @param {string} filename - The original filename of the cached content. + * @param {string} url - The URL from which the cached content was downloaded. + * @param {number|null} cacheExpiry - Timestamp (in milliseconds) indicating when the cache item should expire. + */ + constructor(key, files, added, type = "unknown", responseType, filename, url, cacheExpiry) { + this.key = key; + this.files = files; + this.added = added; + this.lastAccessed = added; + this.type = type; + this.responseType = responseType; + this.filename = filename; + this.url = url; + this.cacheExpiry = cacheExpiry; + } + + /** + * Calculates the total size of all files in this cache item. + * @returns {number} - Total size in bytes. + */ + size() { + let total = 0; + for (let i = 0; i < this.files.length; i++) { + if (this.files[i] && this.files[i].bytes && typeof this.files[i].bytes.byteLength === "number") { + total += this.files[i].bytes.byteLength; + } + } + return total; + } +} + +/** + * EJS_FileItem + * Represents a single file stored in the cache. This class is an internal structure used by EJS_CacheItem. + */ +class EJS_FileItem { + /** + * Creates an instance of EJS_FileItem. + * @param {string} filename - Name of the file. + * @param {Uint8Array} bytes - Byte array representing the file's data. + */ + constructor(filename, bytes) { + this.filename = filename; + this.bytes = bytes; + } +} + +window.EJS_Cache = EJS_Cache; +window.EJS_CacheItem = EJS_CacheItem; +window.EJS_FileItem = EJS_FileItem; +window.EJS_Download = EJS_Download; diff --git a/data/src/compression.js b/data/src/compression.js index e81b680..c7d3762 100644 --- a/data/src/compression.js +++ b/data/src/compression.js @@ -87,13 +87,13 @@ class EJSCompression { path = "compression/libunrar.js"; obj = "rar"; } - const res = await this.EJS.downloadFile(path, null, false, { responseType: "text", method: "GET" }); + const res = await this.EJS.downloadFile(path, this.EJS.downloadType.support.name, null, false, { responseType: "text", method: "GET" }, false, this.EJS.downloadType.support.dontCache); if (res === -1) { this.EJS.startGameError(this.EJS.localization("Network Error")); return; } if (method === "rar") { - const res2 = await this.EJS.downloadFile("compression/libunrar.wasm", null, false, { responseType: "arraybuffer", method: "GET" }); + const res2 = await this.EJS.downloadFile("compression/libunrar.wasm", this.EJS.downloadType.support.name, null, false, { responseType: "arraybuffer", method: "GET" }, false, this.EJS.downloadType.support.dontCache); if (res2 === -1) { this.EJS.startGameError(this.EJS.localization("Network Error")); return; @@ -151,7 +151,7 @@ class EJSCompression { }) resolve(blob); } else { - const blob = new Blob([res.data], { + const blob = new Blob([res.data.files[0].bytes], { type: "application/javascript" }) resolve(blob); diff --git a/data/src/emulator.js b/data/src/emulator.js index f838567..e68c3b0 100644 --- a/data/src/emulator.js +++ b/data/src/emulator.js @@ -90,75 +90,119 @@ class EmulatorJS { data[i].elem.removeEventListener(data[i].listener, data[i].cb); } } - downloadFile(path, progressCB, notWithPath, opts) { - return new Promise(async cb => { - const data = this.toData(path); //check other data types + /** + * Downloads a file from the specified path. + * Helper method that delegates to EJS_Download system for all URL-based downloads. + * Handles direct data objects (ArrayBuffer, Uint8Array, Blob) and constructs proper paths. + * @param {*} path The path to the file to download. + * @param {*} type The expected type of the file. + * @param {*} progress A callback function for progress updates. + * @param {*} notWithPath Whether to exclude the base path. + * @param {*} opts Additional options for the download. + * @param {boolean} forceExtract Whether to force extraction of compressed files regardless of extension (default is false). + * @param {boolean} dontCache If true, the downloaded file will not be cached (default is false). + * @returns A promise that resolves with the downloaded file data. + */ + downloadFile(path, type, progress, notWithPath, opts, forceExtract = false, dontCache = false) { + if (this.debug) console.log("[EJS " + type + "] Downloading " + path); + return new Promise(async (resolve) => { + // Handle direct data objects (ArrayBuffer, Uint8Array, Blob) + const data = this.toData(path); if (data) { data.then((game) => { if (opts.method === "HEAD") { - cb({ headers: {} }); + resolve({ headers: {} }); } else { - cb({ headers: {}, data: game }); + resolve({ headers: {}, data: game }); } - }) + }); return; } + + // Construct the full path/URL const basePath = notWithPath ? "" : this.config.dataPath; - path = basePath + path; + let fullPath = basePath + path; if (!notWithPath && this.config.filePaths && typeof this.config.filePaths[path.split("/").pop()] === "string") { - path = this.config.filePaths[path.split("/").pop()]; + fullPath = this.config.filePaths[path.split("/").pop()]; } - let url; - try { url = new URL(path) } catch(e) {}; - if (url && !["http:", "https:"].includes(url.protocol)) { - //Most commonly blob: urls. Not sure what else it could be - if (opts.method === "HEAD") { - cb({ headers: {} }); + + // Delegate all URL downloads (http, https, blob, data, etc.) to EJS_Download + try { + const onProgress = progress instanceof Function ? (status, percentage, loaded, total) => { + if (status === "downloading") { + const progressText = total ? " " + Math.floor(percentage).toString() + "%" : " " + (loaded / 1048576).toFixed(2) + "MB"; + progress(progressText); + } + } : null; + + const onComplete = (success, result) => { + if (!success) { + console.error("Download failed in onComplete:", result); + } + }; + + const responseType = opts.responseType || "arraybuffer"; + const method = opts.method || "GET"; + const headers = {}; + const timeout = 30000; + + const cacheItem = await this.downloader.downloadFile( + fullPath, + type, + method, + headers, + null, + onProgress, + onComplete, + timeout, + responseType, + forceExtract, + dontCache + ); + + // Handle HEAD requests (returns null) + if (!cacheItem) { + resolve({ headers: {} }); return; } - try { - let res = await fetch(path) - if ((opts.type && opts.type.toLowerCase() === "arraybuffer") || !opts.type) { - res = await res.arrayBuffer(); + + // Extract the data from the cache item + if (cacheItem.files && cacheItem.files.length > 0) { + // If there are files, return the entire cache item + // so the caller can access all extracted files + if (cacheItem.files.length > 0) { + resolve({ + data: cacheItem, + headers: { + "content-length": cacheItem.files.reduce((sum, f) => sum + (f.bytes.byteLength || 0), 0) + } + }); } else { - res = await res.text(); - try { res = JSON.parse(res) } catch(e) {} - } - if (path.startsWith("blob:")) URL.revokeObjectURL(path); - cb({ data: res, headers: {} }); - } catch(e) { - cb(-1); - } - return; - } - const xhr = new XMLHttpRequest(); - if (progressCB instanceof Function) { - xhr.addEventListener("progress", (e) => { - const progress = e.total ? " " + Math.floor(e.loaded / e.total * 100).toString() + "%" : " " + (e.loaded / 1048576).toFixed(2) + "MB"; - progressCB(progress); - }); - } - xhr.onload = function() { - if (xhr.readyState === xhr.DONE) { - let data = xhr.response; - if (xhr.status.toString().startsWith("4") || xhr.status.toString().startsWith("5")) { - cb(-1); - return; - } - try { data = JSON.parse(data) } catch(e) {} - cb({ - data: data, - headers: { - "content-length": xhr.getResponseHeader("content-length") + let data = cacheItem.files[0].bytes; + + // Convert to appropriate format based on responseType + if (responseType === "text" || (opts.type && opts.type.toLowerCase() === "text")) { + const decoder = new TextDecoder(); + data = decoder.decode(data); + try { data = JSON.parse(data) } catch(e) {} } - }); + + resolve({ + data: data, + headers: { + "content-length": data.byteLength || data.length + } + }); + } + } else { + console.error("Invalid cache item returned:", cacheItem); + resolve(-1); } + } catch(error) { + console.error("Download error:", error); + resolve(-1); } - if (opts.responseType) xhr.responseType = opts.responseType; - xhr.onerror = () => cb(-1); - xhr.open(opts.method, path, true); - xhr.send(); - }) + }); } toData(data, rv) { if (!(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) && !(data instanceof Blob)) return null; @@ -210,6 +254,7 @@ class EmulatorJS { this.checkForUpdates(); } this.netplayEnabled = true; + this.utils = new EJS_UTILS(); this.config = config; this.config.buttonOpts = this.buildButtonOptions(this.config.buttonOpts); this.config.settingsLanguage = window.EJS_settingsLanguage || false; @@ -328,19 +373,57 @@ class EmulatorJS { return null; })(); this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - if (this.config.disableDatabases) { - this.storage = { - rom: new window.EJS_DUMMYSTORAGE(), - bios: new window.EJS_DUMMYSTORAGE(), - core: new window.EJS_DUMMYSTORAGE() - } - } else { - this.storage = { - rom: new window.EJS_STORAGE("EmulatorJS-roms", "rom"), - bios: new window.EJS_STORAGE("EmulatorJS-bios", "bios"), - core: new window.EJS_STORAGE("EmulatorJS-core", "core") - } + + this.storage = {} + + // set cache configuration defaults + const cacheConfigDefaults = { + enabled: true, + cacheMaxSizeMB: 4096, + cacheMaxAgeMins: 7200 + }; + // Overwrite invalid or missing values in this.config.cacheConfig with defaults + if (!this.config.cacheConfig || typeof this.config.cacheConfig !== "object") { + this.config.cacheConfig = {}; } + if (typeof this.config.cacheConfig.enabled !== "boolean") { + this.config.cacheConfig.enabled = cacheConfigDefaults.enabled; + } + if (typeof this.config.cacheConfig.cacheMaxSizeMB !== "number" || this.config.cacheConfig.cacheMaxSizeMB <= 0) { + this.config.cacheConfig.cacheMaxSizeMB = cacheConfigDefaults.cacheMaxSizeMB; + } + if (typeof this.config.cacheConfig.cacheMaxAgeMins !== "number" || this.config.cacheConfig.cacheMaxAgeMins <= 0) { + this.config.cacheConfig.cacheMaxAgeMins = cacheConfigDefaults.cacheMaxAgeMins; + } + if (this.config.disableDatabases === true) { + this.config.cacheConfig.enabled = false; + } + + // Populate downloadTypes + this.downloadType = { + "rom": { "name": "ROM", "dontCache": false }, + "core": { "name": "Core", "dontCache": false }, + "bios": { "name": "BIOS", "dontCache": false }, + "parent": { "name": "Parent", "dontCache": false }, + "patch": { "name": "Patch", "dontCache": false }, + "reports": { "name": "Reports", "dontCache": true }, + "states": { "name": "States", "dontCache": true }, + "support": { "name": "Support", "dontCache": true }, + "unknown": { "name": "Unknown", "dontCache": true } + } + + // Initialize storage cache + this.storageCache = new window.EJS_Cache( + this.config.cacheConfig.enabled, + "EmulatorJS-Cache", + this.config.cacheConfig.cacheMaxSizeMB, + this.config.cacheConfig.cacheMaxAgeMins || 7200, + this.debug + ); + + // Initialize downloader with cache + this.downloader = new window.EJS_Download(this.storageCache, this); + // This is not cache. This is save data this.storage.states = new window.EJS_STORAGE("EmulatorJS-states", "states"); @@ -560,17 +643,6 @@ class EmulatorJS { } return text; } - checkCompression(data, msg, fileCbFunc) { - if (!this.compression) { - this.compression = new window.EJS_COMPRESSION(this); - } - if (msg) { - this.textElem.innerText = msg; - } - return this.compression.decompress(data, (m, appendMsg) => { - this.textElem.innerText = appendMsg ? (msg + m) : m; - }, fileCbFunc); - } checkCoreCompatibility(version) { if (this.versionAsInt(version.minimumEJSVersion) > this.versionAsInt(this.ejs_version)) { this.startGameError(this.localization("Outdated EmulatorJS version")); @@ -607,48 +679,86 @@ class EmulatorJS { } const gotCore = (data) => { this.defaultCoreOpts = {}; - this.checkCompression(new Uint8Array(data), this.localization("Decompress Game Core")).then((data) => { - let js, thread, wasm; - for (let k in data) { - if (k.endsWith(".wasm")) { - wasm = data[k]; - } else if (k.endsWith(".worker.js")) { - thread = data[k]; - } else if (k.endsWith(".js")) { - js = data[k]; - } else if (k === "build.json") { - this.checkCoreCompatibility(JSON.parse(new TextDecoder().decode(data[k]))); - } else if (k === "core.json") { - let core = JSON.parse(new TextDecoder().decode(data[k])); - this.extensions = core.extensions; - this.coreName = core.name; - this.repository = core.repo; - this.defaultCoreOpts = core.options; - this.enableMouseLock = core.options.supportsMouse; - this.retroarchOpts = core.retroarchOpts; - this.saveFileExt = core.save; - } else if (k === "license.txt") { - this.license = new TextDecoder().decode(data[k]); - } + + let decompressedData = {}; + + // Check if data is already a cache item with extracted files + if (data && data.files && Array.isArray(data.files)) { + console.log("[EJS Core] Data is already decompressed cache item"); + // Convert cache item files array to object keyed by filename + for (const file of data.files) { + decompressedData[file.filename] = file.bytes; } - - if (this.saveFileExt === false) { - this.elements.bottomBar.saveSavFiles[0].style.display = "none"; - this.elements.bottomBar.loadSavFiles[0].style.display = "none"; + this.processCore(decompressedData); + } else { + // Data is still compressed, need to decompress + console.log("[EJS Core] Data needs decompression"); + if (!this.compression) { + this.compression = new window.EJS_COMPRESSION(this); } - - this.initGameCore(js, wasm, thread); - }); + + this.textElem.innerText = this.localization("Decompress Game Core"); + + this.compression.decompress(new Uint8Array(data), (m, appendMsg) => { + this.textElem.innerText = appendMsg ? (this.localization("Decompress Game Core") + m) : m; + }, null).then(async (decompressedData) => { + this.processCore(decompressedData); + }); + } } + + this.processCore = (decompressedData) => { + if (this.debug) console.log("[EJS Core] Decompressed files:", Object.keys(decompressedData)); + let js, thread, wasm; + for (let k in decompressedData) { + if (k.endsWith(".wasm")) { + wasm = decompressedData[k]; + } else if (k.endsWith(".worker.js")) { + thread = decompressedData[k]; + } else if (k.endsWith(".js")) { + js = decompressedData[k]; + } else if (k === "build.json") { + this.checkCoreCompatibility(JSON.parse(new TextDecoder().decode(decompressedData[k]))); + } else if (k === "core.json") { + let core = JSON.parse(new TextDecoder().decode(decompressedData[k])); + this.extensions = core.extensions; + this.coreName = core.name; + this.repository = core.repo; + this.defaultCoreOpts = core.options; + this.enableMouseLock = core.options.supportsMouse; + this.retroarchOpts = core.retroarchOpts; + this.saveFileExt = core.save; + } else if (k === "license.txt") { + this.license = new TextDecoder().decode(decompressedData[k]); + } + } + + if (this.saveFileExt === false) { + this.elements.bottomBar.saveSavFiles[0].style.display = "none"; + this.elements.bottomBar.loadSavFiles[0].style.display = "none"; + } + + if (this.debug) console.log("[EJS Core] Core decompression complete"); + if (this.debug) console.log("[EJS Core] js size:", js?.byteLength, "wasm size:", wasm?.byteLength, "thread size:", thread?.byteLength); + + this.initGameCore(js, wasm, thread); + } + const report = "cores/reports/" + this.getCore() + ".json"; - this.downloadFile(report, null, false, { responseType: "text", method: "GET" }).then(async rep => { + // Add cache-busting parameter periodically to ensure we get updated build versions + // This ensures that when cores are updated, we'll eventually get the new buildStart value + const cacheBustInterval = 1000 * 60 * 60; // 1 hour + const cacheBustParam = Math.floor(Date.now() / cacheBustInterval); + const reportUrl = `${report}?v=${cacheBustParam}`; + + this.downloadFile(reportUrl, this.downloadType.reports.name, null, false, { responseType: "text", method: "GET" }, false, this.downloadType.reports.dontCache).then(async rep => { if (rep === -1 || typeof rep === "string" || typeof rep.data === "string") { rep = {}; } else { rep = rep.data; } if (!rep.buildStart) { - console.warn("Could not fetch core report JSON! Core caching will be disabled!"); + console.warn("Could not fetch core report JSON at " + reportUrl + "! Core caching will be disabled!"); rep.buildStart = Math.random() * 100; } if (this.webgl2Enabled === null) { @@ -669,24 +779,20 @@ class EmulatorJS { let legacy = (this.supportsWebgl2 && this.webgl2Enabled ? "" : "-legacy"); let filename = this.getCore() + (threads ? "-thread" : "") + legacy + "-wasm.data"; - if (!this.debug) { - const result = await this.storage.core.get(filename); - if (result && result.version === rep.buildStart) { - gotCore(result.data); - return; - } - } + + // Download the core + console.log("[EJS Core] Downloading core:", filename); const corePath = "cores/" + filename; - let res = await this.downloadFile(corePath, (progress) => { + let res = await this.downloadFile(corePath, this.downloadType.core.name, (progress) => { this.textElem.innerText = this.localization("Download Game Core") + progress; - }, false, { responseType: "arraybuffer", method: "GET" }); + }, false, { responseType: "arraybuffer", method: "GET" }, true, this.downloadType.core.dontCache); if (res === -1) { console.log("File not found, attemping to fetch from emulatorjs cdn."); console.error("**THIS METHOD IS A FAILSAFE, AND NOT OFFICIALLY SUPPORTED. USE AT YOUR OWN RISK**"); let version = this.ejs_version.endsWith("-beta") ? "nightly" : this.ejs_version; - res = await this.downloadFile(`https://cdn.emulatorjs.org/${version}/data/${corePath}`, (progress) => { + res = await this.downloadFile(`https://cdn.emulatorjs.org/${version}/data/${corePath}`, this.downloadType.core.name, (progress) => { this.textElem.innerText = this.localization("Download Game Core") + progress; - }, true, { responseType: "arraybuffer", method: "GET" }); + }, true, { responseType: "arraybuffer", method: "GET" }, true, this.downloadType.core.dontCache); if (res === -1) { if (!this.supportsWebgl2) { this.startGameError(this.localization("Outdated graphics driver")); @@ -697,11 +803,9 @@ class EmulatorJS { } console.warn("File was not found locally, but was found on the emulatorjs cdn.\nIt is recommended to download the stable release from here: https://cdn.emulatorjs.org/releases/"); } + + // Core download and caching handled by EJS_Download gotCore(res.data); - this.storage.core.put(filename, { - version: rep.buildStart, - data: res.data - }); }); } initGameCore(js, wasm, thread) { @@ -752,9 +856,9 @@ class EmulatorJS { } this.textElem.innerText = this.localization("Download Game State"); - this.downloadFile(this.config.loadState, (progress) => { + this.downloadFile(this.config.loadState, this.downloadType.states.name, (progress) => { this.textElem.innerText = this.localization("Download Game State") + progress; - }, true, { responseType: "arraybuffer", method: "GET" }).then((res) => { + }, true, { responseType: "arraybuffer", method: "GET" }, false, this.downloadType.states.dontCache).then((res) => { if (res === -1) { this.startGameError(this.localization("Error downloading game state")); return; @@ -768,238 +872,285 @@ class EmulatorJS { }); }) } - downloadGameFile(assetUrl, type, progressMessage, decompressProgressMessage) { + + /** + * Download a file, with caching and File object support + * @param {*} url The URL or File object to download + * @param {*} type The download type (from this.downloadType) + * @returns + */ + download(url, type) { + if (url === undefined || url === null || url === "") { + if (this.debug) console.log("[EJS " + type.name.toUpperCase() + "] No URL provided, skipping download."); + return new Promise((resolve) => { + resolve(url); + }); + } + + if (!this.compression) { + this.compression = new window.EJS_COMPRESSION(this); + } + return new Promise(async (resolve, reject) => { - if ((typeof assetUrl !== "string" || !assetUrl.trim()) && !this.toData(assetUrl, true)) { - return resolve(assetUrl); - } - const gotData = async (input) => { - const coreFilename = "/" + this.fileName; - const coreFilePath = coreFilename.substring(0, coreFilename.length - coreFilename.split("/").pop().length); - if (this.config.dontExtractBIOS === true) { - this.gameManager.FS.writeFile(coreFilePath + assetUrl.split("/").pop(), new Uint8Array(input)); - return resolve(assetUrl); - } - const data = await this.checkCompression(new Uint8Array(input), decompressProgressMessage); - for (const k in data) { - if (k === "!!notCompressedData") { - this.gameManager.FS.writeFile(coreFilePath + assetUrl.split("/").pop().split("#")[0].split("?")[0], data[k]); - break; - } - if (k.endsWith("/")) continue; - this.gameManager.FS.writeFile(coreFilePath + k.split("/").pop(), data[k]); - } - } + let returnData; - this.textElem.innerText = progressMessage; - if (!this.debug) { - const res = await this.downloadFile(assetUrl, null, true, { method: "HEAD" }); - const result = await this.storage.rom.get(assetUrl.split("/").pop()); - if (result && result["content-length"] === res.headers["content-length"] && result.type === type) { - await gotData(result.data); - return resolve(assetUrl); - } - } - const res = await this.downloadFile(assetUrl, (progress) => { - this.textElem.innerText = progressMessage + progress; - }, true, { responseType: "arraybuffer", method: "GET" }); - if (res === -1) { - this.startGameError(this.localization("Network Error")); - reject(); - return; - } - if (assetUrl instanceof File) { - assetUrl = assetUrl.name; - } else if (this.toData(assetUrl, true)) { - assetUrl = "game"; - } - await gotData(res.data); - resolve(assetUrl); - const limit = (typeof this.config.cacheLimit === "number") ? this.config.cacheLimit : 1073741824; - if (parseFloat(res.headers["content-length"]) < limit && this.saveInBrowserSupported() && assetUrl !== "game") { - this.storage.rom.put(assetUrl.split("/").pop(), { - "content-length": res.headers["content-length"], - data: res.data, - type: type - }) - } - }); - } - downloadGamePatch() { - return new Promise(async (resolve) => { - this.config.gamePatchUrl = await this.downloadGameFile(this.config.gamePatchUrl, "patch", this.localization("Download Game Patch"), this.localization("Decompress Game Patch")); - resolve(); - }); - } - downloadGameParent() { - return new Promise(async (resolve) => { - this.config.gameParentUrl = await this.downloadGameFile(this.config.gameParentUrl, "parent", this.localization("Download Game Parent"), this.localization("Decompress Game Parent")); - resolve(); - }); - } - downloadBios() { - return new Promise(async (resolve) => { - this.config.biosUrl = await this.downloadGameFile(this.config.biosUrl, "bios", this.localization("Download Game BIOS"), this.localization("Decompress Game BIOS")); - resolve(); - }); - } - downloadRom() { - const supportsExt = (ext) => { - const core = this.getCore(); - if (!this.extensions) return false; - return this.extensions.includes(ext); - }; + // check if url is a file object, and if so convert it to an EJS_CacheItem + if (typeof url === "object" && url instanceof File) { + if (this.debug) console.log("[EJS " + type.name.toUpperCase() + "] Requested download for File object " + url.name); - return new Promise(resolve => { - this.textElem.innerText = this.localization("Download Game Data"); + // Convert File to Uint8Array + const arrayBuffer = await url.arrayBuffer(); + const inData = new Uint8Array(arrayBuffer); - const gotGameData = (data) => { - const coreName = this.getCore(true); - const altName = this.getBaseFileName(true); - if (["arcade", "mame"].includes(coreName) || this.config.dontExtractRom === true) { - this.fileName = altName; - this.gameManager.FS.writeFile(this.fileName, new Uint8Array(data)); - resolve(); - return; - } - - // List of cores to generate a CUE file for, if it doesn't exist. - const cueGeneration = ["mednafen_psx_hw"]; - const prioritizeExtensions = ["cue", "ccd", "toc", "m3u"]; - - let createCueFile = cueGeneration.includes(this.getCore()); - if (this.config.disableCue === true) { - createCueFile = false; - } - - let fileNames = []; - this.checkCompression(new Uint8Array(data), this.localization("Decompress Game Data"), (fileName, fileData) => { - if (fileName.includes("/")) { - const paths = fileName.split("/"); - let cp = ""; - for (let i = 0; i < paths.length - 1; i++) { - if (paths[i] === "") continue; - cp += `/${paths[i]}`; - if (!this.gameManager.FS.analyzePath(cp).exists) { - this.gameManager.FS.mkdir(cp); - } + // check cache + let key = this.storageCache.generateCacheKey(inData); + let cachedItem = await this.storageCache.get(key); + if (cachedItem) { + if (this.debug) console.log("[EJS " + type.name.toUpperCase() + "] Using cached content for " + url.name); + returnData = cachedItem; + } else { + // Not in cache - decompress + let files = []; + const decompressedData = await this.compression.decompress(inData, (m, appendMsg) => { + this.textElem.innerText = appendMsg ? (this.localization("Decompress Game Core") + m) : m; + }, (fileName, fileData) => { + // Use file callback to collect files during decompression + let bytes; + if (fileData instanceof Uint8Array) { + bytes = fileData; + } else if (fileData instanceof ArrayBuffer) { + bytes = new Uint8Array(fileData); + } else if (fileData && typeof fileData === 'object') { + // Handle case where it might be an object with numeric keys + bytes = new Uint8Array(Object.values(fileData)); + } else { + console.error("Unknown file data type:", typeof fileData, fileData); + return; } - } - if (fileName.endsWith("/")) { - this.gameManager.FS.mkdir(fileName); - return; - } - if (fileName === "!!notCompressedData") { - this.gameManager.FS.writeFile(altName, fileData); - fileNames.push(altName); - } else { - this.gameManager.FS.writeFile(`/${fileName}`, fileData); - fileNames.push(fileName); - } - }).then(() => { - let isoFile = null; - let supportedFile = null; - let cueFile = null; - fileNames.forEach(fileName => { - const ext = fileName.split(".").pop().toLowerCase(); - if (supportedFile === null && supportsExt(ext)) { - supportedFile = fileName; - } - if (isoFile === null && ["iso", "cso", "chd", "elf"].includes(ext)) { - isoFile = fileName; - } - if (prioritizeExtensions.includes(ext)) { - const currentCueExt = (cueFile === null) ? null : cueFile.split(".").pop().toLowerCase(); - if (coreName === "psx") { - // Always prefer m3u files for psx cores - if (currentCueExt !== "m3u") { - if (cueFile === null || ext === "m3u") { - cueFile = fileName; - } - } - } else { - const priority = ["cue", "ccd"] - // Prefer cue or ccd files over toc or m3u - if (!priority.includes(currentCueExt)) { - if (cueFile === null || priority.includes(ext)) { - cueFile = fileName; - } - } - } + + if (fileName === "!!notCompressedData") { + files.push(new EJS_FileItem(url.name, bytes)); + } else if (!fileName.endsWith("/")) { + files.push(new EJS_FileItem(fileName, bytes)); } }); - if (supportedFile !== null) { - this.fileName = supportedFile; - } else { - this.fileName = fileNames[0]; - } - if (isoFile !== null && supportsExt(isoFile.split(".").pop().toLowerCase())) { - this.fileName = isoFile; - } - if (cueFile !== null && supportsExt(cueFile.split(".").pop().toLowerCase())) { - this.fileName = cueFile; - } else if (createCueFile && supportsExt("m3u") && supportsExt("cue")) { - this.fileName = this.gameManager.createCueFile(fileNames); - } - if (this.getCore(true) === "dos" && !this.config.disableBatchBootup) { - this.fileName = this.gameManager.writeBootupBatchFile(); - } - resolve(); - }); - } - const downloadFile = async () => { - const res = await this.downloadFile(this.config.gameUrl, (progress) => { - this.textElem.innerText = this.localization("Download Game Data") + progress; - }, true, { responseType: "arraybuffer", method: "GET" }); - if (res === -1) { + + // construct EJS_CacheItem + let data = new EJS_CacheItem( + key, + files, + Date.now(), + type.name, + "arraybuffer", + url.name, + url.name, + Date.now() + 5 * 24 * 60 * 60 * 1000 // 5 days expiration + ); + + this.storageCache.put(data); + + returnData = data; + } + } else { + // download using a url + if (this.debug) console.log("[EJS " + type.name.toUpperCase() + "] Requested download for " + url); + // download the content + const data = await this.downloadFile( + url, + type.name, + (progress) => { + this.textElem.innerText = this.localization("Download Game Data") + progress; + }, + true, + { responseType: "arraybuffer", method: "GET" }, + false, + type.dontCache + ); + // check for error + if (data === -1) { this.startGameError(this.localization("Network Error")); return; } + // check for content type if (this.config.gameUrl instanceof File) { this.config.gameUrl = this.config.gameUrl.name; } else if (this.toData(this.config.gameUrl, true)) { - this.config.gameUrl = "game"; + this.config.gameUrl = type.name.toLowerCase(); } - gotGameData(res.data); - const limit = (typeof this.config.cacheLimit === "number") ? this.config.cacheLimit : 1073741824; - if (parseFloat(res.headers["content-length"]) < limit && this.saveInBrowserSupported() && this.config.gameUrl !== "game") { - this.storage.rom.put(this.config.gameUrl.split("/").pop(), { - "content-length": res.headers["content-length"], - data: res.data - }) + + returnData = data.data; + } + + if (this.debug) console.log("[EJS " + type.name.toUpperCase() + "] Downloaded content:", returnData); + + const writeFilesToFS = (fileName, fileData) => { + if (fileName.includes("/")) { + const paths = fileName.split("/"); + let cp = ""; + for (let i = 0; i < paths.length - 1; i++) { + if (paths[i] === "") continue; + cp += `/${paths[i]}`; + if (!this.gameManager.FS.analyzePath(cp).exists) { + this.gameManager.FS.mkdir(cp); + } + } + } + if (fileName.endsWith("/")) { + this.gameManager.FS.mkdir(fileName); + return null; + } + this.gameManager.FS.writeFile(`/${fileName}`, fileData); + return fileName; + }; + + // extract to the file system + if (returnData && returnData.files) { + for (let i = 0; i < returnData.files.length; i++) { + writeFilesToFS(returnData.files[i].filename, returnData.files[i].bytes) } } - if (!this.debug) { - this.downloadFile(this.config.gameUrl, null, true, { method: "HEAD" }).then(async (res) => { - const name = (typeof this.config.gameUrl === "string") ? this.config.gameUrl.split("/").pop() : "game"; - const result = await this.storage.rom.get(name); - if (result && result["content-length"] === res.headers["content-length"] && name !== "game") { - gotGameData(result.data); - return; - } - downloadFile(); - }) - } else { - downloadFile(); - } - }) + resolve(returnData); + }); } + /** + * Initialize GameManager and load external files and file systems + */ + async initializeGameManager() { + this.gameManager = new window.EJS_GameManager(this.Module, this); + await this.gameManager.loadExternalFiles(); + await this.gameManager.mountFileSystems(); + this.callEvent("saveDatabaseLoaded", this.gameManager.FS); + if (this.getCore() === "ppsspp") { + await this.gameManager.loadPpssppAssets(); + } + } + + /** + * Determine CUE file handling settings based on core type and configuration + */ + determineCueSettings() { + const coresThatNeedCueHandling = ["pcsx_rearmed", "genesis_plus_gx", "picodrive", "mednafen_pce", "smsplus", "vice_x64", "vice_x64sc", "vice_x128", "vice_xvic", "vice_xpet", "puae"]; + let disableCue = false; + + if (coresThatNeedCueHandling.includes(this.getCore()) && this.config.disableCue === undefined) { + disableCue = true; + } else { + disableCue = this.config.disableCue; + } + + if (this.debug) console.log("Disable CUE handling:", disableCue); + return disableCue; + } + + /** + * Check if extension is supported by the current core + */ + supportsExtension(ext) { + if (!this.extensions) return false; + return this.extensions.includes(ext); + } + + /** + * Select the most appropriate ROM file from available files + */ + selectRomFile(fileNames, coreName) { + const cueGenerationCores = ["mednafen_psx_hw"]; + const prioritizeExtensions = ["cue", "ccd", "toc", "m3u"]; + + let createCueFile = cueGenerationCores.includes(this.getCore()); + if (this.determineCueSettings()) { + createCueFile = false; + } + + let isoFile = null; + let supportedFile = null; + let cueFile = null; + + fileNames.forEach(fileName => { + const ext = fileName.split(".").pop().toLowerCase(); + if (supportedFile === null && this.supportsExtension(ext)) { + supportedFile = fileName; + } + if (isoFile === null && ["iso", "cso", "chd", "elf"].includes(ext)) { + isoFile = fileName; + } + if (prioritizeExtensions.includes(ext)) { + const currentCueExt = (cueFile === null) ? null : cueFile.split(".").pop().toLowerCase(); + if (coreName === "psx") { + // Always prefer m3u files for psx cores + if (currentCueExt !== "m3u") { + if (cueFile === null || ext === "m3u") { + cueFile = fileName; + } + } + } else { + const priority = ["cue", "ccd"] + // Prefer cue or ccd files over toc or m3u + if (!priority.includes(currentCueExt)) { + if (cueFile === null || priority.includes(ext)) { + cueFile = fileName; + } + } + } + } + }); + + // Set the primary file selection with priority order + if (supportedFile !== null) { + this.fileName = supportedFile; + } else { + this.fileName = fileNames[0]; + } + + // ISO files take priority if supported + if (isoFile !== null && this.supportsExtension(isoFile.split(".").pop().toLowerCase())) { + this.fileName = isoFile; + } + + // CUE/CCD files take priority if supported, or create a CUE file if needed + if (cueFile !== null && this.supportsExtension(cueFile.split(".").pop().toLowerCase())) { + this.fileName = cueFile; + } else if (createCueFile && this.supportsExtension("m3u") && this.supportsExtension("cue")) { + this.fileName = this.gameManager.createCueFile(fileNames); + } + + // Special handling for DOS + if (this.getCore(true) === "dos" && !this.config.disableBatchBootup) { + this.fileName = this.gameManager.writeBootupBatchFile(); + } + } + + /** + * Extract file names from downloaded ROM data and start game + */ + startGameFromDownload(romData) { + const fileNames = []; + for (const file of romData.files) { + if (file.filename.endsWith("/")) { + continue; + } + fileNames.push(file.filename); + } + this.selectRomFile(fileNames, this.getCore()); + this.startGame(); + } + + /** + * Download all necessary files and start the game + */ downloadFiles() { (async () => { - this.gameManager = new window.EJS_GameManager(this.Module, this); - await this.gameManager.loadExternalFiles(); - await this.gameManager.mountFileSystems(); - this.callEvent("saveDatabaseLoaded", this.gameManager.FS); - if (this.getCore() === "ppsspp") { - await this.gameManager.loadPpssppAssets(); - } - await this.downloadRom(); - await this.downloadBios(); + await this.initializeGameManager(); + + const romData = await this.download(this.config.gameUrl, this.downloadType.rom); + await this.download(this.config.biosUrl, this.downloadType.bios); await this.downloadStartState(); - await this.downloadGameParent(); - await this.downloadGamePatch(); - this.startGame(); + await this.download(this.config.gameParentUrl, this.downloadType.parent); + await this.download(this.config.gamePatchUrl, this.downloadType.patch); + + this.determineCueSettings(); + this.startGameFromDownload(romData); })(); } initModule(wasmData, threadData) { @@ -1099,6 +1250,12 @@ class EmulatorJS { //Safari is --- funny this.checkStarted(); } + + // debug list directory structure + if (this.debug && this.gameManager && this.gameManager.FS) { + console.log("File system directory"); + this.gameManager.listDir("/"); + } } catch(e) { console.warn("Failed to start game", e); this.startGameError(this.localization("Failed to start game")); @@ -1279,132 +1436,133 @@ class EmulatorJS { elem.appendChild(elm); } } - defaultButtonOptions = { - playPause: { - visible: true, - icon: "play", - displayName: "Play/Pause" - }, - play: { - visible: true, - icon: '', - displayName: "Play" - }, - pause: { - visible: true, - icon: '', - displayName: "Pause" - }, - restart: { - visible: true, - icon: '', - displayName: "Restart" - }, - mute: { - visible: true, - icon: '', - displayName: "Mute" - }, - unmute: { - visible: true, - icon: '', - displayName: "Unmute" - }, - settings: { - visible: true, - icon: '', - displayName: "Settings" - }, - fullscreen: { - visible: true, - icon: "fullscreen", - displayName: "Fullscreen" - }, - enterFullscreen: { - visible: true, - icon: '', - displayName: "Enter Fullscreen" - }, - exitFullscreen: { - visible: true, - icon: '', - displayName: "Exit Fullscreen" - }, - saveState: { - visible: true, - icon: '', - displayName: "Save State" - }, - loadState: { - visible: true, - icon: '', - displayName: "Load State" - }, - screenRecord: { - visible: true - }, - gamepad: { - visible: true, - icon: '', - displayName: "Control Settings" - }, - cheat: { - visible: true, - icon: '', - displayName: "Cheats" - }, - volumeSlider: { - visible: true - }, - saveSavFiles: { - visible: true, - icon: '', - displayName: "Export Save File" - }, - loadSavFiles: { - visible: true, - icon: '', - displayName: "Import Save File" - }, - quickSave: { - visible: true - }, - quickLoad: { - visible: true - }, - screenshot: { - visible: true - }, - cacheManager: { - visible: true, - icon: '', - displayName: "Cache Manager" - }, - exitEmulation: { - visible: true, - icon: '', - displayName: "Exit Emulation" - }, - netplay: { - visible: true, - icon: '', - displayName: "Netplay" - }, - diskButton: { - visible: true, - icon: '', - displayName: "Disks" - }, - contextMenu: { - visible: true, - icon: '', - displayName: "Context Menu" - } - }; - defaultButtonAliases = { - volume: "volumeSlider" - }; buildButtonOptions(buttonUserOpts) { + this.defaultButtonOptions = { + playPause: { + visible: true, + icon: "play", + displayName: "Play/Pause" + }, + play: { + visible: true, + icon: '', + displayName: "Play" + }, + pause: { + visible: true, + icon: '', + displayName: "Pause" + }, + restart: { + visible: true, + icon: '', + displayName: "Restart" + }, + mute: { + visible: true, + icon: '', + displayName: "Mute" + }, + unmute: { + visible: true, + icon: '', + displayName: "Unmute" + }, + settings: { + visible: true, + icon: '', + displayName: "Settings" + }, + fullscreen: { + visible: true, + icon: "fullscreen", + displayName: "Fullscreen" + }, + enterFullscreen: { + visible: true, + icon: '', + displayName: "Enter Fullscreen" + }, + exitFullscreen: { + visible: true, + icon: '', + displayName: "Exit Fullscreen" + }, + saveState: { + visible: true, + icon: '', + displayName: "Save State" + }, + loadState: { + visible: true, + icon: '', + displayName: "Load State" + }, + screenRecord: { + visible: true + }, + gamepad: { + visible: true, + icon: '', + displayName: "Control Settings" + }, + cheat: { + visible: true, + icon: '', + displayName: "Cheats" + }, + volumeSlider: { + visible: true + }, + saveSavFiles: { + visible: true, + icon: '', + displayName: "Export Save File" + }, + loadSavFiles: { + visible: true, + icon: '', + displayName: "Import Save File" + }, + quickSave: { + visible: true + }, + quickLoad: { + visible: true + }, + screenshot: { + visible: true + }, + cacheManager: { + visible: true, + icon: '', + displayName: "Cache Manager" + }, + exitEmulation: { + visible: true, + icon: '', + displayName: "Exit Emulation" + }, + netplay: { + visible: true, + icon: '', + displayName: "Netplay" + }, + diskButton: { + visible: true, + icon: '', + displayName: "Disks" + }, + contextMenu: { + visible: true, + icon: '', + displayName: "Context Menu" + } + }; + this.defaultButtonAliases = { + volume: "volumeSlider" + }; + let mergedButtonOptions = this.defaultButtonOptions; // merge buttonUserOpts with mergedButtonOptions @@ -2033,7 +2191,7 @@ class EmulatorJS { this.openCacheMenu(); }); - if (this.config.disableDatabases) cache.style.display = "none"; + if (this.config.cacheConfig.enabled === false) cache.style.display = "none"; let savUrl; @@ -2424,26 +2582,67 @@ class EmulatorJS { } openCacheMenu() { (async () => { + // Run cleanup before showing cache contents + await this.storageCache.cleanup(); + const list = this.createElement("table"); + const thead = this.createElement("thead"); const tbody = this.createElement("tbody"); + + // Create header row + const headerRow = this.createElement("tr"); + const nameHeader = this.createElement("th"); + const typeHeader = this.createElement("th"); + const sizeHeader = this.createElement("th"); + const lastUsedHeader = this.createElement("th"); + const actionHeader = this.createElement("th"); + + nameHeader.innerText = "Filename"; + typeHeader.innerText = "Type"; + sizeHeader.innerText = "Size"; + lastUsedHeader.innerText = "Last Used"; + actionHeader.innerText = "Action"; + + nameHeader.style.textAlign = "left"; + typeHeader.style.textAlign = "left"; + sizeHeader.style.textAlign = "left"; + lastUsedHeader.style.textAlign = "left"; + actionHeader.style.textAlign = "left"; + + headerRow.appendChild(nameHeader); + headerRow.appendChild(typeHeader); + headerRow.appendChild(sizeHeader); + headerRow.appendChild(lastUsedHeader); + headerRow.appendChild(actionHeader); + thead.appendChild(headerRow); + const body = this.createPopup("Cache Manager", { + "Cleanup Now": async () => { + const cleanupBtn = document.querySelector('.ejs_popup_button'); + if (cleanupBtn) cleanupBtn.textContent = 'Cleaning...'; + await this.storageCache.cleanup(); + tbody.innerHTML = ""; + // Refresh the cache list + await this.populateCacheList(tbody, getSize, getTypeName); + if (cleanupBtn) cleanupBtn.textContent = 'Cleanup Now'; + }, "Clear All": async () => { - const roms = await this.storage.rom.getSizes(); - for (const k in roms) { - await this.storage.rom.remove(k); - } + await this.storageCache.clear(); tbody.innerHTML = ""; }, "Close": () => { this.closePopup(); } }); - const roms = await this.storage.rom.getSizes(); + list.style.width = "100%"; list.style["padding-left"] = "10px"; list.style["text-align"] = "left"; - body.appendChild(list); + + list.appendChild(thead); list.appendChild(tbody); + body.appendChild(list); + const getSize = function (size) { let i = -1; do { @@ -2451,30 +2650,86 @@ class EmulatorJS { } while (size > 1024); return Math.max(size, 0.1).toFixed(1) + [" kB", " MB", " GB", " TB", "PB", "EB", "ZB", "YB"][i]; } - for (const k in roms) { - const line = this.createElement("tr"); - const name = this.createElement("td"); - const size = this.createElement("td"); - const remove = this.createElement("td"); - remove.style.cursor = "pointer"; - name.innerText = k; - size.innerText = getSize(roms[k]); - const a = this.createElement("a"); - a.innerText = this.localization("Remove"); - this.addEventListener(remove, "click", () => { - this.storage.rom.remove(k); - line.remove(); - }) - remove.appendChild(a); - - line.appendChild(name); - line.appendChild(size); - line.appendChild(remove); - tbody.appendChild(line); + const getTypeName = function (key) { + if (key.startsWith('compression_')) return 'Decompressed Content'; + if (key.startsWith('core_decompressed_')) return 'Core'; + // Additional fallback logic for other types + if (key.includes('core')) return 'Core'; + if (key.includes('bios')) return 'BIOS'; + if (key.includes('rom')) return 'ROM'; + if (key.includes('asset')) return 'Asset'; + return 'Unknown'; } + + await this.populateCacheList(tbody, getSize, getTypeName); })(); } + + async populateCacheList(tbody, getSize, getTypeName) { + // Get all cache items from the compression cache + const allCacheItems = await this.storageCache.storage.getAll(); + + for (const item of allCacheItems) { + if (!item.key || !item.fileSize) continue; + + const line = this.createElement("tr"); + const name = this.createElement("td"); + const type = this.createElement("td"); + const size = this.createElement("td"); + const lastUsed = this.createElement("td"); + const remove = this.createElement("td"); + remove.style.cursor = "pointer"; + + // Calculate total size of all files in this cache item + let totalSize = item.fileSize; + + // Use filename if available, otherwise fall back to key + const displayName = item.filename || item.key; + name.innerText = displayName.substring(0, 50) + (displayName.length > 50 ? '...' : ''); + + // Use the stored type if available, otherwise fall back to getTypeName + const itemType = item.type || getTypeName(item.key); + type.innerText = itemType; + size.innerText = getSize(totalSize); + + // Format last accessed time + const lastAccessedTime = item.lastAccessed || item.added || Date.now(); + const formatDate = (timestamp) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + // For older items, show the actual date + return date.toLocaleDateString(); + }; + lastUsed.innerText = formatDate(lastAccessedTime); + + const a = this.createElement("a"); + a.innerText = this.localization("Remove"); + this.addEventListener(remove, "click", async () => { + await this.storageCache.delete(item.key); + line.remove(); + }) + remove.appendChild(a); + + line.appendChild(name); + line.appendChild(type); + line.appendChild(size); + line.appendChild(lastUsed); + line.appendChild(remove); + tbody.appendChild(line); + } + } + getControlScheme() { if (this.config.controlScheme && typeof this.config.controlScheme === "string") { return this.config.controlScheme; @@ -7222,28 +7477,9 @@ class EmulatorJS { } enableSaveUpdateEvent() { - // https://stackoverflow.com/questions/7616461 - // Modified to accept a buffer instead of a string and return hex instead of an int - async function cyrb53(charBuffer, seed = 0) { - let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; - for(let i = 0, ch; i < charBuffer.length; i++) { - ch = charBuffer[i]; - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); - h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); - h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); - - // Cyrb53 is a 53-bit hash; we need 14 hex characters to represent it, and the first char will - // always be 0 or 1 (since it is only 1 bit) - return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, "0"); - }; - function withGameSaveHash(saveFile, callback) { if (saveFile) { - cyrb53(saveFile).then(digest => callback(digest, saveFile)); + this.utils.cyrb53(saveFile).then(digest => callback(digest, saveFile)); } else { console.warn("Save file not found when attempting to hash"); callback(null, null); diff --git a/data/src/gamepad.js b/data/src/gamepad.js index 22095ab..149830f 100644 --- a/data/src/gamepad.js +++ b/data/src/gamepad.js @@ -1,7 +1,4 @@ class GamepadHandler { - gamepads; - timeout; - listeners; constructor() { this.buttonLabels = { 0: 'BUTTON_1', diff --git a/data/src/storage.js b/data/src/storage.js index 7b60c90..aeb36b8 100644 --- a/data/src/storage.js +++ b/data/src/storage.js @@ -1,7 +1,13 @@ class EJS_STORAGE { - constructor(dbName, storeName) { + /** + * @param {string} dbName + * @param {string} storeName + * @param {string[]?} indexes - Optional array of field names to create non-unique indexes on + */ + constructor(dbName, storeName, indexes = null) { this.dbName = dbName; this.storeName = storeName; + this.indexes = indexes; } addFileToDB(key, add) { (async () => { @@ -17,73 +23,85 @@ class EJS_STORAGE { this.put("?EJS_KEYS!", keys); })(); } - get(key) { + getObjectStore(mode = "readwrite") { return new Promise((resolve, reject) => { if (!window.indexedDB) return resolve(); let openRequest = indexedDB.open(this.dbName, 1); openRequest.onerror = () => resolve(); openRequest.onsuccess = () => { let db = openRequest.result; - let transaction = db.transaction([this.storeName], "readwrite"); + let transaction = db.transaction(this.storeName, mode); let objectStore = transaction.objectStore(this.storeName); - let request = objectStore.get(key); - request.onsuccess = (e) => { - resolve(request.result); - }; - request.onerror = () => resolve(); + resolve(objectStore); }; openRequest.onupgradeneeded = () => { let db = openRequest.result; + let objectStore; if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName); - }; + objectStore = db.createObjectStore(this.storeName); + } else { + objectStore = openRequest.transaction.objectStore(this.storeName); + } + // Create indexes if provided + if (this.indexes && Array.isArray(this.indexes)) { + for (const idx of this.indexes) { + if (!objectStore.indexNames.contains(idx)) { + objectStore.createIndex(idx, idx, { unique: false }); + } + } + } }; }); } - put(key, data) { - return new Promise((resolve, reject) => { - if (!window.indexedDB) return resolve(); - let openRequest = indexedDB.open(this.dbName, 1); - openRequest.onerror = () => {}; - openRequest.onsuccess = () => { - let db = openRequest.result; - let transaction = db.transaction([this.storeName], "readwrite"); - let objectStore = transaction.objectStore(this.storeName); - let request = objectStore.put(data, key); + /** + * Get a value by key or by index. + * @param {string|any} key - The key or index value to search for + * @param {string|null} indexName - Optional index name to search by + * @returns {Promise} + */ + get(key, indexName = null) { + return new Promise(async (resolve, reject) => { + const objectStore = await this.getObjectStore(); + if (!objectStore) return resolve(); + if (!indexName) { + // Default: get by primary key + let request = objectStore.get(key); + request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(); - request.onsuccess = () => { - this.addFileToDB(key, true); + } else { + // Get by index + try { + const index = objectStore.index(indexName); + let req = index.get(key); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(); + } catch (e) { + // Index not found resolve(); } + } + }); + } + put(key, data) { + return new Promise(async (resolve, reject) => { + const objectStore = await this.getObjectStore(); + if (!objectStore) return resolve(); + let request = objectStore.put(data, key); + request.onerror = () => resolve(); + request.onsuccess = () => { + this.addFileToDB(key, true); + resolve(); }; - openRequest.onupgradeneeded = () => { - let db = openRequest.result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName); - }; - }; - }) + }); } remove(key) { - return new Promise((resolve, reject) => { - if (!window.indexedDB) return resolve(); - let openRequest = indexedDB.open(this.dbName, 1); - openRequest.onerror = () => {}; - openRequest.onsuccess = () => { - let db = openRequest.result; - let transaction = db.transaction([this.storeName], "readwrite"); - let objectStore = transaction.objectStore(this.storeName); - let request2 = objectStore.delete(key); - this.addFileToDB(key, false); - request2.onsuccess = () => resolve(); - request2.onerror = () => {}; - }; - openRequest.onupgradeneeded = () => { - let db = openRequest.result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName); - }; - }; + return new Promise(async (resolve, reject) => { + const objectStore = await this.getObjectStore(); + if (!objectStore) return resolve(); + let request = objectStore.delete(key); + this.addFileToDB(key, false); + request.onsuccess = () => resolve(); + request.onerror = () => {}; }); } getSizes() { @@ -100,6 +118,28 @@ class EJS_STORAGE { resolve(rv); }) } + getAll() { + return new Promise(async (resolve, reject) => { + if (!window.indexedDB) return resolve([]); + const keys = await this.get("?EJS_KEYS!"); + if (!keys) return resolve([]); + let rv = []; + for (let i = 0; i < keys.length; i++) { + const result = await this.get(keys[i]); + if (!result) continue; + rv.push(result); + } + resolve(rv); + }); + } + getKeys() { + return new Promise(async (resolve, reject) => { + if (!window.indexedDB) return resolve([]); + const keys = await this.get("?EJS_KEYS!"); + if (!keys) return resolve([]); + resolve(keys); + }); + } } class EJS_DUMMYSTORAGE { diff --git a/data/src/utils.js b/data/src/utils.js new file mode 100644 index 0000000..c547fe5 --- /dev/null +++ b/data/src/utils.js @@ -0,0 +1,42 @@ +/** + * EJS Utility Functions + */ +class EJS_UTILS { + /** + * Computes a simple hash of the given data array. + * @param {Uint8Array} dataArray + * @returns {number} The computed hash. + */ + simpleHash(dataArray) { + let hash = 0; + for (let i = 0; i < dataArray.length; i++) { + hash = ((hash << 5) - hash + dataArray[i]) & 0xffffffff; + } + return hash; + } + + /** + * Cyrb53 hash function adapted for buffers. + * @param {*} charBuffer + * @param {*} seed + * @returns {string} Hexadecimal representation of the hash. + */ + async cyrb53(charBuffer, seed = 0) { + // https://stackoverflow.com/questions/7616461 + // Modified to accept a buffer instead of a string and return hex instead of an int + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for(let i = 0, ch; i < charBuffer.length; i++) { + ch = charBuffer[i]; + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + // Cyrb53 is a 53-bit hash; we need 14 hex characters to represent it, and the first char will + // always be 0 or 1 (since it is only 1 bit) + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, "0"); + }; +} diff --git a/docs/CACHING.md b/docs/CACHING.md new file mode 100644 index 0000000..97578a5 --- /dev/null +++ b/docs/CACHING.md @@ -0,0 +1,416 @@ +# EmulatorJS Caching System + +## Overview + +EmulatorJS implements a sophisticated unified caching system designed to optimize performance by minimizing redundant downloads and decompression operations. The system uses IndexedDB-based storage with intelligent cache validation to provide fast loading times for emulator cores, ROMs, BIOS files, and other assets. + +## High-Level Architecture + +### Unified Caching Strategy + +The caching system consists of two main components: + +1. **EJS_Download Manager** + - Handles file downloads with smart caching + - Validates cached content using HTTP headers (Cache-Control, Expires, Last-Modified) + - Automatically decompresses archives (ZIP, 7Z, RAR) + - Provides progress callbacks for download and decompression operations + +2. **EJS_Cache Storage** + - Custom IndexedDB-based storage for cached content + - Stores both metadata and file blobs in separate object stores + - Implements LRU (Least Recently Used) eviction policy + - Configurable size limits and age-based expiration + +### Cache Flow + +``` +Download Request → URL-based Cache Check → Cache Expiry Valid? + ↓ ↓ Yes + HEAD Request Check Return Cached Content + ↓ Modified + Download File → Decompress (if needed) → Store in Cache + ↓ + Return Content to Application +``` + +## Detailed Implementation + +### Download Manager (EJS_Download) + +The `EJS_Download` class handles all file downloads with intelligent caching: + +#### Cache Validation Strategy + +1. **Check URL-based cache**: Downloads are cached by URL, enabling reuse across sessions +2. **Validate cache expiry**: + - Uses `Cache-Control: max-age` header from server + - Falls back to `Expires` header if present + - Defaults to 5 days if no cache headers provided +3. **Conditional validation**: If cache expired, performs HEAD request to check `Last-Modified` +4. **Smart fallback**: Uses cached version if HEAD request fails or file unchanged + +#### Download Features + +- **Progress tracking**: Real-time callbacks for download and decompression progress +- **Automatic decompression**: Detects and extracts ZIP, 7Z, and RAR archives +- **Flexible response types**: Supports arraybuffer, text, blob, and other response types +- **Timeout handling**: Configurable request timeouts with abort controller +- **Force extraction**: Optional parameter to force decompression regardless of file extension + +### Cache Storage (EJS_Cache) + +#### Storage Backend +- **Technology**: IndexedDB via custom `EJS_STORAGE` wrapper +- **Database**: Configurable database name (typically game-specific) +- **Object Stores**: + - `cache`: Stores metadata (key, fileSize, timestamps, type, filename, URL, expiry) + - `blobs`: Stores actual file data separately for better performance +- **Indexes**: Type and URL indexes for efficient lookups +- **Key Structure**: `Obj-{hash}-{size}` format for content-based keys, or URL for download caching + +#### Cache Key Generation +```javascript +// Hash calculation for cache key (content-based) +generateCacheKey(dataArray) { + let hash = this.utils.simpleHash(dataArray); + const compressionCacheKey = "Obj-" + hash + "-" + dataArray.length; + return compressionCacheKey; +} +``` + +#### Cache Configuration +- **Default Size Limit**: 4GB (4096 MB) +- **Default Max Age**: 5 days (7200 minutes) +- **Min Age**: 1 hour or 10% of max age (whichever is greater) +- **Storage Location**: Browser's IndexedDB +- **Cleanup Policy**: + - LRU (Least Recently Used) eviction when size exceeded + - Age-based removal for items older than maxAge + - Automatic cleanup on first cache access per session + - Orphaned blob cleanup as failsafe + +#### Cache Item Structure +```javascript +class EJS_CacheItem { + constructor(key, files, added, type, responseType, filename, url, cacheExpiry) { + this.key = key; // Unique identifier + this.files = files; // Array of EJS_FileItem objects + this.added = added; // Timestamp when added to cache + this.lastAccessed = added; // Timestamp of last access + this.type = type; // Type: 'core', 'ROM', 'BIOS', etc. + this.responseType = responseType; // Original response type + this.filename = filename; // Original filename + this.url = url; // Source URL + this.cacheExpiry = cacheExpiry; // Expiry timestamp (from HTTP headers) + } + + size() { + // Calculates total size of all files + } +} + +class EJS_FileItem { + constructor(filename, bytes) { + this.filename = filename; // Filename (original or from archive) + this.bytes = bytes; // Uint8Array of file content + } +} +``` + +### File Type Handling + +#### All File Types +The unified caching system handles all file types through the same mechanism: + +- **Download**: Managed by `EJS_Download` with URL-based caching +- **Cache validation**: Automatic expiry checking and conditional requests +- **Decompression**: Automatic extraction of .zip, .7z, and .rar files +- **Storage**: Metadata and blobs stored in separate IndexedDB object stores +- **Retrieval**: URL or content-hash based lookups with index support + +#### Archive Handling +- **Automatic detection**: File extension (.zip, .7z, .rar) triggers decompression +- **Force extraction**: `forceExtract` parameter overrides extension check +- **Multiple files**: Each file in archive stored as separate `EJS_FileItem` +- **Progress tracking**: Real-time progress callbacks during extraction + +#### Response Type Support +- **arraybuffer**: Default, used for binary files and archives +- **text**: Automatically encoded to UTF-8 Uint8Array for storage +- **blob**: Converted to Uint8Array for consistent storage +- **others**: Converted to string then encoded for storage + +### Cache Operations + +#### Download with Caching +The `downloadFile()` method handles the complete download and caching workflow: + +```javascript +async downloadFile(url, type, options) { + // 1. Check cache by URL + const cached = await this.storageCache.get(url, false, "url"); + + // 2. Validate cache expiry + if (cached && cached.cacheExpiry > Date.now()) { + return cached; // Cache valid + } + + // 3. Perform conditional validation with HEAD request + const headResp = await fetch(url, { method: "HEAD" }); + const lastModified = headResp.headers.get("Last-Modified"); + if (lastModified && Date.parse(lastModified) <= cached.added) { + return cached; // File not modified + } + + // 4. Download file with progress tracking + const response = await fetch(url); + const data = await readWithProgress(response); + + // 5. Extract cache expiry from headers + const cacheExpiry = parseCacheHeaders(response.headers); + + // 6. Decompress if needed + if (isArchive(filename)) { + files = await decompress(data); + } + + // 7. Store in cache + const cacheItem = new EJS_CacheItem(key, files, Date.now(), + type, responseType, filename, url, cacheExpiry); + await this.storageCache.put(cacheItem); + + return cacheItem; +} +``` + +#### Cache Management Operations + +- **get(key, metadataOnly, indexName)**: Retrieve item by key or index (URL, type) +- **put(item)**: Store item with automatic size management and LRU eviction +- **delete(key)**: Remove item and its blobs from cache +- **clear()**: Remove all cached items +- **cleanup()**: Remove old/excess items based on age and size constraints +- **getKeys()**: List all cache keys +- **getSizes()**: Get size information for all cached items + +### Performance Optimizations + +#### Separate Metadata and Blob Storage +The cache uses two separate IndexedDB object stores: +- **Metadata store**: Small, fast lookups for cache hits/validation +- **Blob store**: Large file data loaded only when needed +- **Benefit**: Faster cache lookups without loading large blobs into memory + +#### Progress Tracking +All long-running operations provide progress callbacks: +```javascript +downloadFile(url, type, { + onProgress: (status, percent, loaded, total) => { + // status: "downloading" | "decompressing" | "complete" + console.log(`${status}: ${percent}%`); + } +}); +``` + +#### Memory Management +- **Chunked downloads**: Large files downloaded in chunks with progress tracking +- **LRU eviction**: Oldest accessed items removed first when size limit exceeded +- **Automatic cleanup**: Runs on first cache access each session +- **Orphan removal**: Failsafe cleanup removes blobs without metadata +- **Size-based eviction**: Pre-emptive removal when adding new items would exceed limit + +### Cache Validation and Invalidation + +#### Multi-Level Validation Strategy +The system uses a tiered approach to validate cached content: + +1. **Cache Expiry Check** (fastest) + - Checks `cacheExpiry` timestamp from HTTP headers + - Avoids network requests for valid cached items + - Default: 5 days from server's Cache-Control/Expires headers + +2. **Conditional HEAD Request** (medium) + - Performed only if cache expired or no expiry set + - Compares server's Last-Modified with cached item's `added` timestamp + - Reuses cache if file unchanged on server + +3. **Full Download** (slowest) + - Only when file modified or validation fails + - Replaces existing cache entry with new version + +#### Client-Side Management +- **Automatic cleanup**: On first cache access per session +- **Manual clearing**: Via cache management UI +- **Selective removal**: Delete individual items by key +- **Index-based queries**: Find items by URL or type + +### Error Handling + +#### Network Failures +- **Graceful fallback**: Uses expired cache if server unavailable +- **Timeout handling**: Configurable request timeout with abort controller +- **Error propagation**: Clear error messages for debugging + +#### Cache Failures +- **Silent degradation**: Cache disabled if IndexedDB unavailable +- **Orphan cleanup**: Removes blobs without metadata entries +- **Error recovery**: Falls back to fresh downloads when cache operations fail + +#### Decompression Errors +- **Error propagation**: Clear error messages from decompression library +- **Fallback**: Returns original data if decompression fails +- **Progress updates**: Status callbacks during decompression + +### Storage Architecture + +#### Unified Database Design +The current implementation uses a unified approach: +- **Single database**: One IndexedDB database per game/instance +- **Two object stores**: + - `cache`: Metadata with indexed fields (type, url) + - `blobs`: Large binary file data +- **Benefits**: + - Easier management and versioning + - Atomic transactions across stores + - Better performance than separate databases + - Simpler cleanup and maintenance + +#### Browser Compatibility +- **IndexedDB Required**: Core functionality depends on IndexedDB support +- **Fallback mode**: `EJS_DUMMYSTORAGE` for browsers without IndexedDB +- **Feature detection**: Automatic detection and graceful degradation + +## Configuration Options + +### EJS_Cache Constructor Parameters +```javascript +new EJS_Cache(enabled, databaseName, maxSizeMB, maxAgeMins, debug) +``` +- `enabled`: Enable/disable caching (default: true) +- `databaseName`: IndexedDB database name (game-specific) +- `maxSizeMB`: Maximum cache size in megabytes (default: 4096) +- `maxAgeMins`: Maximum item age in minutes (default: 7200 = 5 days) +- `debug`: Enable debug logging (default: false) + +### EJS_Download Constructor Parameters +```javascript +new EJS_Download(storageCache, EJS) +``` +- `storageCache`: EJS_Cache instance for storage (null disables caching) +- `EJS`: Main EmulatorJS instance for integration + +### downloadFile() Options +- `url`: Source URL (required) +- `type`: Content type (e.g., "ROM", "CORE", "BIOS") +- `method`: HTTP method (default: "GET") +- `headers`: Request headers object +- `body`: Request body for POST/PUT +- `onProgress`: Progress callback function +- `onComplete`: Completion callback function +- `timeout`: Request timeout in ms (default: 30000) +- `responseType`: Response type (default: "arraybuffer") +- `forceExtract`: Force decompression (default: false) +- `dontCache`: Skip caching for this download (default: false) + +## Best Practices + +### For Developers +1. **Set proper HTTP headers**: Use Cache-Control/Expires/Last-Modified on servers +2. **Monitor cache size**: Regular cleanup prevents storage quota issues +3. **Use progress callbacks**: Provide user feedback during long operations +4. **Handle failures gracefully**: Always provide fallback for cache failures +5. **Enable debug mode**: Use debug flag during development for detailed logging +6. **Test without cache**: Use `dontCache` option to verify download logic +7. **Optimize file delivery**: Smaller files and good compression improve cache efficiency + +### For Server Configuration +1. **Cache-Control headers**: Set appropriate max-age for assets +2. **Last-Modified headers**: Enable conditional requests +3. **Compression**: Pre-compress large assets (gzip, brotli) +4. **CDN usage**: Leverage CDN caching alongside client-side cache + +### For Users +1. **Monitor storage usage**: Check browser storage settings periodically +2. **Clear cache if needed**: Use cache management UI for cleanup +3. **Update browser**: Newer browsers have better IndexedDB performance + +## Troubleshooting + +### Common Issues + +#### Cache Not Working +- Check IndexedDB support: `!!window.indexedDB` +- Verify storage quota: Check browser storage settings +- Enable debug mode: Set `debug: true` in EJS_Cache constructor +- Check console: Look for cache operation logs + +#### Slow First Load +- Expected behavior: First load downloads and caches +- Subsequent loads: Should be much faster with cache hits +- Monitor progress: Use onProgress callback for user feedback + +#### Storage Quota Exceeded +- Automatic cleanup: Cache runs cleanup on startup +- Manual cleanup: Use cache management UI +- Reduce maxSizeMB: Lower the cache size limit +- Clear other data: Free up browser storage space + +#### Stale Content +- Check cache expiry: Default is 5 days +- Adjust maxAgeMins: Lower for more frequent updates +- Force refresh: Clear cache for specific items +- Server headers: Verify Cache-Control/Expires headers + +### Debug Information + +Enable debug mode for detailed logging: +```javascript +const cache = new EJS_Cache(true, "my-game", 4096, 7200, true); +``` + +Debug logs include: +- Cache initialization settings +- Startup cleanup results (items removed, size freed, time taken) +- Cache hit/miss information +- Download progress and timing +- Decompression progress +- Error details and stack traces + +## Cache Manager UI + +The Cache Manager provides a user interface for viewing and managing cached items. It displays information about cached content: + +### Display Information +- **Filename**: Original filename of the cached content +- **Type**: Content type (ROM, CORE, BIOS, etc.) +- **Size**: Total size of all files in the cache item +- **URL**: Source URL where content was downloaded from +- **Last Accessed**: Timestamp of last access with relative time +- **Added**: Timestamp when item was first cached +- **Cache Expiry**: When the cache entry expires (from HTTP headers) + +### Management Operations +- **View Details**: Inspect cache item metadata and file list +- **Delete Item**: Remove individual cache entries +- **Cleanup Now**: Run cleanup to remove old/excess items based on age and size +- **Clear All**: Remove all cached items from storage + +### Cache Statistics +- **Total Items**: Number of cached items +- **Total Size**: Combined size of all cached content +- **Hit Rate**: Percentage of cache hits vs. misses (if tracked) +- **Oldest Item**: Age of least recently accessed item + +## Summary + +This unified caching system provides: +- **Intelligent cache validation** using HTTP headers (Cache-Control, Expires, Last-Modified) +- **Automatic decompression** and caching of extracted files +- **Efficient storage** with separate metadata and blob stores +- **LRU eviction** with configurable size and age limits +- **Progress tracking** for downloads and decompression +- **Graceful degradation** when cache unavailable +- **Debug logging** for troubleshooting + +The system significantly improves EmulatorJS performance by eliminating redundant downloads and decompression operations while maintaining data freshness through smart validation. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9e29173 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +export default [ + { + files: ["**/*.js"], + languageOptions: { + ecmaVersion: 2020, + sourceType: "module", + globals: { + console: "readonly", + window: "readonly", + document: "readonly", + navigator: "readonly", + fetch: "readonly", + localStorage: "readonly", + sessionStorage: "readonly" + } + }, + rules: { + "no-var": "warn", + "prefer-const": "warn", + "prefer-arrow-callback": "warn" + } + } +]; diff --git a/index.html b/index.html index 8dbdd35..430a970 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,10 @@ - + + EmulatorJS - - + +