diff --git a/config.py b/config.py index afaadf7..f94c029 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,6 @@ # Configuration and constants for MacroPad Server -VERSION = "1.1.1" +VERSION = "1.1.2" DEFAULT_PORT = 40000 SETTINGS_FILE = "settings.json" diff --git a/version.txt b/version.txt index 8cfbc90..8428158 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1.1 \ No newline at end of file +1.1.2 \ No newline at end of file diff --git a/web/js/app.js b/web/js/app.js index 6eb12b7..9ce870d 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -34,8 +34,10 @@ class MacroPadApp { // Guards against out-of-order macro fetches this._macroReqId = 0; - // Blob object URLs created for macro images (revoked on re-render) - this._objectUrls = []; + // Cache of image URL -> Promise. Macro images are + // content-addressed by uuid filename, so they are immutable; caching + // avoids re-fetching every image on each re-render. + this._imageCache = new Map(); // Set once a local-mode auth failure has been surfaced, to avoid // reconnect loops and repeated toasts. @@ -451,16 +453,12 @@ class MacroPadApp { return headers; } - // Fetch a macro image with the auth header and display it as a blob - // object URL, so no credential is ever placed on an src. + // Fetch a macro image with the auth header and display it as a blob object + // URL, so no credential is ever placed on an src. The fetched blob is + // cached (images are immutable per URL) and reused across re-renders. async loadMacroImage(url, img, placeholder) { try { - const res = await fetch(url, { headers: this.getAuthHeaders() }); - if (!res.ok) throw new Error('image request failed'); - const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - // Track for best-effort revocation on the next render. - this._objectUrls.push(objectUrl); + const objectUrl = await this._getImageUrl(url); img.src = objectUrl; } catch (e) { // Fall back to the placeholder if the image can't be loaded. @@ -469,6 +467,23 @@ class MacroPadApp { } } + // Returns a cached Promise for an image URL, fetching once and + // retaining it for the page's lifetime (immutable content, nothing to + // invalidate); de-duplicates concurrent requests too. + _getImageUrl(url) { + let entry = this._imageCache.get(url); + if (entry) return entry; + entry = (async () => { + const res = await fetch(url, { headers: this.getAuthHeaders() }); + if (!res.ok) throw new Error('image request failed'); + const blob = await res.blob(); + return URL.createObjectURL(blob); + })(); + entry.catch(() => this._imageCache.delete(url)); + this._imageCache.set(url, entry); + return entry; + } + // Rendering (safe DOM construction only - no user value reaches innerHTML) renderTabs() { const container = document.getElementById('tabs-container'); @@ -492,12 +507,6 @@ class MacroPadApp { const container = document.getElementById('macro-grid'); if (!container) return; - // Best-effort: revoke object URLs from the previous render to avoid leaks. - if (this._objectUrls && this._objectUrls.length) { - this._objectUrls.forEach((u) => URL.revokeObjectURL(u)); - } - this._objectUrls = []; - container.textContent = ''; const macroEntries = Object.entries(this.macros); diff --git a/web_server.py b/web_server.py index 9d0e10d..3dc3066 100644 --- a/web_server.py +++ b/web_server.py @@ -338,7 +338,11 @@ class WebServer: # Only serve files that resolve to inside the macro_images directory if (os.path.commonpath([requested, images_real]) == images_real and os.path.isfile(requested)): - return FileResponse(requested) + # Macro images are content-addressed (uuid filenames) and thus + # immutable, so let the browser cache them aggressively. + return FileResponse(requested, headers={ + "Cache-Control": "private, max-age=31536000, immutable" + }) raise HTTPException(status_code=404, detail="Image not found") @app.websocket("/ws")