Files
MP-Server/web/js/app.js
T
shadowdao 6218a46b57 perf(web): cache macro images in local PWA, bump to 1.1.2
Same fix as the relay: the local PWA re-fetched every macro image and revoked
the blob on each re-render. Cache image URL -> Promise<objectURL> and reuse
across renders (images are immutable per uuid path); add Cache-Control:
immutable on the desktop /api/image response. Verified in a browser: 7
re-renders produced only the initial 3 image fetches, none repeated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:29:16 -07:00

1095 lines
38 KiB
JavaScript

// 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<objectURL>. 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 <img> 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<objectURL> 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);
});
});
}