// MacroPad PWA Application (Execute-only) 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.desktopConnected = true; this.wsAuthenticated = false; if (this.relayMode) { this.initRelayMode(); } 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); } 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; } return headers; } async init() { await this.loadTabs(); await this.loadMacros(); this.setupWebSocket(); this.setupEventListeners(); 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() { try { const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`; const response = await fetch(this.getApiUrl(path), { headers: this.getApiHeaders() }); 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(); this.macros = data.macros || {}; this.renderMacros(); } catch (error) { console.error('Error loading macros:', error); this.showToast('Error loading macros', 'error'); } } async executeMacro(macroId) { try { const card = document.querySelector(`[data-macro-id="${macroId}"]`); if (card) card.classList.add('executing'); 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 === 503) { this.handleDesktopDisconnected(); } throw new Error('Execution failed'); } setTimeout(() => { if (card) card.classList.remove('executing'); }, 300); } catch (error) { console.error('Error executing macro:', error); this.showToast('Error executing macro', 'error'); } } handleAuthError() { this.showToast('Authentication failed', 'error'); if (this.relayMode) { // Clear stored password and redirect to login sessionStorage.removeItem(`macropad_${this.sessionId}`); window.location.href = `/${this.sessionId}`; } } handleDesktopDisconnected() { this.desktopConnected = false; this.updateConnectionStatus(false, 'Desktop offline'); this.showToast('Desktop app is not connected', 'error'); } // WebSocket setupWebSocket() { 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`; } try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { if (!this.relayMode) { this.updateConnectionStatus(true); } // In relay mode, wait for auth before showing connected }; this.ws.onclose = () => { this.wsAuthenticated = false; this.updateConnectionStatus(false); setTimeout(() => this.setupWebSocket(), 3000); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleWebSocketMessage(data); }; this.ws.onerror = () => { this.updateConnectionStatus(false); }; } catch (error) { console.error('WebSocket error:', error); } } handleWebSocketMessage(data) { switch (data.type) { // Relay-specific messages case 'auth_required': // Send authentication if (this.password) { 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 = document.querySelector(`[data-macro-id="${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'; } } } // Rendering renderTabs() { const container = document.getElementById('tabs-container'); if (!container) return; container.innerHTML = this.tabs.map(tab => ` `).join(''); } renderMacros() { const container = document.getElementById('macro-grid'); if (!container) return; const macroEntries = Object.entries(this.macros); if (macroEntries.length === 0) { container.innerHTML = `

No macros found

Create macros in the desktop app

`; return; } container.innerHTML = macroEntries.map(([id, macro]) => { let imageSrc = null; if (macro.image_path) { const basePath = this.getApiUrl(`/api/image/${macro.image_path}`); // Add password as query param for relay mode (img tags can't use headers) if (this.relayMode && this.password) { imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`; } else { imageSrc = basePath; } } const firstChar = macro.name.charAt(0).toUpperCase(); return `
${imageSrc ? `${macro.name}` : '' }
${firstChar}
${macro.name}
`; }).join(''); } // Event Listeners setupEventListeners() { // Tab clicks document.getElementById('tabs-container')?.addEventListener('click', (e) => { if (e.target.classList.contains('tab')) { this.currentTab = e.target.dataset.tab; this.renderTabs(); this.loadMacros(); } }); } // 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'; banner.innerHTML = ` Install MacroPad for quick access
`; 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); }); }); }