1. Images not showing through relay: - Use getApiUrl() for image paths in relay mode - Add password as query param for img tags (can't use headers) 2. URL not updating in desktop app: - Set _connected=True before on_session_id callback fires - Ensures update_ip_label() shows relay URL immediately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
518 lines
17 KiB
JavaScript
518 lines
17 KiB
JavaScript
// 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 => `
|
|
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
|
|
data-tab="${tab}">${tab}</button>
|
|
`).join('');
|
|
}
|
|
|
|
renderMacros() {
|
|
const container = document.getElementById('macro-grid');
|
|
if (!container) return;
|
|
|
|
const macroEntries = Object.entries(this.macros);
|
|
|
|
if (macroEntries.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<p>No macros found</p>
|
|
<p class="hint">Create macros in the desktop app</p>
|
|
</div>
|
|
`;
|
|
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 `
|
|
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
|
|
${imageSrc
|
|
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
|
|
: ''
|
|
}
|
|
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
|
|
${firstChar}
|
|
</div>
|
|
<span class="macro-name">${macro.name}</span>
|
|
</div>
|
|
`;
|
|
}).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 = `
|
|
<span>Install MacroPad for quick access</span>
|
|
<div>
|
|
<button onclick="app.installPWA()">Install</button>
|
|
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
|
|
</div>
|
|
`;
|
|
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);
|
|
});
|
|
});
|
|
}
|