// MacroPad App for Relay Mode (externalized so the CSP can drop // 'unsafe-inline' for scripts). All macro/tab/id/name values are rendered // with safe DOM APIs (createElement/textContent) and never via innerHTML. class MacroPadApp { constructor() { this.macros = {}; this.tabs = []; this.currentTab = 'All'; this.ws = null; // Cache of image_path -> Promise. Macro images are // content-addressed by uuid filename, so they are immutable: once // fetched, an image never changes for a given path. Caching here avoids // re-fetching every image through the relay on each re-render. this._imageCache = new Map(); this.desktopConnected = false; this.wsAuthenticated = false; // Reconnect backoff. Reset only after a successful AUTH (not on // socket open): a server that accepts the socket and then closes it // would otherwise reset the backoff on every open and defeat it. this.baseReconnectDelay = 3000; this.maxReconnectDelay = 30000; this.reconnectDelay = this.baseReconnectDelay; // Get session ID from URL const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/); this.sessionId = pathMatch ? pathMatch[1] : null; // Get password from storage (never from the URL). this.password = sessionStorage.getItem(`macropad_${this.sessionId}`) || localStorage.getItem(`macropad_${this.sessionId}`); if (this.password) { sessionStorage.setItem(`macropad_${this.sessionId}`, this.password); } this.init(); } async init() { this.wakeLock = null; this.wakeLockEnabled = false; this.setupPWA(); this.setupWebSocket(); this.setupEventListeners(); this.setupWakeLock(); } getApiHeaders() { return { 'Content-Type': 'application/json', 'X-MacroPad-Password': this.password || '' }; } async loadTabs() { try { const response = await fetch(`/${this.sessionId}/api/tabs`, { headers: this.getApiHeaders() }); if (response.status === 401) return this.handleAuthError(); if (response.status === 503) return this.handleDesktopOffline(); const data = await response.json(); this.tabs = data.tabs || []; this.renderTabs(); } catch (error) { console.error('Error loading tabs:', error); } } async loadMacros() { try { const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`; const response = await fetch(`/${this.sessionId}${path}`, { headers: this.getApiHeaders() }); if (response.status === 401) return this.handleAuthError(); if (response.status === 503) return this.handleDesktopOffline(); const data = await response.json(); this.macros = data.macros || {}; this.renderMacros(); } catch (error) { console.error('Error loading macros:', error); } } async executeMacro(macroId) { const card = document.querySelector(`[data-macro-id="${macroId}"]`); if (card) card.classList.add('executing'); try { const response = await fetch(`/${this.sessionId}/api/execute`, { method: 'POST', headers: this.getApiHeaders(), body: JSON.stringify({ macro_id: macroId }) }); if (!response.ok) throw new Error('Failed'); } catch (error) { this.showToast('Execution failed', 'error'); } setTimeout(() => card?.classList.remove('executing'), 300); } handleAuthError() { sessionStorage.removeItem(`macropad_${this.sessionId}`); localStorage.removeItem(`macropad_${this.sessionId}`); window.location.href = `/${this.sessionId}`; } handleDesktopOffline() { this.desktopConnected = false; this.updateConnectionStatus(false); document.getElementById('offline-banner').classList.add('visible'); } setupWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`; this.ws = new WebSocket(wsUrl); this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleMessage(data); }; this.ws.onclose = () => { this.wsAuthenticated = false; this.updateConnectionStatus(false); // Reconnect using the current backoff, then grow it (capped) for // the next attempt. The backoff is only reset on a successful // auth response, so accept-then-close cycles keep backing off. const delay = this.reconnectDelay; this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); setTimeout(() => this.setupWebSocket(), delay); }; this.ws.onerror = () => this.updateConnectionStatus(false); } handleMessage(data) { switch (data.type) { case 'auth_required': if (this.password) { this.ws.send(JSON.stringify({ type: 'auth', password: this.password })); } break; case 'auth_response': if (data.success) { this.wsAuthenticated = true; // Reset the reconnect backoff only after a confirmed, // successful auth (not merely on socket open). this.reconnectDelay = this.baseReconnectDelay; this.updateConnectionStatus(this.desktopConnected); } else { this.handleAuthError(); } break; case 'desktop_status': this.desktopConnected = data.status === 'connected'; this.updateConnectionStatus(this.desktopConnected); document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected); if (this.desktopConnected) { this.loadTabs(); this.loadMacros(); } break; case 'macro_created': case 'macro_updated': case 'macro_deleted': this.loadTabs(); this.loadMacros(); break; case 'executed': { const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`); if (card) { card.classList.add('executing'); setTimeout(() => card.classList.remove('executing'), 300); } break; } } } updateConnectionStatus(connected) { const dot = document.querySelector('.status-dot'); const text = document.querySelector('.connection-status span'); if (dot) dot.classList.toggle('connected', connected); if (text) text.textContent = connected ? 'Connected' : 'Disconnected'; } renderTabs() { const container = document.getElementById('tabs-container'); container.replaceChildren(); for (const tab of this.tabs) { const name = typeof tab === 'string' ? tab : String(tab); const btn = document.createElement('button'); btn.className = name === this.currentTab ? 'tab active' : 'tab'; btn.setAttribute('data-tab', name); btn.textContent = name; container.appendChild(btn); } } renderMacros() { const container = document.getElementById('macro-grid'); const entries = Object.entries(this.macros); container.replaceChildren(); if (entries.length === 0) { const empty = document.createElement('div'); empty.className = 'empty-state'; const p = document.createElement('p'); p.textContent = 'No macros found'; empty.appendChild(p); container.appendChild(empty); return; } for (const [id, macro] of entries) { const name = macro && typeof macro.name === 'string' ? macro.name : ''; const hasImage = !!(macro && macro.image_path); const firstChar = name.charAt(0).toUpperCase(); const card = document.createElement('div'); card.className = 'macro-card'; card.setAttribute('data-macro-id', id); card.addEventListener('click', () => this.executeMacro(id)); if (hasImage) { const img = document.createElement('img'); img.className = 'macro-image'; img.style.display = 'none'; card.appendChild(img); } const placeholder = document.createElement('div'); placeholder.className = 'macro-image-placeholder'; placeholder.textContent = firstChar; card.appendChild(placeholder); const nameSpan = document.createElement('span'); nameSpan.className = 'macro-name'; nameSpan.textContent = name; card.appendChild(nameSpan); container.appendChild(card); } // Load macro images via authenticated fetch (header, not URL). this.loadMacroImages(); } // Fetch each macro image with the password header and display it as a blob // object URL so the credential never appears in a URL. Images are fetched // in parallel and cached (see _imageCache) so re-renders reuse them instead // of re-fetching through the relay each time. loadMacroImages() { for (const [id, macro] of Object.entries(this.macros)) { if (!macro.image_path) continue; const card = document.querySelector(`[data-macro-id="${id}"]`); if (!card) continue; const img = card.querySelector('.macro-image'); if (!img) continue; this._applyMacroImage(img, macro.image_path); } } async _applyMacroImage(img, imagePath) { try { const objectUrl = await this._getImageUrl(imagePath); img.src = objectUrl; img.style.display = ''; const placeholder = img.nextElementSibling; if (placeholder) placeholder.style.display = 'none'; } catch (error) { // Leave the placeholder visible on failure. } } // Returns a cached Promise for an image path, fetching once. // The object URL is retained for the page's lifetime (images are immutable // per path, so there is nothing to invalidate); this de-duplicates // concurrent requests and eliminates re-fetching on re-render. _getImageUrl(imagePath) { let entry = this._imageCache.get(imagePath); if (entry) return entry; entry = (async () => { const response = await fetch( `/${this.sessionId}/api/image/${imagePath}`, { headers: this.getApiHeaders() } ); if (!response.ok) throw new Error(`image ${response.status}`); const blob = await response.blob(); return URL.createObjectURL(blob); })(); // Drop failed fetches from the cache so a later render can retry. entry.catch(() => this._imageCache.delete(imagePath)); this._imageCache.set(imagePath, entry); return entry; } setupEventListeners() { document.getElementById('tabs-container').addEventListener('click', (e) => { if (e.target.classList.contains('tab')) { this.currentTab = e.target.dataset.tab; this.renderTabs(); this.loadMacros(); } }); const fullscreenBtn = document.getElementById('fullscreen-btn'); if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); const refreshBtn = document.getElementById('refresh-btn'); if (refreshBtn) refreshBtn.addEventListener('click', () => this.refresh()); } showToast(message, type = 'info') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => toast.remove(), 3000); } refresh() { this.loadTabs(); this.loadMacros(); } // Fullscreen toggleFullscreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(err => { console.log('Fullscreen error:', err); }); } else { document.exitFullscreen(); } } // Wake Lock async setupWakeLock() { const status = document.getElementById('wake-lock-status'); if (!('wakeLock' in navigator)) { console.log('Wake Lock API not supported'); if (status) { status.classList.add('unsupported'); status.title = 'Wake lock not available (requires HTTPS)'; } return; } if (status) { status.style.cursor = 'pointer'; status.addEventListener('click', () => this.toggleWakeLock()); } await this.requestWakeLock(); document.addEventListener('visibilitychange', async () => { if (document.visibilityState === 'visible' && this.wakeLockEnabled) { await this.requestWakeLock(); } }); } async toggleWakeLock() { if (this.wakeLock) { await this.wakeLock.release(); this.wakeLock = null; this.wakeLockEnabled = false; this.updateWakeLockStatus(false); this.showToast('Screen can now sleep', 'info'); } else { this.wakeLockEnabled = true; await this.requestWakeLock(); if (this.wakeLock) { this.showToast('Screen will stay awake', 'success'); } } } async requestWakeLock() { try { this.wakeLock = await navigator.wakeLock.request('screen'); this.wakeLockEnabled = true; this.updateWakeLockStatus(true); this.wakeLock.addEventListener('release', () => { this.updateWakeLockStatus(false); }); } catch (err) { console.log('Wake Lock error:', err); this.updateWakeLockStatus(false); const status = document.getElementById('wake-lock-status'); if (status && !status.classList.contains('unsupported')) { status.title = 'Wake lock failed: ' + err.message; } } } updateWakeLockStatus(active) { const status = document.getElementById('wake-lock-status'); if (status) { status.classList.toggle('active', active); if (!status.classList.contains('unsupported')) { status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)'; } } } // PWA manifest setup setupPWA() { // Create dynamic manifest for this session const manifest = { name: 'MacroPad', short_name: 'MacroPad', description: 'Remote macro control', start_url: `/${this.sessionId}/app`, display: 'standalone', background_color: '#2e2e2e', theme_color: '#007acc', icons: [ { src: '/static/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/static/icons/icon-512.png', sizes: '512x512', type: 'image/png' } ] }; const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' }); const manifestUrl = URL.createObjectURL(blob); document.getElementById('manifest-link').setAttribute('href', manifestUrl); } } let app; document.addEventListener('DOMContentLoaded', () => { app = new MacroPadApp(); });