security+feat(web): kill DOM XSS, add CSP, in-browser macro editor

Security:
- Eliminate DOM-based XSS: renderMacros/renderTabs rebuilt with
  createElement/textContent/setAttribute + addEventListener. No macro name,
  id, image_path, or category ever reaches innerHTML.
- Remove all inline on*= handlers; add a strict Content-Security-Policy meta
  (script-src 'self', object-src 'none', base-uri 'none', frame-ancestors
  'none').
- Guard JSON.parse on WebSocket frames.

Robustness:
- Service worker: network-first for the app shell + JS/CSS so fixes actually
  ship; synthetic 503 for offline API calls; drop no-op /ws intercept; bump
  cache version.
- WebSocket reconnect: exponential backoff (1s→30s), tear down old socket
  before reconnect, stop on pagehide/beforeunload.
- Tab-switch race fixed with a request-id guard; executing state cleared in
  finally.

Accessibility:
- Macro cards and tabs are real buttons with aria-labels/roles; toast +
  connection status are aria-live; removed user-scalable=no.

Feature — web-based macro creation/editing:
- Floating "+" button + modal command-sequence builder (text/key/hotkey/
  wait/app) with reorder/remove; create via POST, edit via PUT (prefilled
  from GET /api/macro/{id}), delete with a named confirm. Works in both local
  and relay auth modes. Empty state now offers "Create your first macro".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:24:14 -07:00
