Security hardening (P0 RCE + audit follow-through), web macro editor, and redesign #6
+46
-9
@@ -2,7 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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="theme-color" content="#007acc">
|
||||||
<meta name="description" content="Remote macro control for your desktop">
|
<meta name="description" content="Remote macro control for your desktop">
|
||||||
|
|
||||||
@@ -29,20 +30,20 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>MacroPad</h1>
|
<h1>MacroPad</h1>
|
||||||
<div class="header-actions">
|
<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>
|
<div class="status-dot"></div>
|
||||||
<span>Disconnected</span>
|
<span>Disconnected</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="wake-lock-status" id="wake-lock-status" title="Screen wake lock">
|
<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">☀</span>
|
<span class="wake-icon" aria-hidden="true">☀</span>
|
||||||
</div>
|
</button>
|
||||||
<button class="header-btn icon-btn" id="fullscreen-btn" onclick="app.toggleFullscreen()" title="Toggle fullscreen">⛶</button>
|
<button type="button" class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen" aria-label="Toggle fullscreen">⛶</button>
|
||||||
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
|
<button type="button" class="header-btn secondary" id="refresh-btn">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<nav class="tabs" id="tabs-container">
|
<nav class="tabs" id="tabs-container" role="tablist" aria-label="Macro categories">
|
||||||
<!-- Tabs rendered dynamically -->
|
<!-- Tabs rendered dynamically -->
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -53,8 +54,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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 -->
|
<!-- 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>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</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 {
|
class MacroPadApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -16,6 +26,17 @@ class MacroPadApp {
|
|||||||
this.desktopConnected = true;
|
this.desktopConnected = true;
|
||||||
this.wsAuthenticated = false;
|
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) {
|
if (this.relayMode) {
|
||||||
this.initRelayMode();
|
this.initRelayMode();
|
||||||
} else {
|
} else {
|
||||||
@@ -86,10 +107,10 @@ class MacroPadApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
this.setupEventListeners();
|
||||||
await this.loadTabs();
|
await this.loadTabs();
|
||||||
await this.loadMacros();
|
await this.loadMacros();
|
||||||
this.setupWebSocket();
|
this.setupWebSocket();
|
||||||
this.setupEventListeners();
|
|
||||||
this.setupWakeLock();
|
this.setupWakeLock();
|
||||||
this.checkInstallPrompt();
|
this.checkInstallPrompt();
|
||||||
}
|
}
|
||||||
@@ -117,6 +138,9 @@ class MacroPadApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadMacros() {
|
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 {
|
try {
|
||||||
const path = this.currentTab === 'All'
|
const path = this.currentTab === 'All'
|
||||||
? '/api/macros'
|
? '/api/macros'
|
||||||
@@ -124,6 +148,7 @@ class MacroPadApp {
|
|||||||
const response = await fetch(this.getApiUrl(path), {
|
const response = await fetch(this.getApiUrl(path), {
|
||||||
headers: this.getApiHeaders()
|
headers: this.getApiHeaders()
|
||||||
});
|
});
|
||||||
|
if (reqId !== this._macroReqId) return; // superseded
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.handleAuthError();
|
this.handleAuthError();
|
||||||
@@ -136,19 +161,21 @@ class MacroPadApp {
|
|||||||
throw new Error('Failed to load macros');
|
throw new Error('Failed to load macros');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (reqId !== this._macroReqId) return; // superseded
|
||||||
this.macros = data.macros || {};
|
this.macros = data.macros || {};
|
||||||
this.renderMacros();
|
this.renderMacros();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (reqId !== this._macroReqId) return;
|
||||||
console.error('Error loading macros:', error);
|
console.error('Error loading macros:', error);
|
||||||
this.showToast('Error loading macros', 'error');
|
this.showToast('Error loading macros', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeMacro(macroId) {
|
async executeMacro(macroId) {
|
||||||
try {
|
const card = this.findCard(macroId);
|
||||||
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
if (card) card.classList.add('executing');
|
||||||
if (card) card.classList.add('executing');
|
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch(this.getApiUrl('/api/execute'), {
|
const response = await fetch(this.getApiUrl('/api/execute'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.getApiHeaders(),
|
headers: this.getApiHeaders(),
|
||||||
@@ -156,18 +183,23 @@ class MacroPadApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
this.handleAuthError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.status === 503) {
|
if (response.status === 503) {
|
||||||
this.handleDesktopDisconnected();
|
this.handleDesktopDisconnected();
|
||||||
}
|
}
|
||||||
throw new Error('Execution failed');
|
throw new Error('Execution failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (card) card.classList.remove('executing');
|
|
||||||
}, 300);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error executing macro:', error);
|
console.error('Error executing macro:', error);
|
||||||
this.showToast('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
|
// 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() {
|
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:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
let wsUrl;
|
let wsUrl;
|
||||||
|
|
||||||
@@ -205,6 +267,8 @@ class MacroPadApp {
|
|||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
|
// Successful connection resets the backoff.
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
if (!this.relayMode) {
|
if (!this.relayMode) {
|
||||||
this.updateConnectionStatus(true);
|
this.updateConnectionStatus(true);
|
||||||
}
|
}
|
||||||
@@ -214,19 +278,27 @@ class MacroPadApp {
|
|||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
this.wsAuthenticated = false;
|
this.wsAuthenticated = false;
|
||||||
this.updateConnectionStatus(false);
|
this.updateConnectionStatus(false);
|
||||||
setTimeout(() => this.setupWebSocket(), 3000);
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
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.handleWebSocketMessage(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
this.ws.onerror = () => {
|
||||||
|
// Let onclose own reconnection; just reflect status here.
|
||||||
this.updateConnectionStatus(false);
|
this.updateConnectionStatus(false);
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +307,7 @@ class MacroPadApp {
|
|||||||
// Relay-specific messages
|
// Relay-specific messages
|
||||||
case 'auth_required':
|
case 'auth_required':
|
||||||
// Send authentication
|
// Send authentication
|
||||||
if (this.password) {
|
if (this.password && this.ws) {
|
||||||
this.ws.send(JSON.stringify({
|
this.ws.send(JSON.stringify({
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
password: this.password
|
password: this.password
|
||||||
@@ -272,13 +344,14 @@ class MacroPadApp {
|
|||||||
this.loadMacros();
|
this.loadMacros();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'executed':
|
case 'executed': {
|
||||||
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
|
const card = this.findCard(data.macro_id);
|
||||||
if (card) {
|
if (card) {
|
||||||
card.classList.add('executing');
|
card.classList.add('executing');
|
||||||
setTimeout(() => card.classList.remove('executing'), 300);
|
setTimeout(() => card.classList.remove('executing'), 300);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
// Keep-alive response
|
// 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() {
|
renderTabs() {
|
||||||
const container = document.getElementById('tabs-container');
|
const container = document.getElementById('tabs-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = this.tabs.map(tab => `
|
container.textContent = '';
|
||||||
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
|
this.tabs.forEach((tab) => {
|
||||||
data-tab="${tab}">${tab}</button>
|
const active = tab === this.currentTab;
|
||||||
`).join('');
|
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() {
|
renderMacros() {
|
||||||
const container = document.getElementById('macro-grid');
|
const container = document.getElementById('macro-grid');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const macroEntries = Object.entries(this.macros);
|
container.textContent = '';
|
||||||
|
|
||||||
|
const macroEntries = Object.entries(this.macros);
|
||||||
if (macroEntries.length === 0) {
|
if (macroEntries.length === 0) {
|
||||||
container.innerHTML = `
|
container.appendChild(this.buildEmptyState());
|
||||||
<div class="empty-state">
|
|
||||||
<p>No macros found</p>
|
|
||||||
<p class="hint">Create macros in the desktop app</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = macroEntries.map(([id, macro]) => {
|
const fragment = document.createDocumentFragment();
|
||||||
let imageSrc = null;
|
macroEntries.forEach(([id, macro]) => {
|
||||||
if (macro.image_path) {
|
fragment.appendChild(this.buildMacroCard(id, macro));
|
||||||
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
|
});
|
||||||
// Add credential as query param since img tags can't use headers
|
container.appendChild(fragment);
|
||||||
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();
|
|
||||||
|
|
||||||
return `
|
buildMacroCard(id, macro) {
|
||||||
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
|
const name = macro.name || '';
|
||||||
${imageSrc
|
|
||||||
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
|
const card = document.createElement('button');
|
||||||
: ''
|
card.type = 'button';
|
||||||
}
|
card.className = 'macro-card';
|
||||||
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
|
card.dataset.macroId = id;
|
||||||
${firstChar}
|
card.setAttribute('aria-label', `Run ${name}`);
|
||||||
</div>
|
card.style.setProperty('--macro-color', `hsl(${this.hashHue(name)} 55% 45%)`);
|
||||||
<span class="macro-name">${macro.name}</span>
|
|
||||||
</div>
|
const placeholder = document.createElement('span');
|
||||||
`;
|
placeholder.className = 'macro-image-placeholder';
|
||||||
}).join('');
|
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
|
// Event Listeners
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Tab clicks
|
// Tab clicks
|
||||||
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
|
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
|
||||||
if (e.target.classList.contains('tab')) {
|
const tab = e.target.closest('.tab');
|
||||||
this.currentTab = e.target.dataset.tab;
|
if (tab) {
|
||||||
|
this.currentTab = tab.dataset.tab;
|
||||||
this.renderTabs();
|
this.renderTabs();
|
||||||
this.loadMacros();
|
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
|
// Toast notifications
|
||||||
@@ -401,13 +862,26 @@ class MacroPadApp {
|
|||||||
showInstallBanner(deferredPrompt) {
|
showInstallBanner(deferredPrompt) {
|
||||||
const banner = document.createElement('div');
|
const banner = document.createElement('div');
|
||||||
banner.className = 'install-banner';
|
banner.className = 'install-banner';
|
||||||
banner.innerHTML = `
|
|
||||||
<span>Install MacroPad for quick access</span>
|
const label = document.createElement('span');
|
||||||
<div>
|
label.textContent = 'Install MacroPad for quick access';
|
||||||
<button onclick="app.installPWA()">Install</button>
|
|
||||||
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
|
const actions = document.createElement('div');
|
||||||
</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);
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
this.deferredPrompt = deferredPrompt;
|
this.deferredPrompt = deferredPrompt;
|
||||||
}
|
}
|
||||||
@@ -434,7 +908,7 @@ class MacroPadApp {
|
|||||||
// Fullscreen
|
// Fullscreen
|
||||||
toggleFullscreen() {
|
toggleFullscreen() {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen().catch(err => {
|
document.documentElement.requestFullscreen().catch((err) => {
|
||||||
console.log('Fullscreen error:', err);
|
console.log('Fullscreen error:', err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+51
-33
@@ -1,5 +1,5 @@
|
|||||||
// MacroPad PWA Service Worker
|
// MacroPad PWA Service Worker
|
||||||
const CACHE_NAME = 'macropad-v3';
|
const CACHE_NAME = 'macropad-v4';
|
||||||
const ASSETS_TO_CACHE = [
|
const ASSETS_TO_CACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/static/css/styles.css',
|
'/static/css/styles.css',
|
||||||
@@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [
|
|||||||
'/manifest.json'
|
'/manifest.json'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - cache assets
|
// Install event - pre-cache the app shell
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME)
|
||||||
@@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch event - serve from cache, fallback to network
|
// Network-first for navigations + app code so deployed fixes reach clients.
|
||||||
self.addEventListener('fetch', (event) => {
|
async function networkFirst(request) {
|
||||||
const url = new URL(event.request.url);
|
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
|
self.addEventListener('fetch', (event) => {
|
||||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
|
const request = event.request;
|
||||||
event.respondWith(fetch(event.request));
|
|
||||||
|
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
|
||||||
|
if (request.method !== 'GET') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other requests, try cache first, then network
|
const url = new URL(request.url);
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request)
|
// API requests always go to the network; never serve stale data.
|
||||||
.then((response) => {
|
// Return a synthetic offline JSON response instead of an unhandled undefined.
|
||||||
if (response) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
return response;
|
event.respondWith(
|
||||||
}
|
fetch(request).catch(() => new Response(
|
||||||
return fetch(event.request).then((networkResponse) => {
|
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
|
||||||
// Cache successful responses
|
{ status: 503, headers: { 'Content-Type': 'application/json' } }
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
))
|
||||||
const responseClone = networkResponse.clone();
|
);
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
return;
|
||||||
cache.put(event.request, responseClone);
|
}
|
||||||
});
|
|
||||||
}
|
// Navigations and static app assets: network-first with cache fallback.
|
||||||
return networkResponse;
|
event.respondWith(networkFirst(request));
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Return offline fallback for navigation requests
|
|
||||||
if (event.request.mode === 'navigate') {
|
|
||||||
return caches.match('/');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user