// MacroPad PWA Application // Supported command types for the macro builder. Kept in sync with the // server-side Command model: {type, value?, keys?, ms?, command?}. const COMMAND_TYPES = [ { value: 'text', label: 'Text', placeholder: 'Text to type', numeric: false }, { value: 'key', label: 'Key', placeholder: 'Key name, e.g. enter', numeric: false }, { value: 'hotkey', label: 'Hotkey', placeholder: 'Keys, e.g. ctrl, c', numeric: false }, { value: 'wait', label: 'Wait', placeholder: 'Milliseconds', numeric: true }, { value: 'app', label: 'App', placeholder: 'App or command to launch', numeric: false } ]; class MacroPadApp { constructor() { this.macros = {}; this.tabs = []; this.currentTab = 'All'; this.ws = null; this.wakeLock = null; // Relay mode detection this.relayMode = this.detectRelayMode(); this.sessionId = null; this.password = null; this.localToken = null; this.desktopConnected = true; this.wsAuthenticated = false; // WebSocket reconnect state (exponential backoff) this.shouldReconnect = true; this.reconnectDelay = 1000; this.reconnectTimer = null; // Guards against out-of-order macro fetches this._macroReqId = 0; // 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. this.localAuthFailed = false; // Macro editor state (null = create mode) this.editingMacroId = null; if (this.relayMode) { this.initRelayMode(); } else { this.initLocalMode(); } this.init(); } detectRelayMode() { // Check if URL matches relay pattern: /sessionId/app or /sessionId const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})(\/app)?$/); return pathMatch !== null; } initRelayMode() { // Extract session ID from URL const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})/); if (pathMatch) { this.sessionId = pathMatch[1]; } // Get password from URL query param or sessionStorage const urlParams = new URLSearchParams(window.location.search); this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`); if (this.password) { // Store password for future use sessionStorage.setItem(`macropad_${this.sessionId}`, this.password); // Clear from URL for security if (urlParams.has('auth')) { window.history.replaceState({}, '', window.location.pathname); } } console.log('Relay mode enabled, session:', this.sessionId); } initLocalMode() { // Get token from URL query param or localStorage (local/LAN mode) const urlParams = new URLSearchParams(window.location.search); this.localToken = urlParams.get('token') || localStorage.getItem('macropad_local_token'); if (this.localToken) { // Persist token and strip it from the URL for cleanliness localStorage.setItem('macropad_local_token', this.localToken); if (urlParams.has('token')) { window.history.replaceState({}, '', window.location.pathname); } } } getApiUrl(path) { if (this.relayMode && this.sessionId) { return `/${this.sessionId}${path}`; } return path; } getApiHeaders() { const headers = { 'Content-Type': 'application/json' }; if (this.relayMode && this.password) { headers['X-MacroPad-Password'] = this.password; } else if (!this.relayMode && this.localToken) { headers['X-MacroPad-Token'] = this.localToken; } return headers; } async init() { this.setupEventListeners(); await this.loadTabs(); await this.loadMacros(); this.setupWebSocket(); this.setupWakeLock(); this.checkInstallPrompt(); } // API Methods async loadTabs() { try { const response = await fetch(this.getApiUrl('/api/tabs'), { headers: this.getApiHeaders() }); if (!response.ok) { if (response.status === 401) { this.handleAuthError(); return; } throw new Error('Failed to load tabs'); } const data = await response.json(); this.tabs = data.tabs || []; this.renderTabs(); } catch (error) { console.error('Error loading tabs:', error); this.showToast('Error loading tabs', 'error'); } } async loadMacros() { // Only the latest request is allowed to render, so overlapping // tab-switch fetches can't apply out of order. const reqId = ++this._macroReqId; try { const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`; const response = await fetch(this.getApiUrl(path), { headers: this.getApiHeaders() }); if (reqId !== this._macroReqId) return; // superseded if (!response.ok) { if (response.status === 401) { this.handleAuthError(); return; } if (response.status === 503) { this.handleDesktopDisconnected(); return; } throw new Error('Failed to load macros'); } const data = await response.json(); if (reqId !== this._macroReqId) return; // superseded this.macros = data.macros || {}; this.renderMacros(); } catch (error) { if (reqId !== this._macroReqId) return; console.error('Error loading macros:', error); this.showToast('Error loading macros', 'error'); } } async executeMacro(macroId) { const card = this.findCard(macroId); if (card) card.classList.add('executing'); try { const response = await fetch(this.getApiUrl('/api/execute'), { method: 'POST', headers: this.getApiHeaders(), body: JSON.stringify({ macro_id: macroId }) }); if (!response.ok) { if (response.status === 401) { this.handleAuthError(); return; } if (response.status === 503) { this.handleDesktopDisconnected(); } throw new Error('Execution failed'); } } catch (error) { console.error('Error executing macro:', error); this.showToast('Error executing macro', 'error'); } finally { // Always clear the animation state, even on failure. setTimeout(() => { if (card) card.classList.remove('executing'); }, 300); } } handleAuthError() { if (this.relayMode) { this.showToast('Authentication failed', 'error'); // Clear stored password and redirect to login sessionStorage.removeItem(`macropad_${this.sessionId}`); window.location.href = `/${this.sessionId}`; return; } // Local (token) mode: a wrong/missing token makes every request 401 and // the WebSocket close with 1008, which would otherwise reconnect forever // and spam toasts. Surface a single, clear message and stop retrying. if (this.localAuthFailed) return; this.localAuthFailed = true; this.shouldReconnect = false; clearTimeout(this.reconnectTimer); this.teardownSocket(); this.updateConnectionStatus(false, 'Access denied'); this.renderAuthRequired(); } renderAuthRequired() { const container = document.getElementById('macro-grid'); if (!container) return; container.textContent = ''; const wrap = document.createElement('div'); wrap.className = 'empty-state'; const p1 = document.createElement('p'); p1.textContent = 'Access token required'; const p2 = document.createElement('p'); p2.className = 'hint'; p2.textContent = "Open the link from the desktop app's URL or QR code."; wrap.append(p1, p2); container.appendChild(wrap); } handleDesktopDisconnected() { this.desktopConnected = false; this.updateConnectionStatus(false, 'Desktop offline'); this.showToast('Desktop app is not connected', 'error'); } // WebSocket teardownSocket() { if (this.ws) { // Detach handlers so the old socket can't fire reconnect logic. this.ws.onopen = null; this.ws.onclose = null; this.ws.onmessage = null; this.ws.onerror = null; try { this.ws.close(); } catch (e) { /* ignore */ } this.ws = null; } } scheduleReconnect() { if (!this.shouldReconnect) return; clearTimeout(this.reconnectTimer); const delay = this.reconnectDelay; this.reconnectTimer = setTimeout(() => this.setupWebSocket(), delay); // Exponential backoff, capped at 30s. this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); } setupWebSocket() { if (!this.shouldReconnect) return; // Clean up any prior socket before opening a new one. this.teardownSocket(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl; if (this.relayMode && this.sessionId) { wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`; } else { wsUrl = `${protocol}//${window.location.host}/ws`; // Append token as query param (WebSocket can't set headers) if (this.localToken) { wsUrl += `?token=${encodeURIComponent(this.localToken)}`; } } try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { // Successful connection resets the backoff. this.reconnectDelay = 1000; if (!this.relayMode) { this.updateConnectionStatus(true); } // In relay mode, wait for auth before showing connected }; this.ws.onclose = () => { this.wsAuthenticated = false; this.updateConnectionStatus(false); this.scheduleReconnect(); }; this.ws.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch (e) { // Ignore malformed frames. return; } this.handleWebSocketMessage(data); }; this.ws.onerror = () => { // Let onclose own reconnection; just reflect status here. this.updateConnectionStatus(false); }; } catch (error) { console.error('WebSocket error:', error); this.scheduleReconnect(); } } handleWebSocketMessage(data) { switch (data.type) { // Relay-specific messages case 'auth_required': // Send authentication if (this.password && this.ws) { this.ws.send(JSON.stringify({ type: 'auth', password: this.password })); } break; case 'auth_response': if (data.success) { this.wsAuthenticated = true; this.updateConnectionStatus(this.desktopConnected); } else { this.handleAuthError(); } break; case 'desktop_status': this.desktopConnected = data.status === 'connected'; this.updateConnectionStatus(this.desktopConnected); if (!this.desktopConnected) { this.showToast('Desktop disconnected', 'error'); } else { this.showToast('Desktop connected', 'success'); this.loadTabs(); this.loadMacros(); } break; // Standard MacroPad messages case 'macro_created': case 'macro_updated': case 'macro_deleted': this.loadTabs(); this.loadMacros(); break; case 'executed': { const card = this.findCard(data.macro_id); if (card) { card.classList.add('executing'); setTimeout(() => card.classList.remove('executing'), 300); } break; } case 'pong': // Keep-alive response break; } } updateConnectionStatus(connected, customText = null) { const dot = document.querySelector('.status-dot'); const text = document.querySelector('.connection-status span'); if (dot) { dot.classList.toggle('connected', connected); } if (text) { if (customText) { text.textContent = customText; } else if (this.relayMode) { text.textContent = connected ? 'Connected (Relay)' : 'Disconnected'; } else { text.textContent = connected ? 'Connected' : 'Disconnected'; } } } // Deterministic hue from a macro name so the CSS can color placeholders. hashHue(name) { const s = String(name || ''); let hash = 0; for (let i = 0; i < s.length; i++) { hash = (hash * 31 + s.charCodeAt(i)) | 0; } return Math.abs(hash) % 360; } findCard(macroId) { const cards = document.querySelectorAll('.macro-card'); for (const card of cards) { if (card.dataset.macroId === macroId) return card; } return null; } macroImageUrl(macro) { if (!macro.image_path) return null; // No credential ever goes on the URL; auth is via a header on a // fetch() below, and the image is shown as a blob object URL. return this.getApiUrl(`/api/image/${macro.image_path}`); } // Auth-only headers (no Content-Type) for GET fetches like images. getAuthHeaders() { const headers = {}; if (this.relayMode && this.password) { headers['X-MacroPad-Password'] = this.password; } else if (!this.relayMode && this.localToken) { headers['X-MacroPad-Token'] = this.localToken; } 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. The fetched blob is // cached (images are immutable per URL) and reused across re-renders. async loadMacroImage(url, img, placeholder) { try { const objectUrl = await this._getImageUrl(url); img.src = objectUrl; } catch (e) { // Fall back to the placeholder if the image can't be loaded. img.style.display = 'none'; placeholder.style.display = ''; } } // 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'); if (!container) return; container.textContent = ''; this.tabs.forEach((tab) => { const active = tab === this.currentTab; const btn = document.createElement('button'); btn.type = 'button'; btn.className = active ? 'tab active' : 'tab'; btn.setAttribute('role', 'tab'); btn.setAttribute('aria-selected', active ? 'true' : 'false'); btn.dataset.tab = tab; btn.textContent = tab; container.appendChild(btn); }); } renderMacros() { const container = document.getElementById('macro-grid'); if (!container) return; container.textContent = ''; const macroEntries = Object.entries(this.macros); if (macroEntries.length === 0) { container.appendChild(this.buildEmptyState()); return; } const fragment = document.createDocumentFragment(); macroEntries.forEach(([id, macro]) => { fragment.appendChild(this.buildMacroCard(id, macro)); }); container.appendChild(fragment); } buildMacroCard(id, macro) { const name = macro.name || ''; const card = document.createElement('button'); card.type = 'button'; card.className = 'macro-card'; card.dataset.macroId = id; card.setAttribute('aria-label', `Run ${name}`); card.style.setProperty('--macro-color', `hsl(${this.hashHue(name)} 55% 45%)`); const placeholder = document.createElement('span'); placeholder.className = 'macro-image-placeholder'; placeholder.textContent = (name.charAt(0) || '?').toUpperCase(); const imageSrc = this.macroImageUrl(macro); if (imageSrc) { const img = document.createElement('img'); img.className = 'macro-image'; img.alt = ''; placeholder.style.display = 'none'; // Fall back to the placeholder if the (blob) image fails to load. img.addEventListener('error', () => { img.style.display = 'none'; placeholder.style.display = ''; }); card.appendChild(img); // Header-authenticated fetch -> blob object URL (no credential on src). this.loadMacroImage(imageSrc, img, placeholder); } card.appendChild(placeholder); const nameEl = document.createElement('span'); nameEl.className = 'macro-name'; nameEl.textContent = name; card.appendChild(nameEl); const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'macro-edit-btn'; editBtn.setAttribute('aria-label', `Edit ${name}`); editBtn.textContent = '✎'; editBtn.addEventListener('click', (e) => { // Don't trigger execute when editing. e.stopPropagation(); this.openMacroModal(id); }); card.appendChild(editBtn); card.addEventListener('click', () => this.executeMacro(id)); return card; } buildEmptyState() { const wrap = document.createElement('div'); wrap.className = 'empty-state'; const p1 = document.createElement('p'); p1.textContent = 'No macros yet'; const p2 = document.createElement('p'); p2.className = 'hint'; p2.textContent = 'Build one right here in your browser.'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-primary'; btn.textContent = 'Create your first macro'; btn.addEventListener('click', () => this.openMacroModal()); wrap.append(p1, p2, btn); return wrap; } // ---- Macro editor (create / edit / delete) ---- populateCategoryOptions() { const list = document.getElementById('macro-category-list'); if (!list) return; list.textContent = ''; this.tabs .filter((t) => t && t !== 'All') .forEach((t) => { const opt = document.createElement('option'); opt.value = t; list.appendChild(opt); }); } commandToInputValue(cmd) { switch (cmd.type) { case 'hotkey': return Array.isArray(cmd.keys) ? cmd.keys.join(', ') : (cmd.keys || ''); case 'wait': return cmd.ms != null ? String(cmd.ms) : ''; case 'app': return cmd.command || ''; case 'text': case 'key': default: return cmd.value || ''; } } applyCommandRowType(select, input) { const meta = COMMAND_TYPES.find((t) => t.value === select.value) || COMMAND_TYPES[0]; input.type = meta.numeric ? 'number' : 'text'; input.placeholder = meta.placeholder; if (meta.numeric) { input.min = '0'; } else { input.removeAttribute('min'); } } addCommandRow(type = 'text', cmd = null) { const list = document.getElementById('command-list'); if (!list) return; const row = document.createElement('div'); row.className = 'command-item'; const select = document.createElement('select'); select.className = 'command-type-select'; COMMAND_TYPES.forEach((t) => { const opt = document.createElement('option'); opt.value = t.value; opt.textContent = t.label; select.appendChild(opt); }); select.value = COMMAND_TYPES.some((t) => t.value === type) ? type : 'text'; const input = document.createElement('input'); input.className = 'command-value'; input.autocomplete = 'off'; const actions = document.createElement('div'); actions.className = 'command-actions'; const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.textContent = '↑'; upBtn.setAttribute('aria-label', 'Move step up'); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.textContent = '↓'; downBtn.setAttribute('aria-label', 'Move step down'); const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'delete'; delBtn.textContent = '✕'; delBtn.setAttribute('aria-label', 'Remove step'); this.applyCommandRowType(select, input); if (cmd) { input.value = this.commandToInputValue(cmd); } select.addEventListener('change', () => this.applyCommandRowType(select, input)); upBtn.addEventListener('click', () => { const prev = row.previousElementSibling; if (prev) list.insertBefore(row, prev); }); downBtn.addEventListener('click', () => { const next = row.nextElementSibling; if (next) list.insertBefore(next, row); }); delBtn.addEventListener('click', () => row.remove()); actions.append(upBtn, downBtn, delBtn); row.append(select, input, actions); list.appendChild(row); } renderCommandRows(commands) { const list = document.getElementById('command-list'); if (!list) return; list.textContent = ''; commands.forEach((cmd) => this.addCommandRow(cmd.type || 'text', cmd)); } collectCommands() { const list = document.getElementById('command-list'); if (!list) return []; const commands = []; list.querySelectorAll('.command-item').forEach((row) => { const type = row.querySelector('.command-type-select').value; const raw = row.querySelector('.command-value').value.trim(); switch (type) { case 'hotkey': { // Store keys as a list (split on commas/whitespace). const keys = raw.split(/[\s,]+/).filter(Boolean); commands.push({ type: 'hotkey', keys }); break; } case 'wait': { const ms = parseInt(raw, 10); commands.push({ type: 'wait', ms: Number.isFinite(ms) ? ms : 0 }); break; } case 'app': commands.push({ type: 'app', command: raw }); break; case 'key': commands.push({ type: 'key', value: raw }); break; case 'text': default: commands.push({ type: 'text', value: raw }); break; } }); return commands; } async openMacroModal(macroId = null) { this.editingMacroId = macroId || null; const modal = document.getElementById('macro-modal'); const title = document.getElementById('macro-modal-title'); const nameInput = document.getElementById('macro-name-input'); const categoryInput = document.getElementById('macro-category-input'); const deleteBtn = document.getElementById('delete-macro-btn'); if (!modal) return; this.populateCategoryOptions(); nameInput.value = ''; categoryInput.value = ''; this.renderCommandRows([]); if (macroId) { title.textContent = 'Edit Macro'; if (deleteBtn) deleteBtn.hidden = false; try { const res = await fetch( this.getApiUrl(`/api/macro/${encodeURIComponent(macroId)}`), { headers: this.getApiHeaders() } ); if (!res.ok) { if (res.status === 401) { this.handleAuthError(); return; } throw new Error('Failed to load macro'); } const data = await res.json(); const macro = data.macro || {}; nameInput.value = macro.name || ''; categoryInput.value = macro.category || ''; this.renderCommandRows(Array.isArray(macro.commands) ? macro.commands : []); } catch (e) { console.error('Error loading macro:', e); this.showToast('Could not load macro', 'error'); } } else { title.textContent = 'New Macro'; if (deleteBtn) deleteBtn.hidden = true; categoryInput.value = (this.currentTab && this.currentTab !== 'All') ? this.currentTab : ''; this.addCommandRow('text'); } modal.hidden = false; nameInput.focus(); } closeMacroModal() { const modal = document.getElementById('macro-modal'); if (modal) modal.hidden = true; this.editingMacroId = null; } async saveMacro() { const nameInput = document.getElementById('macro-name-input'); const categoryInput = document.getElementById('macro-category-input'); const saveBtn = document.getElementById('save-macro-btn'); const name = nameInput.value.trim(); const category = categoryInput.value.trim(); const commands = this.collectCommands(); if (!name) { this.showToast('Please enter a name', 'error'); nameInput.focus(); return; } if (commands.length === 0) { this.showToast('Add at least one step', 'error'); return; } const editing = this.editingMacroId; const url = editing ? this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`) : this.getApiUrl('/api/macros'); const method = editing ? 'PUT' : 'POST'; const body = JSON.stringify({ name, commands, category }); if (saveBtn) saveBtn.disabled = true; try { const res = await fetch(url, { method, headers: this.getApiHeaders(), body }); if (!res.ok) { if (res.status === 401) { this.handleAuthError(); return; } throw new Error('Save failed'); } this.showToast(editing ? 'Macro updated' : 'Macro created', 'success'); this.closeMacroModal(); // WS broadcast will also refresh, but reload immediately for snappiness. await this.loadTabs(); await this.loadMacros(); } catch (e) { console.error('Error saving macro:', e); this.showToast('Error saving macro', 'error'); } finally { if (saveBtn) saveBtn.disabled = false; } } async deleteMacro() { const editing = this.editingMacroId; if (!editing) return; const nameInput = document.getElementById('macro-name-input'); const name = (nameInput && nameInput.value.trim()) || 'this macro'; if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) return; try { const res = await fetch( this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`), { method: 'DELETE', headers: this.getApiHeaders() } ); if (!res.ok) { if (res.status === 401) { this.handleAuthError(); return; } throw new Error('Delete failed'); } this.showToast('Macro deleted', 'success'); this.closeMacroModal(); await this.loadTabs(); await this.loadMacros(); } catch (e) { console.error('Error deleting macro:', e); this.showToast('Error deleting macro', 'error'); } } // Event Listeners setupEventListeners() { // Tab clicks document.getElementById('tabs-container')?.addEventListener('click', (e) => { const tab = e.target.closest('.tab'); if (tab) { this.currentTab = tab.dataset.tab; this.renderTabs(); this.loadMacros(); } }); // Header controls document.getElementById('refresh-btn')?.addEventListener('click', () => this.refresh()); document.getElementById('fullscreen-btn')?.addEventListener('click', () => this.toggleFullscreen()); // Macro editor controls document.getElementById('new-macro-btn')?.addEventListener('click', () => this.openMacroModal()); document.getElementById('add-command-btn')?.addEventListener('click', () => this.addCommandRow('text')); document.getElementById('save-macro-btn')?.addEventListener('click', () => this.saveMacro()); document.getElementById('cancel-macro-btn')?.addEventListener('click', () => this.closeMacroModal()); document.getElementById('delete-macro-btn')?.addEventListener('click', () => this.deleteMacro()); document.getElementById('macro-modal-close')?.addEventListener('click', () => this.closeMacroModal()); const modal = document.getElementById('macro-modal'); modal?.addEventListener('click', (e) => { // Click on the backdrop (not the content) closes. if (e.target === modal) this.closeMacroModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal && !modal.hidden) this.closeMacroModal(); }); // Stop reconnecting once the page is going away. const stop = () => { this.shouldReconnect = false; clearTimeout(this.reconnectTimer); this.teardownSocket(); }; window.addEventListener('pagehide', stop); window.addEventListener('beforeunload', stop); } // Toast notifications showToast(message, type = 'info') { const container = document.getElementById('toast-container'); if (!container) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.remove(); }, 3000); } // PWA Install Prompt checkInstallPrompt() { let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; this.showInstallBanner(deferredPrompt); }); } showInstallBanner(deferredPrompt) { const banner = document.createElement('div'); banner.className = 'install-banner'; const label = document.createElement('span'); label.textContent = 'Install MacroPad for quick access'; const actions = document.createElement('div'); const installBtn = document.createElement('button'); installBtn.type = 'button'; installBtn.textContent = 'Install'; installBtn.addEventListener('click', () => this.installPWA()); const dismissBtn = document.createElement('button'); dismissBtn.type = 'button'; dismissBtn.className = 'dismiss'; dismissBtn.textContent = '✕'; dismissBtn.setAttribute('aria-label', 'Dismiss install banner'); dismissBtn.addEventListener('click', () => banner.remove()); actions.append(installBtn, dismissBtn); banner.append(label, actions); document.body.insertBefore(banner, document.body.firstChild); this.deferredPrompt = deferredPrompt; } async installPWA() { if (!this.deferredPrompt) return; this.deferredPrompt.prompt(); const { outcome } = await this.deferredPrompt.userChoice; if (outcome === 'accepted') { document.querySelector('.install-banner')?.remove(); } this.deferredPrompt = null; } // Refresh 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 - prevents screen from sleeping async setupWakeLock() { const status = document.getElementById('wake-lock-status'); if (!('wakeLock' in navigator)) { console.log('Wake Lock API not supported'); // Don't remove the icon - show it as unsupported instead if (status) { status.classList.add('unsupported'); status.title = 'Wake lock not available (requires HTTPS)'; } return; } // Make the icon clickable to toggle wake lock if (status) { status.style.cursor = 'pointer'; status.addEventListener('click', () => this.toggleWakeLock()); } // Request wake lock automatically await this.requestWakeLock(); // Re-acquire wake lock when page becomes visible again document.addEventListener('visibilitychange', async () => { if (document.visibilityState === 'visible' && this.wakeLockEnabled) { await this.requestWakeLock(); } }); } async toggleWakeLock() { if (this.wakeLock) { // Release wake lock await this.wakeLock.release(); this.wakeLock = null; this.wakeLockEnabled = false; this.updateWakeLockStatus(false); this.showToast('Screen can now sleep', 'info'); } else { // Request wake lock 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); // Show error only if user explicitly tried to enable 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)'; } } } } // Initialize app let app; document.addEventListener('DOMContentLoaded', () => { app = new MacroPadApp(); }); // Register service worker if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/service-worker.js') .then((registration) => { console.log('SW registered:', registration.scope); }) .catch((error) => { console.log('SW registration failed:', error); }); }); }