parent eba5a2a38e
commit 435cc1aca9
3 changed files with 634 additions and 105 deletions
+46 -9
View File
@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'">
<meta name="theme-color" content="#007acc">
<meta name="description" content="Remote macro control for your desktop">
@@ -29,20 +30,20 @@
<header class="header">
<h1>MacroPad</h1>
<div class="header-actions">
<div class="connection-status">
<div class="connection-status" id="connection-status" role="status" aria-live="polite">
<div class="status-dot"></div>
<span>Disconnected</span>
</div>
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
<span class="wake-icon"></span>
</div>
<button class="header-btn icon-btn" id="fullscreen-btn" onclick="app.toggleFullscreen()" title="Toggle fullscreen"></button>
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
<button type="button" class="wake-lock-status" id="wake-lock-status" title="Screen wake lock" aria-label="Toggle screen wake lock">
<span class="wake-icon" aria-hidden="true"></span>
</button>
<button type="button" class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen" aria-label="Toggle fullscreen"></button>
<button type="button" class="header-btn secondary" id="refresh-btn">Refresh</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs" id="tabs-container">
<nav class="tabs" id="tabs-container" role="tablist" aria-label="Macro categories">
<!-- Tabs rendered dynamically -->
</nav>
@@ -53,8 +54,44 @@
</div>
</main>
<!-- Floating action button: create new macro -->
<button type="button" class="fab" id="new-macro-btn" aria-label="New macro"></button>
<!-- Macro create/edit modal -->
<div class="modal" id="macro-modal" role="dialog" aria-modal="true" aria-labelledby="macro-modal-title" hidden>
<div class="modal-content">
<div class="modal-header">
<h2 id="macro-modal-title">New Macro</h2>
<button type="button" class="modal-close" id="macro-modal-close" aria-label="Close dialog">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="macro-name-input">Name</label>
<input type="text" id="macro-name-input" autocomplete="off" placeholder="Macro name">
</div>
<div class="form-group">
<label for="macro-category-input">Category</label>
<input type="text" id="macro-category-input" autocomplete="off" list="macro-category-list" placeholder="Category (optional)">
<datalist id="macro-category-list"></datalist>
</div>
<div class="form-group">
<label>Commands</label>
<div class="command-list" id="command-list"></div>
<div class="add-command-btns">
<button type="button" class="add-command-btn" id="add-command-btn"> Add step</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" id="delete-macro-btn" hidden>Delete</button>
<button type="button" class="btn btn-secondary" id="cancel-macro-btn">Cancel</button>
<button type="button" class="btn btn-primary" id="save-macro-btn">Save</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite"></div>
<script src="/static/js/app.js"></script>
</body>
+537 -63
View File
@@ -1,4 +1,14 @@
// MacroPad PWA Application (Execute-only)
// 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() {
@@ -16,6 +26,17 @@ class MacroPadApp {
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;
// Macro editor state (null = create mode)
this.editingMacroId = null;
if (this.relayMode) {
this.initRelayMode();
} else {
@@ -86,10 +107,10 @@ class MacroPadApp {
}
async init() {
this.setupEventListeners();
await this.loadTabs();
await this.loadMacros();
this.setupWebSocket();
this.setupEventListeners();
this.setupWakeLock();
this.checkInstallPrompt();
}
@@ -117,6 +138,9 @@ class MacroPadApp {
}
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'
@@ -124,6 +148,7 @@ class MacroPadApp {
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();
@@ -136,19 +161,21 @@ class MacroPadApp {
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) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
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(),
@@ -156,18 +183,23 @@ class MacroPadApp {
});
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
return;
}
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');
} finally {
// Always clear the animation state, even on failure.
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
}
}
@@ -187,7 +219,37 @@ class MacroPadApp {
}
// 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;
@@ -205,6 +267,8 @@ class MacroPadApp {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
// Successful connection resets the backoff.
this.reconnectDelay = 1000;
if (!this.relayMode) {
this.updateConnectionStatus(true);
}
@@ -214,19 +278,27 @@ class MacroPadApp {
this.ws.onclose = () => {
this.wsAuthenticated = false;
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
this.scheduleReconnect();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
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();
}
}
@@ -235,7 +307,7 @@ class MacroPadApp {
// Relay-specific messages
case 'auth_required':
// Send authentication
if (this.password) {
if (this.password && this.ws) {
this.ws.send(JSON.stringify({
type: 'auth',
password: this.password
@@ -272,13 +344,14 @@ class MacroPadApp {
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
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
@@ -303,73 +376,461 @@ class MacroPadApp {
}
}
// Rendering
// 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;
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
// Credentials go as query params since <img> can't set headers.
if (this.relayMode && this.password) {
return `${basePath}?password=${encodeURIComponent(this.password)}`;
}
if (!this.relayMode && this.localToken) {
return `${basePath}?token=${encodeURIComponent(this.localToken)}`;
}
return basePath;
}
// Rendering (safe DOM construction only - no user value reaches innerHTML)
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('');
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;
const macroEntries = Object.entries(this.macros);
container.textContent = '';
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>
`;
container.appendChild(this.buildEmptyState());
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
let imageSrc = null;
if (macro.image_path) {
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
// Add credential as query param since img tags can't use headers
if (this.relayMode && this.password) {
imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`;
} else if (!this.relayMode && this.localToken) {
imageSrc = `${basePath}?token=${encodeURIComponent(this.localToken)}`;
} else {
imageSrc = basePath;
}
}
const firstChar = macro.name.charAt(0).toUpperCase();
const fragment = document.createDocumentFragment();
macroEntries.forEach(([id, macro]) => {
fragment.appendChild(this.buildMacroCard(id, macro));
});
container.appendChild(fragment);
}
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('');
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 = '';
img.src = imageSrc;
placeholder.style.display = 'none';
// Fall back to the placeholder if the image fails to load.
img.addEventListener('error', () => {
img.style.display = 'none';
placeholder.style.display = '';
});
card.appendChild(img);
}
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) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
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
@@ -401,13 +862,26 @@ class MacroPadApp {
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>
`;
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;
}
@@ -434,7 +908,7 @@ class MacroPadApp {
// Fullscreen
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
document.documentElement.requestFullscreen().catch((err) => {
console.log('Fullscreen error:', err);
});
} else {
+51 -33
View File
@@ -1,5 +1,5 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v3';
const CACHE_NAME = 'macropad-v4';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
@@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [
'/manifest.json'
];
// Install event - cache assets
// Install event - pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
@@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => {
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Network-first for navigations + app code so deployed fixes reach clients.
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (err) {
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Offline fallback for navigations: serve the cached shell.
if (request.mode === 'navigate') {
const shell = await cache.match('/');
if (shell) {
return shell;
}
}
return new Response('Offline', {
status: 503,
statusText: 'Offline',
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Always fetch API requests from network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
event.respondWith(fetch(event.request));
self.addEventListener('fetch', (event) => {
const request = event.request;
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
if (request.method !== 'GET') {
return;
}
// For other requests, try cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
.catch(() => {
// Return offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
})
);
const url = new URL(request.url);
// API requests always go to the network; never serve stale data.
// Return a synthetic offline JSON response instead of an unhandled undefined.
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request).catch(() => new Response(
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
))
);
return;
}
// Navigations and static app assets: network-first with cache fallback.
event.respondWith(networkFirst(request));
});