6218a46b57
Same fix as the relay: the local PWA re-fetched every macro image and revoked the blob on each re-render. Cache image URL -> Promise<objectURL> and reuse across renders (images are immutable per uuid path); add Cache-Control: immutable on the desktop /api/image response. Verified in a browser: 7 re-renders produced only the initial 3 image fetches, none repeated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1095 lines
38 KiB
JavaScript
1095 lines
38 KiB
JavaScript
// MacroPad PWA Application
|
|
|
|
// Supported command types for the macro builder. Kept in sync with the
|
|
// server-side Command model: {type, value?, keys?, ms?, command?}.
|
|
const COMMAND_TYPES = [
|
|
{ value: 'text', label: 'Text', placeholder: 'Text to type', numeric: false },
|
|
{ value: 'key', label: 'Key', placeholder: 'Key name, e.g. enter', numeric: false },
|
|
{ value: 'hotkey', label: 'Hotkey', placeholder: 'Keys, e.g. ctrl, c', numeric: false },
|
|
{ value: 'wait', label: 'Wait', placeholder: 'Milliseconds', numeric: true },
|
|
{ value: 'app', label: 'App', placeholder: 'App or command to launch', numeric: false }
|
|
];
|
|
|
|
class MacroPadApp {
|
|
constructor() {
|
|
this.macros = {};
|
|
this.tabs = [];
|
|
this.currentTab = 'All';
|
|
this.ws = null;
|
|
this.wakeLock = null;
|
|
|
|
// Relay mode detection
|
|
this.relayMode = this.detectRelayMode();
|
|
this.sessionId = null;
|
|
this.password = null;
|
|
this.localToken = null;
|
|
this.desktopConnected = true;
|
|
this.wsAuthenticated = false;
|
|
|
|
// WebSocket reconnect state (exponential backoff)
|
|
this.shouldReconnect = true;
|
|
this.reconnectDelay = 1000;
|
|
this.reconnectTimer = null;
|
|
|
|
// Guards against out-of-order macro fetches
|
|
this._macroReqId = 0;
|
|
|
|
// Cache of image URL -> Promise<objectURL>. Macro images are
|
|
// content-addressed by uuid filename, so they are immutable; caching
|
|
// avoids re-fetching every image on each re-render.
|
|
this._imageCache = new Map();
|
|
|
|
// Set once a local-mode auth failure has been surfaced, to avoid
|
|
// reconnect loops and repeated toasts.
|
|
this.localAuthFailed = false;
|
|
|
|
// Macro editor state (null = create mode)
|
|
this.editingMacroId = null;
|
|
|
|
if (this.relayMode) {
|
|
this.initRelayMode();
|
|
} else {
|
|
this.initLocalMode();
|
|
}
|
|
|
|
this.init();
|
|
}
|
|
|
|
detectRelayMode() {
|
|
// Check if URL matches relay pattern: /sessionId/app or /sessionId
|
|
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})(\/app)?$/);
|
|
return pathMatch !== null;
|
|
}
|
|
|
|
initRelayMode() {
|
|
// Extract session ID from URL
|
|
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})/);
|
|
if (pathMatch) {
|
|
this.sessionId = pathMatch[1];
|
|
}
|
|
|
|
// Get password from URL query param or sessionStorage
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
|
|
|
|
if (this.password) {
|
|
// Store password for future use
|
|
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
|
|
// Clear from URL for security
|
|
if (urlParams.has('auth')) {
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
}
|
|
|
|
console.log('Relay mode enabled, session:', this.sessionId);
|
|
}
|
|
|
|
initLocalMode() {
|
|
// Get token from URL query param or localStorage (local/LAN mode)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.localToken = urlParams.get('token') || localStorage.getItem('macropad_local_token');
|
|
|
|
if (this.localToken) {
|
|
// Persist token and strip it from the URL for cleanliness
|
|
localStorage.setItem('macropad_local_token', this.localToken);
|
|
if (urlParams.has('token')) {
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
}
|
|
}
|
|
|
|
getApiUrl(path) {
|
|
if (this.relayMode && this.sessionId) {
|
|
return `/${this.sessionId}${path}`;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
getApiHeaders() {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (this.relayMode && this.password) {
|
|
headers['X-MacroPad-Password'] = this.password;
|
|
} else if (!this.relayMode && this.localToken) {
|
|
headers['X-MacroPad-Token'] = this.localToken;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async init() {
|
|
this.setupEventListeners();
|
|
await this.loadTabs();
|
|
await this.loadMacros();
|
|
this.setupWebSocket();
|
|
this.setupWakeLock();
|
|
this.checkInstallPrompt();
|
|
}
|
|
|
|
// API Methods
|
|
async loadTabs() {
|
|
try {
|
|
const response = await fetch(this.getApiUrl('/api/tabs'), {
|
|
headers: this.getApiHeaders()
|
|
});
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
this.handleAuthError();
|
|
return;
|
|
}
|
|
throw new Error('Failed to load tabs');
|
|
}
|
|
const data = await response.json();
|
|
this.tabs = data.tabs || [];
|
|
this.renderTabs();
|
|
} catch (error) {
|
|
console.error('Error loading tabs:', error);
|
|
this.showToast('Error loading tabs', 'error');
|
|
}
|
|
}
|
|
|
|
async loadMacros() {
|
|
// Only the latest request is allowed to render, so overlapping
|
|
// tab-switch fetches can't apply out of order.
|
|
const reqId = ++this._macroReqId;
|
|
try {
|
|
const path = this.currentTab === 'All'
|
|
? '/api/macros'
|
|
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
|
|
const response = await fetch(this.getApiUrl(path), {
|
|
headers: this.getApiHeaders()
|
|
});
|
|
if (reqId !== this._macroReqId) return; // superseded
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
this.handleAuthError();
|
|
return;
|
|
}
|
|
if (response.status === 503) {
|
|
this.handleDesktopDisconnected();
|
|
return;
|
|
}
|
|
throw new Error('Failed to load macros');
|
|
}
|
|
const data = await response.json();
|
|
if (reqId !== this._macroReqId) return; // superseded
|
|
this.macros = data.macros || {};
|
|
this.renderMacros();
|
|
} catch (error) {
|
|
if (reqId !== this._macroReqId) return;
|
|
console.error('Error loading macros:', error);
|
|
this.showToast('Error loading macros', 'error');
|
|
}
|
|
}
|
|
|
|
async executeMacro(macroId) {
|
|
const card = this.findCard(macroId);
|
|
if (card) card.classList.add('executing');
|
|
|
|
try {
|
|
const response = await fetch(this.getApiUrl('/api/execute'), {
|
|
method: 'POST',
|
|
headers: this.getApiHeaders(),
|
|
body: JSON.stringify({ macro_id: macroId })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
this.handleAuthError();
|
|
return;
|
|
}
|
|
if (response.status === 503) {
|
|
this.handleDesktopDisconnected();
|
|
}
|
|
throw new Error('Execution failed');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error executing macro:', error);
|
|
this.showToast('Error executing macro', 'error');
|
|
} finally {
|
|
// Always clear the animation state, even on failure.
|
|
setTimeout(() => {
|
|
if (card) card.classList.remove('executing');
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
handleAuthError() {
|
|
if (this.relayMode) {
|
|
this.showToast('Authentication failed', 'error');
|
|
// Clear stored password and redirect to login
|
|
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
|
window.location.href = `/${this.sessionId}`;
|
|
return;
|
|
}
|
|
|
|
// Local (token) mode: a wrong/missing token makes every request 401 and
|
|
// the WebSocket close with 1008, which would otherwise reconnect forever
|
|
// and spam toasts. Surface a single, clear message and stop retrying.
|
|
if (this.localAuthFailed) return;
|
|
this.localAuthFailed = true;
|
|
|
|
this.shouldReconnect = false;
|
|
clearTimeout(this.reconnectTimer);
|
|
this.teardownSocket();
|
|
this.updateConnectionStatus(false, 'Access denied');
|
|
this.renderAuthRequired();
|
|
}
|
|
|
|
renderAuthRequired() {
|
|
const container = document.getElementById('macro-grid');
|
|
if (!container) return;
|
|
container.textContent = '';
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'empty-state';
|
|
|
|
const p1 = document.createElement('p');
|
|
p1.textContent = 'Access token required';
|
|
|
|
const p2 = document.createElement('p');
|
|
p2.className = 'hint';
|
|
p2.textContent = "Open the link from the desktop app's URL or QR code.";
|
|
|
|
wrap.append(p1, p2);
|
|
container.appendChild(wrap);
|
|
}
|
|
|
|
handleDesktopDisconnected() {
|
|
this.desktopConnected = false;
|
|
this.updateConnectionStatus(false, 'Desktop offline');
|
|
this.showToast('Desktop app is not connected', 'error');
|
|
}
|
|
|
|
// WebSocket
|
|
teardownSocket() {
|
|
if (this.ws) {
|
|
// Detach handlers so the old socket can't fire reconnect logic.
|
|
this.ws.onopen = null;
|
|
this.ws.onclose = null;
|
|
this.ws.onmessage = null;
|
|
this.ws.onerror = null;
|
|
try {
|
|
this.ws.close();
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
this.ws = null;
|
|
}
|
|
}
|
|
|
|
scheduleReconnect() {
|
|
if (!this.shouldReconnect) return;
|
|
clearTimeout(this.reconnectTimer);
|
|
const delay = this.reconnectDelay;
|
|
this.reconnectTimer = setTimeout(() => this.setupWebSocket(), delay);
|
|
// Exponential backoff, capped at 30s.
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
|
}
|
|
|
|
setupWebSocket() {
|
|
if (!this.shouldReconnect) return;
|
|
|
|
// Clean up any prior socket before opening a new one.
|
|
this.teardownSocket();
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
let wsUrl;
|
|
|
|
if (this.relayMode && this.sessionId) {
|
|
wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
|
|
} else {
|
|
wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
// Append token as query param (WebSocket can't set headers)
|
|
if (this.localToken) {
|
|
wsUrl += `?token=${encodeURIComponent(this.localToken)}`;
|
|
}
|
|
}
|
|
|
|
try {
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
// Successful connection resets the backoff.
|
|
this.reconnectDelay = 1000;
|
|
if (!this.relayMode) {
|
|
this.updateConnectionStatus(true);
|
|
}
|
|
// In relay mode, wait for auth before showing connected
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
this.wsAuthenticated = false;
|
|
this.updateConnectionStatus(false);
|
|
this.scheduleReconnect();
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(event.data);
|
|
} catch (e) {
|
|
// Ignore malformed frames.
|
|
return;
|
|
}
|
|
this.handleWebSocketMessage(data);
|
|
};
|
|
|
|
this.ws.onerror = () => {
|
|
// Let onclose own reconnection; just reflect status here.
|
|
this.updateConnectionStatus(false);
|
|
};
|
|
} catch (error) {
|
|
console.error('WebSocket error:', error);
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
handleWebSocketMessage(data) {
|
|
switch (data.type) {
|
|
// Relay-specific messages
|
|
case 'auth_required':
|
|
// Send authentication
|
|
if (this.password && this.ws) {
|
|
this.ws.send(JSON.stringify({
|
|
type: 'auth',
|
|
password: this.password
|
|
}));
|
|
}
|
|
break;
|
|
|
|
case 'auth_response':
|
|
if (data.success) {
|
|
this.wsAuthenticated = true;
|
|
this.updateConnectionStatus(this.desktopConnected);
|
|
} else {
|
|
this.handleAuthError();
|
|
}
|
|
break;
|
|
|
|
case 'desktop_status':
|
|
this.desktopConnected = data.status === 'connected';
|
|
this.updateConnectionStatus(this.desktopConnected);
|
|
if (!this.desktopConnected) {
|
|
this.showToast('Desktop disconnected', 'error');
|
|
} else {
|
|
this.showToast('Desktop connected', 'success');
|
|
this.loadTabs();
|
|
this.loadMacros();
|
|
}
|
|
break;
|
|
|
|
// Standard MacroPad messages
|
|
case 'macro_created':
|
|
case 'macro_updated':
|
|
case 'macro_deleted':
|
|
this.loadTabs();
|
|
this.loadMacros();
|
|
break;
|
|
|
|
case 'executed': {
|
|
const card = this.findCard(data.macro_id);
|
|
if (card) {
|
|
card.classList.add('executing');
|
|
setTimeout(() => card.classList.remove('executing'), 300);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'pong':
|
|
// Keep-alive response
|
|
break;
|
|
}
|
|
}
|
|
|
|
updateConnectionStatus(connected, customText = null) {
|
|
const dot = document.querySelector('.status-dot');
|
|
const text = document.querySelector('.connection-status span');
|
|
if (dot) {
|
|
dot.classList.toggle('connected', connected);
|
|
}
|
|
if (text) {
|
|
if (customText) {
|
|
text.textContent = customText;
|
|
} else if (this.relayMode) {
|
|
text.textContent = connected ? 'Connected (Relay)' : 'Disconnected';
|
|
} else {
|
|
text.textContent = connected ? 'Connected' : 'Disconnected';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deterministic hue from a macro name so the CSS can color placeholders.
|
|
hashHue(name) {
|
|
const s = String(name || '');
|
|
let hash = 0;
|
|
for (let i = 0; i < s.length; i++) {
|
|
hash = (hash * 31 + s.charCodeAt(i)) | 0;
|
|
}
|
|
return Math.abs(hash) % 360;
|
|
}
|
|
|
|
findCard(macroId) {
|
|
const cards = document.querySelectorAll('.macro-card');
|
|
for (const card of cards) {
|
|
if (card.dataset.macroId === macroId) return card;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
macroImageUrl(macro) {
|
|
if (!macro.image_path) return null;
|
|
// No credential ever goes on the URL; auth is via a header on a
|
|
// fetch() below, and the image is shown as a blob object URL.
|
|
return this.getApiUrl(`/api/image/${macro.image_path}`);
|
|
}
|
|
|
|
// Auth-only headers (no Content-Type) for GET fetches like images.
|
|
getAuthHeaders() {
|
|
const headers = {};
|
|
if (this.relayMode && this.password) {
|
|
headers['X-MacroPad-Password'] = this.password;
|
|
} else if (!this.relayMode && this.localToken) {
|
|
headers['X-MacroPad-Token'] = this.localToken;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
// Fetch a macro image with the auth header and display it as a blob object
|
|
// URL, so no credential is ever placed on an <img> src. The fetched blob is
|
|
// cached (images are immutable per URL) and reused across re-renders.
|
|
async loadMacroImage(url, img, placeholder) {
|
|
try {
|
|
const objectUrl = await this._getImageUrl(url);
|
|
img.src = objectUrl;
|
|
} catch (e) {
|
|
// Fall back to the placeholder if the image can't be loaded.
|
|
img.style.display = 'none';
|
|
placeholder.style.display = '';
|
|
}
|
|
}
|
|
|
|
// Returns a cached Promise<objectURL> for an image URL, fetching once and
|
|
// retaining it for the page's lifetime (immutable content, nothing to
|
|
// invalidate); de-duplicates concurrent requests too.
|
|
_getImageUrl(url) {
|
|
let entry = this._imageCache.get(url);
|
|
if (entry) return entry;
|
|
entry = (async () => {
|
|
const res = await fetch(url, { headers: this.getAuthHeaders() });
|
|
if (!res.ok) throw new Error('image request failed');
|
|
const blob = await res.blob();
|
|
return URL.createObjectURL(blob);
|
|
})();
|
|
entry.catch(() => this._imageCache.delete(url));
|
|
this._imageCache.set(url, entry);
|
|
return entry;
|
|
}
|
|
|
|
// Rendering (safe DOM construction only - no user value reaches innerHTML)
|
|
renderTabs() {
|
|
const container = document.getElementById('tabs-container');
|
|
if (!container) return;
|
|
|
|
container.textContent = '';
|
|
this.tabs.forEach((tab) => {
|
|
const active = tab === this.currentTab;
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = active ? 'tab active' : 'tab';
|
|
btn.setAttribute('role', 'tab');
|
|
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
btn.dataset.tab = tab;
|
|
btn.textContent = tab;
|
|
container.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
renderMacros() {
|
|
const container = document.getElementById('macro-grid');
|
|
if (!container) return;
|
|
|
|
container.textContent = '';
|
|
|
|
const macroEntries = Object.entries(this.macros);
|
|
if (macroEntries.length === 0) {
|
|
container.appendChild(this.buildEmptyState());
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
macroEntries.forEach(([id, macro]) => {
|
|
fragment.appendChild(this.buildMacroCard(id, macro));
|
|
});
|
|
container.appendChild(fragment);
|
|
}
|
|
|
|
buildMacroCard(id, macro) {
|
|
const name = macro.name || '';
|
|
|
|
const card = document.createElement('button');
|
|
card.type = 'button';
|
|
card.className = 'macro-card';
|
|
card.dataset.macroId = id;
|
|
card.setAttribute('aria-label', `Run ${name}`);
|
|
card.style.setProperty('--macro-color', `hsl(${this.hashHue(name)} 55% 45%)`);
|
|
|
|
const placeholder = document.createElement('span');
|
|
placeholder.className = 'macro-image-placeholder';
|
|
placeholder.textContent = (name.charAt(0) || '?').toUpperCase();
|
|
|
|
const imageSrc = this.macroImageUrl(macro);
|
|
if (imageSrc) {
|
|
const img = document.createElement('img');
|
|
img.className = 'macro-image';
|
|
img.alt = '';
|
|
placeholder.style.display = 'none';
|
|
// Fall back to the placeholder if the (blob) image fails to load.
|
|
img.addEventListener('error', () => {
|
|
img.style.display = 'none';
|
|
placeholder.style.display = '';
|
|
});
|
|
card.appendChild(img);
|
|
// Header-authenticated fetch -> blob object URL (no credential on src).
|
|
this.loadMacroImage(imageSrc, img, placeholder);
|
|
}
|
|
card.appendChild(placeholder);
|
|
|
|
const nameEl = document.createElement('span');
|
|
nameEl.className = 'macro-name';
|
|
nameEl.textContent = name;
|
|
card.appendChild(nameEl);
|
|
|
|
const editBtn = document.createElement('button');
|
|
editBtn.type = 'button';
|
|
editBtn.className = 'macro-edit-btn';
|
|
editBtn.setAttribute('aria-label', `Edit ${name}`);
|
|
editBtn.textContent = '✎';
|
|
editBtn.addEventListener('click', (e) => {
|
|
// Don't trigger execute when editing.
|
|
e.stopPropagation();
|
|
this.openMacroModal(id);
|
|
});
|
|
card.appendChild(editBtn);
|
|
|
|
card.addEventListener('click', () => this.executeMacro(id));
|
|
return card;
|
|
}
|
|
|
|
buildEmptyState() {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'empty-state';
|
|
|
|
const p1 = document.createElement('p');
|
|
p1.textContent = 'No macros yet';
|
|
|
|
const p2 = document.createElement('p');
|
|
p2.className = 'hint';
|
|
p2.textContent = 'Build one right here in your browser.';
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'btn btn-primary';
|
|
btn.textContent = 'Create your first macro';
|
|
btn.addEventListener('click', () => this.openMacroModal());
|
|
|
|
wrap.append(p1, p2, btn);
|
|
return wrap;
|
|
}
|
|
|
|
// ---- Macro editor (create / edit / delete) ----
|
|
|
|
populateCategoryOptions() {
|
|
const list = document.getElementById('macro-category-list');
|
|
if (!list) return;
|
|
list.textContent = '';
|
|
this.tabs
|
|
.filter((t) => t && t !== 'All')
|
|
.forEach((t) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t;
|
|
list.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
commandToInputValue(cmd) {
|
|
switch (cmd.type) {
|
|
case 'hotkey':
|
|
return Array.isArray(cmd.keys) ? cmd.keys.join(', ') : (cmd.keys || '');
|
|
case 'wait':
|
|
return cmd.ms != null ? String(cmd.ms) : '';
|
|
case 'app':
|
|
return cmd.command || '';
|
|
case 'text':
|
|
case 'key':
|
|
default:
|
|
return cmd.value || '';
|
|
}
|
|
}
|
|
|
|
applyCommandRowType(select, input) {
|
|
const meta = COMMAND_TYPES.find((t) => t.value === select.value) || COMMAND_TYPES[0];
|
|
input.type = meta.numeric ? 'number' : 'text';
|
|
input.placeholder = meta.placeholder;
|
|
if (meta.numeric) {
|
|
input.min = '0';
|
|
} else {
|
|
input.removeAttribute('min');
|
|
}
|
|
}
|
|
|
|
addCommandRow(type = 'text', cmd = null) {
|
|
const list = document.getElementById('command-list');
|
|
if (!list) return;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'command-item';
|
|
|
|
const select = document.createElement('select');
|
|
select.className = 'command-type-select';
|
|
COMMAND_TYPES.forEach((t) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t.value;
|
|
opt.textContent = t.label;
|
|
select.appendChild(opt);
|
|
});
|
|
select.value = COMMAND_TYPES.some((t) => t.value === type) ? type : 'text';
|
|
|
|
const input = document.createElement('input');
|
|
input.className = 'command-value';
|
|
input.autocomplete = 'off';
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'command-actions';
|
|
|
|
const upBtn = document.createElement('button');
|
|
upBtn.type = 'button';
|
|
upBtn.textContent = '↑';
|
|
upBtn.setAttribute('aria-label', 'Move step up');
|
|
|
|
const downBtn = document.createElement('button');
|
|
downBtn.type = 'button';
|
|
downBtn.textContent = '↓';
|
|
downBtn.setAttribute('aria-label', 'Move step down');
|
|
|
|
const delBtn = document.createElement('button');
|
|
delBtn.type = 'button';
|
|
delBtn.className = 'delete';
|
|
delBtn.textContent = '✕';
|
|
delBtn.setAttribute('aria-label', 'Remove step');
|
|
|
|
this.applyCommandRowType(select, input);
|
|
if (cmd) {
|
|
input.value = this.commandToInputValue(cmd);
|
|
}
|
|
|
|
select.addEventListener('change', () => this.applyCommandRowType(select, input));
|
|
upBtn.addEventListener('click', () => {
|
|
const prev = row.previousElementSibling;
|
|
if (prev) list.insertBefore(row, prev);
|
|
});
|
|
downBtn.addEventListener('click', () => {
|
|
const next = row.nextElementSibling;
|
|
if (next) list.insertBefore(next, row);
|
|
});
|
|
delBtn.addEventListener('click', () => row.remove());
|
|
|
|
actions.append(upBtn, downBtn, delBtn);
|
|
row.append(select, input, actions);
|
|
list.appendChild(row);
|
|
}
|
|
|
|
renderCommandRows(commands) {
|
|
const list = document.getElementById('command-list');
|
|
if (!list) return;
|
|
list.textContent = '';
|
|
commands.forEach((cmd) => this.addCommandRow(cmd.type || 'text', cmd));
|
|
}
|
|
|
|
collectCommands() {
|
|
const list = document.getElementById('command-list');
|
|
if (!list) return [];
|
|
const commands = [];
|
|
list.querySelectorAll('.command-item').forEach((row) => {
|
|
const type = row.querySelector('.command-type-select').value;
|
|
const raw = row.querySelector('.command-value').value.trim();
|
|
switch (type) {
|
|
case 'hotkey': {
|
|
// Store keys as a list (split on commas/whitespace).
|
|
const keys = raw.split(/[\s,]+/).filter(Boolean);
|
|
commands.push({ type: 'hotkey', keys });
|
|
break;
|
|
}
|
|
case 'wait': {
|
|
const ms = parseInt(raw, 10);
|
|
commands.push({ type: 'wait', ms: Number.isFinite(ms) ? ms : 0 });
|
|
break;
|
|
}
|
|
case 'app':
|
|
commands.push({ type: 'app', command: raw });
|
|
break;
|
|
case 'key':
|
|
commands.push({ type: 'key', value: raw });
|
|
break;
|
|
case 'text':
|
|
default:
|
|
commands.push({ type: 'text', value: raw });
|
|
break;
|
|
}
|
|
});
|
|
return commands;
|
|
}
|
|
|
|
async openMacroModal(macroId = null) {
|
|
this.editingMacroId = macroId || null;
|
|
|
|
const modal = document.getElementById('macro-modal');
|
|
const title = document.getElementById('macro-modal-title');
|
|
const nameInput = document.getElementById('macro-name-input');
|
|
const categoryInput = document.getElementById('macro-category-input');
|
|
const deleteBtn = document.getElementById('delete-macro-btn');
|
|
if (!modal) return;
|
|
|
|
this.populateCategoryOptions();
|
|
nameInput.value = '';
|
|
categoryInput.value = '';
|
|
this.renderCommandRows([]);
|
|
|
|
if (macroId) {
|
|
title.textContent = 'Edit Macro';
|
|
if (deleteBtn) deleteBtn.hidden = false;
|
|
try {
|
|
const res = await fetch(
|
|
this.getApiUrl(`/api/macro/${encodeURIComponent(macroId)}`),
|
|
{ headers: this.getApiHeaders() }
|
|
);
|
|
if (!res.ok) {
|
|
if (res.status === 401) { this.handleAuthError(); return; }
|
|
throw new Error('Failed to load macro');
|
|
}
|
|
const data = await res.json();
|
|
const macro = data.macro || {};
|
|
nameInput.value = macro.name || '';
|
|
categoryInput.value = macro.category || '';
|
|
this.renderCommandRows(Array.isArray(macro.commands) ? macro.commands : []);
|
|
} catch (e) {
|
|
console.error('Error loading macro:', e);
|
|
this.showToast('Could not load macro', 'error');
|
|
}
|
|
} else {
|
|
title.textContent = 'New Macro';
|
|
if (deleteBtn) deleteBtn.hidden = true;
|
|
categoryInput.value = (this.currentTab && this.currentTab !== 'All') ? this.currentTab : '';
|
|
this.addCommandRow('text');
|
|
}
|
|
|
|
modal.hidden = false;
|
|
nameInput.focus();
|
|
}
|
|
|
|
closeMacroModal() {
|
|
const modal = document.getElementById('macro-modal');
|
|
if (modal) modal.hidden = true;
|
|
this.editingMacroId = null;
|
|
}
|
|
|
|
async saveMacro() {
|
|
const nameInput = document.getElementById('macro-name-input');
|
|
const categoryInput = document.getElementById('macro-category-input');
|
|
const saveBtn = document.getElementById('save-macro-btn');
|
|
|
|
const name = nameInput.value.trim();
|
|
const category = categoryInput.value.trim();
|
|
const commands = this.collectCommands();
|
|
|
|
if (!name) {
|
|
this.showToast('Please enter a name', 'error');
|
|
nameInput.focus();
|
|
return;
|
|
}
|
|
if (commands.length === 0) {
|
|
this.showToast('Add at least one step', 'error');
|
|
return;
|
|
}
|
|
|
|
const editing = this.editingMacroId;
|
|
const url = editing
|
|
? this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`)
|
|
: this.getApiUrl('/api/macros');
|
|
const method = editing ? 'PUT' : 'POST';
|
|
const body = JSON.stringify({ name, commands, category });
|
|
|
|
if (saveBtn) saveBtn.disabled = true;
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: this.getApiHeaders(),
|
|
body
|
|
});
|
|
if (!res.ok) {
|
|
if (res.status === 401) { this.handleAuthError(); return; }
|
|
throw new Error('Save failed');
|
|
}
|
|
this.showToast(editing ? 'Macro updated' : 'Macro created', 'success');
|
|
this.closeMacroModal();
|
|
// WS broadcast will also refresh, but reload immediately for snappiness.
|
|
await this.loadTabs();
|
|
await this.loadMacros();
|
|
} catch (e) {
|
|
console.error('Error saving macro:', e);
|
|
this.showToast('Error saving macro', 'error');
|
|
} finally {
|
|
if (saveBtn) saveBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async deleteMacro() {
|
|
const editing = this.editingMacroId;
|
|
if (!editing) return;
|
|
|
|
const nameInput = document.getElementById('macro-name-input');
|
|
const name = (nameInput && nameInput.value.trim()) || 'this macro';
|
|
if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
|
|
|
try {
|
|
const res = await fetch(
|
|
this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`),
|
|
{ method: 'DELETE', headers: this.getApiHeaders() }
|
|
);
|
|
if (!res.ok) {
|
|
if (res.status === 401) { this.handleAuthError(); return; }
|
|
throw new Error('Delete failed');
|
|
}
|
|
this.showToast('Macro deleted', 'success');
|
|
this.closeMacroModal();
|
|
await this.loadTabs();
|
|
await this.loadMacros();
|
|
} catch (e) {
|
|
console.error('Error deleting macro:', e);
|
|
this.showToast('Error deleting macro', 'error');
|
|
}
|
|
}
|
|
|
|
// Event Listeners
|
|
setupEventListeners() {
|
|
// Tab clicks
|
|
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
|
|
const tab = e.target.closest('.tab');
|
|
if (tab) {
|
|
this.currentTab = tab.dataset.tab;
|
|
this.renderTabs();
|
|
this.loadMacros();
|
|
}
|
|
});
|
|
|
|
// Header controls
|
|
document.getElementById('refresh-btn')?.addEventListener('click', () => this.refresh());
|
|
document.getElementById('fullscreen-btn')?.addEventListener('click', () => this.toggleFullscreen());
|
|
|
|
// Macro editor controls
|
|
document.getElementById('new-macro-btn')?.addEventListener('click', () => this.openMacroModal());
|
|
document.getElementById('add-command-btn')?.addEventListener('click', () => this.addCommandRow('text'));
|
|
document.getElementById('save-macro-btn')?.addEventListener('click', () => this.saveMacro());
|
|
document.getElementById('cancel-macro-btn')?.addEventListener('click', () => this.closeMacroModal());
|
|
document.getElementById('delete-macro-btn')?.addEventListener('click', () => this.deleteMacro());
|
|
document.getElementById('macro-modal-close')?.addEventListener('click', () => this.closeMacroModal());
|
|
|
|
const modal = document.getElementById('macro-modal');
|
|
modal?.addEventListener('click', (e) => {
|
|
// Click on the backdrop (not the content) closes.
|
|
if (e.target === modal) this.closeMacroModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal && !modal.hidden) this.closeMacroModal();
|
|
});
|
|
|
|
// Stop reconnecting once the page is going away.
|
|
const stop = () => {
|
|
this.shouldReconnect = false;
|
|
clearTimeout(this.reconnectTimer);
|
|
this.teardownSocket();
|
|
};
|
|
window.addEventListener('pagehide', stop);
|
|
window.addEventListener('beforeunload', stop);
|
|
}
|
|
|
|
// Toast notifications
|
|
showToast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
if (!container) return;
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
|
|
// PWA Install Prompt
|
|
checkInstallPrompt() {
|
|
let deferredPrompt;
|
|
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
this.showInstallBanner(deferredPrompt);
|
|
});
|
|
}
|
|
|
|
showInstallBanner(deferredPrompt) {
|
|
const banner = document.createElement('div');
|
|
banner.className = 'install-banner';
|
|
|
|
const label = document.createElement('span');
|
|
label.textContent = 'Install MacroPad for quick access';
|
|
|
|
const actions = document.createElement('div');
|
|
|
|
const installBtn = document.createElement('button');
|
|
installBtn.type = 'button';
|
|
installBtn.textContent = 'Install';
|
|
installBtn.addEventListener('click', () => this.installPWA());
|
|
|
|
const dismissBtn = document.createElement('button');
|
|
dismissBtn.type = 'button';
|
|
dismissBtn.className = 'dismiss';
|
|
dismissBtn.textContent = '✕';
|
|
dismissBtn.setAttribute('aria-label', 'Dismiss install banner');
|
|
dismissBtn.addEventListener('click', () => banner.remove());
|
|
|
|
actions.append(installBtn, dismissBtn);
|
|
banner.append(label, actions);
|
|
document.body.insertBefore(banner, document.body.firstChild);
|
|
this.deferredPrompt = deferredPrompt;
|
|
}
|
|
|
|
async installPWA() {
|
|
if (!this.deferredPrompt) return;
|
|
|
|
this.deferredPrompt.prompt();
|
|
const { outcome } = await this.deferredPrompt.userChoice;
|
|
|
|
if (outcome === 'accepted') {
|
|
document.querySelector('.install-banner')?.remove();
|
|
}
|
|
|
|
this.deferredPrompt = null;
|
|
}
|
|
|
|
// Refresh
|
|
refresh() {
|
|
this.loadTabs();
|
|
this.loadMacros();
|
|
}
|
|
|
|
// Fullscreen
|
|
toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch((err) => {
|
|
console.log('Fullscreen error:', err);
|
|
});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
// Wake Lock - prevents screen from sleeping
|
|
async setupWakeLock() {
|
|
const status = document.getElementById('wake-lock-status');
|
|
|
|
if (!('wakeLock' in navigator)) {
|
|
console.log('Wake Lock API not supported');
|
|
// Don't remove the icon - show it as unsupported instead
|
|
if (status) {
|
|
status.classList.add('unsupported');
|
|
status.title = 'Wake lock not available (requires HTTPS)';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Make the icon clickable to toggle wake lock
|
|
if (status) {
|
|
status.style.cursor = 'pointer';
|
|
status.addEventListener('click', () => this.toggleWakeLock());
|
|
}
|
|
|
|
// Request wake lock automatically
|
|
await this.requestWakeLock();
|
|
|
|
// Re-acquire wake lock when page becomes visible again
|
|
document.addEventListener('visibilitychange', async () => {
|
|
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
|
|
await this.requestWakeLock();
|
|
}
|
|
});
|
|
}
|
|
|
|
async toggleWakeLock() {
|
|
if (this.wakeLock) {
|
|
// Release wake lock
|
|
await this.wakeLock.release();
|
|
this.wakeLock = null;
|
|
this.wakeLockEnabled = false;
|
|
this.updateWakeLockStatus(false);
|
|
this.showToast('Screen can now sleep', 'info');
|
|
} else {
|
|
// Request wake lock
|
|
this.wakeLockEnabled = true;
|
|
await this.requestWakeLock();
|
|
if (this.wakeLock) {
|
|
this.showToast('Screen will stay awake', 'success');
|
|
}
|
|
}
|
|
}
|
|
|
|
async requestWakeLock() {
|
|
try {
|
|
this.wakeLock = await navigator.wakeLock.request('screen');
|
|
this.wakeLockEnabled = true;
|
|
this.updateWakeLockStatus(true);
|
|
|
|
this.wakeLock.addEventListener('release', () => {
|
|
this.updateWakeLockStatus(false);
|
|
});
|
|
} catch (err) {
|
|
console.log('Wake Lock error:', err);
|
|
this.updateWakeLockStatus(false);
|
|
// Show error only if user explicitly tried to enable
|
|
const status = document.getElementById('wake-lock-status');
|
|
if (status && !status.classList.contains('unsupported')) {
|
|
status.title = 'Wake lock failed: ' + err.message;
|
|
}
|
|
}
|
|
}
|
|
|
|
updateWakeLockStatus(active) {
|
|
const status = document.getElementById('wake-lock-status');
|
|
if (status) {
|
|
status.classList.toggle('active', active);
|
|
if (!status.classList.contains('unsupported')) {
|
|
status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize app
|
|
let app;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
app = new MacroPadApp();
|
|
});
|
|
|
|
// Register service worker
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/service-worker.js')
|
|
.then((registration) => {
|
|
console.log('SW registered:', registration.scope);
|
|
})
|
|
.catch((error) => {
|
|
console.log('SW registration failed:', error);
|
|
});
|
|
});
|
|
}
|