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:
+46
-9
@@ -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
@@ -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
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user