2bbbc5e283
The relay web client re-fetched every macro image through the relay->desktop proxy on each render (tab switch, WS update) and revoked the object URL on load, so nothing was reused and fetches were serial — slow to display. - Client: cache image_path -> Promise<objectURL> and reuse across renders (macro images are content-addressed by uuid, so immutable); fetch in parallel and de-duplicate concurrent requests. - Proxy: send Cache-Control: private, max-age=31536000, immutable on image responses so the browser also disk-caches them across reloads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
446 lines
16 KiB
JavaScript
446 lines
16 KiB
JavaScript
// MacroPad App for Relay Mode (externalized so the CSP can drop
|
|
// 'unsafe-inline' for scripts). All macro/tab/id/name values are rendered
|
|
// with safe DOM APIs (createElement/textContent) and never via innerHTML.
|
|
class MacroPadApp {
|
|
constructor() {
|
|
this.macros = {};
|
|
this.tabs = [];
|
|
this.currentTab = 'All';
|
|
this.ws = null;
|
|
// Cache of image_path -> Promise<objectURL>. Macro images are
|
|
// content-addressed by uuid filename, so they are immutable: once
|
|
// fetched, an image never changes for a given path. Caching here avoids
|
|
// re-fetching every image through the relay on each re-render.
|
|
this._imageCache = new Map();
|
|
this.desktopConnected = false;
|
|
this.wsAuthenticated = false;
|
|
|
|
// Reconnect backoff. Reset only after a successful AUTH (not on
|
|
// socket open): a server that accepts the socket and then closes it
|
|
// would otherwise reset the backoff on every open and defeat it.
|
|
this.baseReconnectDelay = 3000;
|
|
this.maxReconnectDelay = 30000;
|
|
this.reconnectDelay = this.baseReconnectDelay;
|
|
|
|
// Get session ID from URL
|
|
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
|
|
this.sessionId = pathMatch ? pathMatch[1] : null;
|
|
|
|
// Get password from storage (never from the URL).
|
|
this.password = sessionStorage.getItem(`macropad_${this.sessionId}`)
|
|
|| localStorage.getItem(`macropad_${this.sessionId}`);
|
|
|
|
if (this.password) {
|
|
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
|
|
}
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
this.wakeLock = null;
|
|
this.wakeLockEnabled = false;
|
|
|
|
this.setupPWA();
|
|
this.setupWebSocket();
|
|
this.setupEventListeners();
|
|
this.setupWakeLock();
|
|
}
|
|
|
|
getApiHeaders() {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'X-MacroPad-Password': this.password || ''
|
|
};
|
|
}
|
|
|
|
async loadTabs() {
|
|
try {
|
|
const response = await fetch(`/${this.sessionId}/api/tabs`, {
|
|
headers: this.getApiHeaders()
|
|
});
|
|
if (response.status === 401) return this.handleAuthError();
|
|
if (response.status === 503) return this.handleDesktopOffline();
|
|
const data = await response.json();
|
|
this.tabs = data.tabs || [];
|
|
this.renderTabs();
|
|
} catch (error) {
|
|
console.error('Error loading tabs:', error);
|
|
}
|
|
}
|
|
|
|
async loadMacros() {
|
|
try {
|
|
const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`;
|
|
const response = await fetch(`/${this.sessionId}${path}`, {
|
|
headers: this.getApiHeaders()
|
|
});
|
|
if (response.status === 401) return this.handleAuthError();
|
|
if (response.status === 503) return this.handleDesktopOffline();
|
|
const data = await response.json();
|
|
this.macros = data.macros || {};
|
|
this.renderMacros();
|
|
} catch (error) {
|
|
console.error('Error loading macros:', error);
|
|
}
|
|
}
|
|
|
|
async executeMacro(macroId) {
|
|
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
|
if (card) card.classList.add('executing');
|
|
|
|
try {
|
|
const response = await fetch(`/${this.sessionId}/api/execute`, {
|
|
method: 'POST',
|
|
headers: this.getApiHeaders(),
|
|
body: JSON.stringify({ macro_id: macroId })
|
|
});
|
|
if (!response.ok) throw new Error('Failed');
|
|
} catch (error) {
|
|
this.showToast('Execution failed', 'error');
|
|
}
|
|
|
|
setTimeout(() => card?.classList.remove('executing'), 300);
|
|
}
|
|
|
|
handleAuthError() {
|
|
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
|
localStorage.removeItem(`macropad_${this.sessionId}`);
|
|
window.location.href = `/${this.sessionId}`;
|
|
}
|
|
|
|
handleDesktopOffline() {
|
|
this.desktopConnected = false;
|
|
this.updateConnectionStatus(false);
|
|
document.getElementById('offline-banner').classList.add('visible');
|
|
}
|
|
|
|
setupWebSocket() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
|
|
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
this.handleMessage(data);
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
this.wsAuthenticated = false;
|
|
this.updateConnectionStatus(false);
|
|
// Reconnect using the current backoff, then grow it (capped) for
|
|
// the next attempt. The backoff is only reset on a successful
|
|
// auth response, so accept-then-close cycles keep backing off.
|
|
const delay = this.reconnectDelay;
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
setTimeout(() => this.setupWebSocket(), delay);
|
|
};
|
|
|
|
this.ws.onerror = () => this.updateConnectionStatus(false);
|
|
}
|
|
|
|
handleMessage(data) {
|
|
switch (data.type) {
|
|
case 'auth_required':
|
|
if (this.password) {
|
|
this.ws.send(JSON.stringify({ type: 'auth', password: this.password }));
|
|
}
|
|
break;
|
|
case 'auth_response':
|
|
if (data.success) {
|
|
this.wsAuthenticated = true;
|
|
// Reset the reconnect backoff only after a confirmed,
|
|
// successful auth (not merely on socket open).
|
|
this.reconnectDelay = this.baseReconnectDelay;
|
|
this.updateConnectionStatus(this.desktopConnected);
|
|
} else {
|
|
this.handleAuthError();
|
|
}
|
|
break;
|
|
case 'desktop_status':
|
|
this.desktopConnected = data.status === 'connected';
|
|
this.updateConnectionStatus(this.desktopConnected);
|
|
document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected);
|
|
if (this.desktopConnected) {
|
|
this.loadTabs();
|
|
this.loadMacros();
|
|
}
|
|
break;
|
|
case 'macro_created':
|
|
case 'macro_updated':
|
|
case 'macro_deleted':
|
|
this.loadTabs();
|
|
this.loadMacros();
|
|
break;
|
|
case 'executed': {
|
|
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
|
|
if (card) {
|
|
card.classList.add('executing');
|
|
setTimeout(() => card.classList.remove('executing'), 300);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
updateConnectionStatus(connected) {
|
|
const dot = document.querySelector('.status-dot');
|
|
const text = document.querySelector('.connection-status span');
|
|
if (dot) dot.classList.toggle('connected', connected);
|
|
if (text) text.textContent = connected ? 'Connected' : 'Disconnected';
|
|
}
|
|
|
|
renderTabs() {
|
|
const container = document.getElementById('tabs-container');
|
|
container.replaceChildren();
|
|
for (const tab of this.tabs) {
|
|
const name = typeof tab === 'string' ? tab : String(tab);
|
|
const btn = document.createElement('button');
|
|
btn.className = name === this.currentTab ? 'tab active' : 'tab';
|
|
btn.setAttribute('data-tab', name);
|
|
btn.textContent = name;
|
|
container.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
renderMacros() {
|
|
const container = document.getElementById('macro-grid');
|
|
const entries = Object.entries(this.macros);
|
|
|
|
container.replaceChildren();
|
|
|
|
if (entries.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'empty-state';
|
|
const p = document.createElement('p');
|
|
p.textContent = 'No macros found';
|
|
empty.appendChild(p);
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
for (const [id, macro] of entries) {
|
|
const name = macro && typeof macro.name === 'string' ? macro.name : '';
|
|
const hasImage = !!(macro && macro.image_path);
|
|
const firstChar = name.charAt(0).toUpperCase();
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'macro-card';
|
|
card.setAttribute('data-macro-id', id);
|
|
card.addEventListener('click', () => this.executeMacro(id));
|
|
|
|
if (hasImage) {
|
|
const img = document.createElement('img');
|
|
img.className = 'macro-image';
|
|
img.style.display = 'none';
|
|
card.appendChild(img);
|
|
}
|
|
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'macro-image-placeholder';
|
|
placeholder.textContent = firstChar;
|
|
card.appendChild(placeholder);
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'macro-name';
|
|
nameSpan.textContent = name;
|
|
card.appendChild(nameSpan);
|
|
|
|
container.appendChild(card);
|
|
}
|
|
|
|
// Load macro images via authenticated fetch (header, not URL).
|
|
this.loadMacroImages();
|
|
}
|
|
|
|
// Fetch each macro image with the password header and display it as a blob
|
|
// object URL so the credential never appears in a URL. Images are fetched
|
|
// in parallel and cached (see _imageCache) so re-renders reuse them instead
|
|
// of re-fetching through the relay each time.
|
|
loadMacroImages() {
|
|
for (const [id, macro] of Object.entries(this.macros)) {
|
|
if (!macro.image_path) continue;
|
|
const card = document.querySelector(`[data-macro-id="${id}"]`);
|
|
if (!card) continue;
|
|
const img = card.querySelector('.macro-image');
|
|
if (!img) continue;
|
|
this._applyMacroImage(img, macro.image_path);
|
|
}
|
|
}
|
|
|
|
async _applyMacroImage(img, imagePath) {
|
|
try {
|
|
const objectUrl = await this._getImageUrl(imagePath);
|
|
img.src = objectUrl;
|
|
img.style.display = '';
|
|
const placeholder = img.nextElementSibling;
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
} catch (error) {
|
|
// Leave the placeholder visible on failure.
|
|
}
|
|
}
|
|
|
|
// Returns a cached Promise<objectURL> for an image path, fetching once.
|
|
// The object URL is retained for the page's lifetime (images are immutable
|
|
// per path, so there is nothing to invalidate); this de-duplicates
|
|
// concurrent requests and eliminates re-fetching on re-render.
|
|
_getImageUrl(imagePath) {
|
|
let entry = this._imageCache.get(imagePath);
|
|
if (entry) return entry;
|
|
entry = (async () => {
|
|
const response = await fetch(
|
|
`/${this.sessionId}/api/image/${imagePath}`,
|
|
{ headers: this.getApiHeaders() }
|
|
);
|
|
if (!response.ok) throw new Error(`image ${response.status}`);
|
|
const blob = await response.blob();
|
|
return URL.createObjectURL(blob);
|
|
})();
|
|
// Drop failed fetches from the cache so a later render can retry.
|
|
entry.catch(() => this._imageCache.delete(imagePath));
|
|
this._imageCache.set(imagePath, entry);
|
|
return entry;
|
|
}
|
|
|
|
setupEventListeners() {
|
|
document.getElementById('tabs-container').addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('tab')) {
|
|
this.currentTab = e.target.dataset.tab;
|
|
this.renderTabs();
|
|
this.loadMacros();
|
|
}
|
|
});
|
|
|
|
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
|
if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
|
|
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
if (refreshBtn) refreshBtn.addEventListener('click', () => this.refresh());
|
|
}
|
|
|
|
showToast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
|
|
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
|
|
async setupWakeLock() {
|
|
const status = document.getElementById('wake-lock-status');
|
|
|
|
if (!('wakeLock' in navigator)) {
|
|
console.log('Wake Lock API not supported');
|
|
if (status) {
|
|
status.classList.add('unsupported');
|
|
status.title = 'Wake lock not available (requires HTTPS)';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (status) {
|
|
status.style.cursor = 'pointer';
|
|
status.addEventListener('click', () => this.toggleWakeLock());
|
|
}
|
|
|
|
await this.requestWakeLock();
|
|
|
|
document.addEventListener('visibilitychange', async () => {
|
|
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
|
|
await this.requestWakeLock();
|
|
}
|
|
});
|
|
}
|
|
|
|
async toggleWakeLock() {
|
|
if (this.wakeLock) {
|
|
await this.wakeLock.release();
|
|
this.wakeLock = null;
|
|
this.wakeLockEnabled = false;
|
|
this.updateWakeLockStatus(false);
|
|
this.showToast('Screen can now sleep', 'info');
|
|
} else {
|
|
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);
|
|
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)';
|
|
}
|
|
}
|
|
}
|
|
|
|
// PWA manifest setup
|
|
setupPWA() {
|
|
// Create dynamic manifest for this session
|
|
const manifest = {
|
|
name: 'MacroPad',
|
|
short_name: 'MacroPad',
|
|
description: 'Remote macro control',
|
|
start_url: `/${this.sessionId}/app`,
|
|
display: 'standalone',
|
|
background_color: '#2e2e2e',
|
|
theme_color: '#007acc',
|
|
icons: [
|
|
{ src: '/static/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
|
{ src: '/static/icons/icon-512.png', sizes: '512x512', type: 'image/png' }
|
|
]
|
|
};
|
|
|
|
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
|
|
const manifestUrl = URL.createObjectURL(blob);
|
|
document.getElementById('manifest-link').setAttribute('href', manifestUrl);
|
|
}
|
|
}
|
|
|
|
let app;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
app = new MacroPadApp();
|
|
});
|