Merge pull request 'Security hardening (P0 RCE + audit follow-through), web macro editor, and redesign' (#6) from security/p0-rce-hardening into main

This commit was merged in pull request #6.
This commit is contained in:
2026-07-18 04:08:24 +00:00
30 changed files with 5519 additions and 1183 deletions
+18 -5
View File
@@ -11,6 +11,7 @@ A cross-platform macro management application with desktop and web interfaces. C
- **Hotkeys**: Key combinations (Ctrl+C, Alt+Tab, etc.)
- **Wait**: Insert delays between commands (in milliseconds)
- **App**: Launch applications or scripts
- **Media & System Keys**: Control playback and audio (volume up/down/mute, play/pause, next/previous track, stop) alongside regular keys
- **Custom Images**: Assign images to macros for easy identification
- **Category Management**: Organize macros into custom tabs
@@ -119,15 +120,18 @@ A macro that types a username, waits, presses Tab, types a password, and presses
### Web Interface (PWA)
1. Start the application (web server starts automatically on port 40000)
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000`)
2. Note the URL shown in the toolbar (e.g., `http://192.168.1.100:40000/?token=...`)
3. Open this URL on any device on your network
4. **Install as PWA**:
- Mobile: Tap browser menu → "Add to Home Screen"
- Desktop: Click install icon in address bar
> [!IMPORTANT]
> The local web server now requires an access token. A per-install token is auto-generated and stored in `settings.json`, and is embedded in the URL shown in the toolbar and in the QR code — so copying the URL or scanning the QR just works. Anyone with that URL can control your macros, so treat it like a password. By default the server also binds to your LAN; set `web.allow_lan` to `false` in settings to restrict access to localhost only.
The web interface provides full macro management:
- View and execute macros
- Create and edit macros with command builder
- Create, edit, and delete macros directly from the web app — tap the **+** button to open the command-sequence builder (no desktop app required)
- Organize into categories
- Real-time sync across devices
@@ -149,12 +153,18 @@ Access your macros from outside your local network using a relay server:
Once connected, a relay URL will appear in the toolbar. Use this URL from any device with internet access. The relay provides:
- Secure HTTPS connection
- Full macro execution and management
- Macro browsing and execution
- PWA installation support
- Wake lock and fullscreen mode
> [!NOTE]
> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions.
> The relay web client is **execute-only**: remote devices can browse and trigger macros, but macro creation/editing is intentionally limited to the local network (the desktop app and the local web interface). This keeps macro authoring — which defines what runs on your computer — off the public internet.
> [!NOTE]
> The relay password is stored in your OS keyring when one is available, falling back to the settings file (written with restricted `0600` permissions) otherwise.
> [!NOTE]
> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions. The self-hosted relay now enforces login rate-limiting/lockout, keeps credentials out of URLs, and requires setting sane environment values — see the relay repo's `.env.example`.
## Command Types Reference
@@ -164,10 +174,13 @@ Once connected, a relay URL will appear in the toolbar. Use this URL from any de
| `key` | Presses a single key | `value`: Key name (enter, tab, escape, f1-f12, etc.) |
| `hotkey` | Presses key combination | `keys`: List of keys (e.g., ["ctrl", "c"]) |
| `wait` | Delays execution | `ms`: Milliseconds to wait |
| `app` | Launches application | `command`: Shell command to execute |
| `app` | Launches an application | `command`: Program and arguments to run (tokenized and executed directly — no shell) |
## Example Application Commands
> [!IMPORTANT]
> `app` commands are tokenized and the program is executed **directly**, without a shell. This closes a remote-code-execution risk, but it means shell features are **not** supported: command chaining (`;`, `&&`, `|`), output redirection (`>`), environment-variable expansion (`$VAR`, `%VAR%`), and shell built-ins will not work. Launching a program with arguments works exactly as shown below.
### Windows
```bash
# Steam game
+4 -1
View File
@@ -13,7 +13,10 @@ THEME = {
'button_bg': "#505050",
'button_fg': "#ffffff",
'tab_bg': "#404040",
'tab_selected': "#007acc"
'tab_selected': "#007acc",
'accent_hover': "#0096ff",
'danger_color': "#dc3545",
'danger_hover': "#c82333"
}
# File extensions for images
+29 -16
View File
@@ -1,19 +1,30 @@
# Macro editor dialog with command builder (PySide6)
import os
from typing import Optional, List, Dict
from typing import Optional, List
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea, QCompleter
QComboBox, QMessageBox, QFileDialog, QWidget,
QGroupBox, QScrollArea
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtGui import QPixmap
from config import THEME, IMAGE_EXTENSIONS
# Selectable single keys offered in the key picker. Includes common media/system
# keys pyautogui supports, a near-zero-cost win for a media/stream deck.
KEY_CHOICES = [
"Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
# Media / system keys
"VolumeUp", "VolumeDown", "VolumeMute",
"PlayPause", "NextTrack", "PrevTrack", "Stop",
]
class CommandItem(QWidget):
"""Widget representing a single command in the list."""
@@ -86,7 +97,7 @@ class CommandItem(QWidget):
del_btn = QPushButton("X")
del_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
background-color: {THEME['danger_color']};
color: white;
border: none;
padding: 6px 8px;
@@ -94,7 +105,7 @@ class CommandItem(QWidget):
min-height: 20px;
}}
QPushButton:hover {{
background-color: #c82333;
background-color: {THEME['danger_hover']};
}}
""")
del_btn.setFixedSize(30, 28)
@@ -212,9 +223,7 @@ class CommandBuilder(QWidget):
elif cmd_type == "key":
from PySide6.QtWidgets import QInputDialog
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
keys = list(KEY_CHOICES)
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
if not ok or not key:
return
@@ -295,9 +304,7 @@ class CommandBuilder(QWidget):
cmd["value"] = text
elif cmd_type == "key":
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
keys = list(KEY_CHOICES)
keys_lower = [k.lower() for k in keys]
current = keys_lower.index(cmd.get("value", "enter")) if cmd.get("value") in keys_lower else 0
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
@@ -511,7 +518,7 @@ class MacroEditorDialog(QDialog):
delete_btn = QPushButton("Delete")
delete_btn.setStyleSheet(f"""
QPushButton {{
background-color: #dc3545;
background-color: {THEME['danger_color']};
color: white;
border: none;
padding: 10px 20px;
@@ -519,7 +526,7 @@ class MacroEditorDialog(QDialog):
font-weight: bold;
}}
QPushButton:hover {{
background-color: #c82333;
background-color: {THEME['danger_hover']};
}}
""")
delete_btn.clicked.connect(self.delete_macro)
@@ -554,7 +561,7 @@ class MacroEditorDialog(QDialog):
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
background-color: {THEME['accent_hover']};
}}
""")
save_btn.clicked.connect(self.save_macro)
@@ -634,9 +641,15 @@ class MacroEditorDialog(QDialog):
def delete_macro(self):
"""Delete the current macro."""
macro_name = self.name_input.text().strip()
prompt = (
f"Are you sure you want to delete \"{macro_name}\"?"
if macro_name else
"Are you sure you want to delete this macro?"
)
reply = QMessageBox.question(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
prompt,
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
+74 -17
View File
@@ -21,11 +21,11 @@ def get_resource_path(relative_path):
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTabWidget, QGridLayout,
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
QScrollArea, QMenu, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
from PySide6.QtCore import Qt, Signal, QTimer, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction
from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE
from macro_manager import MacroManager
@@ -127,6 +127,8 @@ class MainWindow(QMainWindow):
relay_session_received = Signal(str)
relay_connected_signal = Signal()
relay_disconnected_signal = Signal()
# Report macro execution result back to the GUI thread (success flag).
macro_execution_finished = Signal(bool)
def __init__(self, app_dir: str):
super().__init__()
@@ -146,7 +148,12 @@ class MainWindow(QMainWindow):
self.macro_manager = MacroManager(data_file, images_dir, app_dir)
# Initialize web server
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
token = self.settings_manager.get_web_auth_token()
host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1"
self.web_server = WebServer(
self.macro_manager, app_dir, DEFAULT_PORT,
auth_token=token, host=host
)
self.server_thread = None
# Relay client (initialized later if enabled)
@@ -169,6 +176,14 @@ class MainWindow(QMainWindow):
self.relay_session_received.connect(self._handle_relay_session)
self.relay_connected_signal.connect(lambda: self._update_relay_status(True))
self.relay_disconnected_signal.connect(lambda: self._update_relay_status(False))
self.macro_execution_finished.connect(self._on_macro_execution_finished)
# Debounce timer for resize-driven grid rebuilds.
self._resize_timer = QTimer(self)
self._resize_timer.setSingleShot(True)
self._resize_timer.setInterval(150)
self._resize_timer.timeout.connect(self._apply_resize)
self._last_col_count = None
# Load initial data
self.refresh_tabs()
@@ -204,7 +219,7 @@ class MainWindow(QMainWindow):
font-weight: bold;
}}
QPushButton:hover {{
background-color: #0096ff;
background-color: {THEME['accent_hover']};
}}
""")
add_btn.clicked.connect(self.add_macro)
@@ -450,7 +465,10 @@ class MainWindow(QMainWindow):
self.ip_label.setText(full_url)
return
# Fall back to local IP
# Fall back to local IP. Append the auth token as a query param so the
# QR/copied URL lets a LAN device authenticate against the API.
token = self.settings_manager.get_web_auth_token()
token_qs = f"?token={token}" if token else ""
try:
import netifaces
for iface in netifaces.interfaces():
@@ -459,11 +477,11 @@ class MainWindow(QMainWindow):
for addr in addrs[netifaces.AF_INET]:
ip = addr.get('addr', '')
if ip and not ip.startswith('127.'):
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}")
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
return
except Exception:
pass
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}")
def copy_url_to_clipboard(self):
"""Copy the web interface URL to clipboard."""
@@ -547,7 +565,8 @@ class MainWindow(QMainWindow):
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
# Add macro buttons
cols = max(1, (self.width() - 40) // 130)
cols = self._compute_columns()
self._last_col_count = cols
for i, (macro_id, macro) in enumerate(filtered):
btn = MacroButton(macro_id, macro, self.app_dir)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
@@ -560,8 +579,25 @@ class MainWindow(QMainWindow):
self.refresh_macros()
def execute_macro(self, macro_id: str):
"""Execute a macro."""
success = self.macro_manager.execute_macro(macro_id)
"""Execute a macro off the GUI thread so the UI doesn't freeze.
pyautogui presses and wait steps are blocking; running them on the Qt
main thread would freeze the window. Run in a worker thread and report
the result back via a signal (thread-safe).
"""
self.status_bar.showMessage("Executing macro...", 2000)
def run():
try:
success = self.macro_manager.execute_macro(macro_id)
except Exception:
success = False
self.macro_execution_finished.emit(success)
threading.Thread(target=run, daemon=True).start()
def _on_macro_execution_finished(self, success: bool):
"""Report macro execution result on the GUI thread."""
if success:
self.status_bar.showMessage("Macro executed", 2000)
else:
@@ -585,9 +621,16 @@ class MainWindow(QMainWindow):
def delete_macro(self, macro_id: str):
"""Delete a macro with confirmation."""
macro = self.macro_manager.get_macro(macro_id)
macro_name = macro.get("name", "") if macro else ""
prompt = (
f"Are you sure you want to delete \"{macro_name}\"?"
if macro_name else
"Are you sure you want to delete this macro?"
)
reply = QMessageBox.question(
self, "Delete Macro",
"Are you sure you want to delete this macro?",
prompt,
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
@@ -703,7 +746,8 @@ class MainWindow(QMainWindow):
local_port=DEFAULT_PORT,
on_connected=self.on_relay_connected,
on_disconnected=self.on_relay_disconnected,
on_session_id=self.on_relay_session_id
on_session_id=self.on_relay_session_id,
local_auth_token=self.settings_manager.get_web_auth_token()
)
self.relay_client.start()
self.status_bar.showMessage("Connecting to relay server...")
@@ -729,7 +773,8 @@ class MainWindow(QMainWindow):
def _handle_relay_session(self, session_id: str):
"""Handle relay session on main thread."""
self.settings_manager.set_relay_session_id(session_id)
if not self.settings_manager.set_relay_session_id(session_id):
self.status_bar.showMessage("Warning: failed to save relay session", 3000)
self.update_ip_label()
def _update_relay_status(self, connected: bool):
@@ -816,15 +861,27 @@ class MainWindow(QMainWindow):
event.accept()
def _compute_columns(self) -> int:
"""Compute how many macro columns fit at the current width."""
return max(1, (self.width() - 40) // 130)
def _apply_resize(self):
"""Rebuild the grid only if the column count actually changed."""
if self._compute_columns() != self._last_col_count:
self.refresh_macros()
def resizeEvent(self, event):
"""Handle window resize."""
"""Handle window resize (debounced to coalesce rapid resize events)."""
super().resizeEvent(event)
self.refresh_macros()
# Coalesce a burst of resize events; only rebuild after things settle
# and only when the computed column count changed.
self._resize_timer.start()
def changeEvent(self, event):
"""Handle window state changes - minimize to tray."""
if event.type() == QEvent.Type.WindowStateChange:
if self.windowState() & Qt.WindowMinimized:
if (self.windowState() & Qt.WindowMinimized
and self.settings_manager.get_minimize_to_tray()):
# Hide instead of minimize (goes to tray)
event.ignore()
self.hide()
+9 -3
View File
@@ -123,7 +123,7 @@ class SettingsDialog(QDialog):
color: white;
}}
QPushButton:hover {{
background-color: #0096ff;
background-color: {THEME['accent_hover']};
}}
""")
save_btn.clicked.connect(self.save_settings)
@@ -316,9 +316,15 @@ class SettingsDialog(QDialog):
self.settings_manager.set('relay.enabled', new_enabled)
self.settings_manager.set('relay.server_url', new_url)
self.settings_manager.set('relay.password', new_password)
self.settings_manager.save()
# Store the password via the keyring-aware setter (also persists the
# rest of the settings). Avoid writing the plaintext password to JSON.
ok = self.settings_manager.set_relay_password(new_password)
if not ok:
QMessageBox.warning(
self, "Save Warning",
"Settings could not be fully saved to disk."
)
if relay_changed:
self.relay_settings_changed.emit()
+69 -4
View File
@@ -2,8 +2,19 @@
import os
import json
import secrets
from typing import Any, Optional
# Optional OS keyring for storing the relay password at rest. If the library or
# a working backend is unavailable we fall back to the JSON file (now chmod 0600).
try:
import keyring as _keyring
except Exception: # ImportError or any backend import-time failure
_keyring = None
KEYRING_SERVICE = "MacroPad"
KEYRING_USER = "relay"
DEFAULT_SETTINGS = {
"relay": {
@@ -12,6 +23,10 @@ DEFAULT_SETTINGS = {
"session_id": None,
"password": ""
},
"web": {
"auth_token": "",
"allow_lan": True
},
"minimize_to_tray": True
}
@@ -36,12 +51,23 @@ class SettingsManager:
# Merge with defaults to ensure all keys exist
self.settings = self._merge_defaults(DEFAULT_SETTINGS, self.settings)
# Ensure a web auth token exists (this is the security boundary for API access)
if not self.get('web.auth_token'):
token = secrets.token_urlsafe(32)
self.set('web.auth_token', token)
self.save()
def save(self):
"""Save settings to file."""
try:
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=2)
# Best-effort restrict permissions (protects stored relay password + token)
try:
os.chmod(self.settings_file, 0o600)
except OSError:
pass
return True
except IOError:
return False
@@ -93,13 +119,52 @@ class SettingsManager:
return self.get('relay.session_id')
def get_relay_password(self) -> str:
"""Get the relay password."""
"""Get the relay password, preferring the OS keyring when available."""
if _keyring is not None:
try:
value = _keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
if value:
return value
except Exception:
# Keyring backend missing/broken: fall back to JSON.
pass
return self.get('relay.password', '')
def set_relay_session_id(self, session_id: str):
"""Store the relay session ID."""
def set_relay_password(self, password: str) -> bool:
"""Store the relay password, preferring the OS keyring when available.
When keyring works, the plaintext password is kept out of the JSON file.
Never crashes if the keyring backend is missing; falls back to JSON.
Returns True on successful persistence.
"""
password = password or ""
stored_in_keyring = False
if _keyring is not None:
try:
_keyring.set_password(KEYRING_SERVICE, KEYRING_USER, password)
stored_in_keyring = True
except Exception:
stored_in_keyring = False
if stored_in_keyring:
# Don't persist plaintext in the JSON when the keyring holds it.
self.set('relay.password', "")
else:
self.set('relay.password', password)
return self.save()
def set_relay_session_id(self, session_id: str) -> bool:
"""Store the relay session ID. Returns True on successful save."""
self.set('relay.session_id', session_id)
self.save()
return self.save()
def get_web_auth_token(self) -> str:
"""Get the web API auth token."""
return self.get('web.auth_token', '')
def get_web_allow_lan(self) -> bool:
"""Check if LAN (0.0.0.0) binding is allowed."""
return self.get('web.allow_lan', True)
def get_minimize_to_tray(self) -> bool:
"""Check if minimize to tray is enabled."""
+178 -68
View File
@@ -3,6 +3,9 @@
import copy
import json
import os
import shlex
import tempfile
import threading
import uuid
import pyautogui
import subprocess
@@ -11,37 +14,61 @@ from PIL import Image
from typing import Optional
# --- Payload validation for execution ---------------------------------------
# Allowlist of key names pyautogui knows how to press. Anything not in this set
# is rejected before being handed to pyautogui so arbitrary strings can't be
# passed through. Populated from pyautogui when available.
VALID_KEYS = frozenset(getattr(pyautogui, "KEYBOARD_KEYS", []) or [])
# Upper bound (milliseconds) for a single wait step so a macro can't hang the
# executor forever.
MAX_WAIT_MS = 60000
# Only persist last_used to disk at most this often (seconds) to avoid rewriting
# the whole JSON file on every single execution.
LAST_USED_SAVE_INTERVAL = 5.0
class MacroManager:
"""Manages macro storage, migration, and execution with command sequences."""
"""Manages macro storage, migration, and execution with command sequences.
Thread-safe: all reads/writes of ``self.macros`` and all disk writes are
guarded by ``self._lock``. Public methods acquire the lock and delegate to
``*_locked`` internal helpers so we never re-acquire a non-reentrant lock.
"""
def __init__(self, data_file: str, images_dir: str, app_dir: str):
self.data_file = data_file
self.images_dir = images_dir
self.app_dir = app_dir
self.macros = {}
self._lock = threading.Lock()
# Timestamp of the last last_used-triggered save (for debouncing).
self._last_used_save_time = 0.0
self.load_macros()
def load_macros(self):
"""Load macros from JSON file and migrate if needed."""
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
with self._lock:
try:
if os.path.exists(self.data_file):
with open(self.data_file, "r") as file:
self.macros = json.load(file)
# Migrate old format macros
migrated = False
for macro_id, macro in list(self.macros.items()):
if macro.get("type") != "sequence":
self.macros[macro_id] = self._migrate_macro(macro)
migrated = True
# Migrate old format macros
migrated = False
for macro_id, macro in list(self.macros.items()):
if macro.get("type") != "sequence":
self.macros[macro_id] = self._migrate_macro(macro)
migrated = True
if migrated:
self.save_macros()
print("Migrated macros to new command sequence format")
if migrated:
self._save_macros_locked()
print("Migrated macros to new command sequence format")
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
except Exception as e:
print(f"Error loading macros: {e}")
self.macros = {}
def _migrate_macro(self, old_macro: dict) -> dict:
"""Convert old macro format to new command sequence format."""
@@ -91,28 +118,56 @@ class MacroManager:
}
def save_macros(self):
"""Save macros to JSON file."""
"""Save macros to JSON file (thread-safe, atomic)."""
with self._lock:
self._save_macros_locked()
def _save_macros_locked(self):
"""Atomically write macros to disk. Caller must hold ``self._lock``.
Writes to a temp file in the same directory then os.replace() over the
target so a crash mid-write can't leave a truncated/corrupt JSON file.
"""
try:
with open(self.data_file, "w") as file:
json.dump(self.macros, file, indent=4)
target_dir = os.path.dirname(self.data_file) or "."
os.makedirs(target_dir, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix=".macros-", suffix=".tmp", dir=target_dir
)
try:
with os.fdopen(fd, "w") as file:
json.dump(self.macros, file, indent=4)
file.flush()
os.fsync(file.fileno())
os.replace(tmp_path, self.data_file)
except Exception:
# Clean up the temp file on any failure
try:
os.remove(tmp_path)
except OSError:
pass
raise
except Exception as e:
print(f"Error saving macros: {e}")
def get_sorted_macros(self, sort_by: str = "name"):
"""Get macros sorted by specified criteria."""
macro_list = list(self.macros.items())
with self._lock:
macro_list = list(self.macros.items())
if sort_by == "name":
macro_list.sort(key=lambda x: x[1]["name"].lower())
macro_list.sort(key=lambda x: x[1].get("name", "").lower())
elif sort_by == "type":
# Sort by first command type in sequence
def get_first_type(macro):
cmds = macro.get("commands", [])
return cmds[0].get("type", "") if cmds else ""
macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower()))
macro_list.sort(
key=lambda x: (get_first_type(x[1]), x[1].get("name", "").lower())
)
elif sort_by == "recent":
macro_list.sort(
key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()),
key=lambda x: (x[1].get("last_used", 0), x[1].get("name", "").lower()),
reverse=True
)
@@ -141,9 +196,10 @@ class MacroManager:
tabs = ["All"]
categories = set()
for macro in self.macros.values():
if macro.get("category"):
categories.add(macro["category"])
with self._lock:
for macro in self.macros.values():
if macro.get("category"):
categories.add(macro["category"])
for category in sorted(categories):
if category and category not in tabs:
@@ -161,7 +217,7 @@ class MacroManager:
"""Add a new macro with command sequence."""
macro_id = str(uuid.uuid4())
# Process image if provided
# Process image if provided (file IO; does not touch self.macros)
image_path_reference = self._process_image(image_path) if image_path else ""
# Create macro data (deep copy commands to avoid reference issues)
@@ -174,8 +230,9 @@ class MacroManager:
"last_used": 0
}
self.macros[macro_id] = macro_data
self.save_macros()
with self._lock:
self.macros[macro_id] = macro_data
self._save_macros_locked()
return macro_id
def update_macro(
@@ -187,28 +244,32 @@ class MacroManager:
image_path: Optional[str] = None
) -> bool:
"""Update an existing macro."""
if macro_id not in self.macros:
return False
with self._lock:
if macro_id not in self.macros:
return False
existing_image = self.macros[macro_id].get("image_path", "")
existing_last_used = self.macros[macro_id].get("last_used", 0)
macro = self.macros[macro_id]
# Keep old image or update with new one
# Keep old image or update with new one (file IO outside the lock)
if image_path is not None:
image_path_reference = self._process_image(image_path) if image_path else ""
else:
image_path_reference = macro.get("image_path", "")
image_path_reference = existing_image
# Update macro data (deep copy commands to avoid reference issues)
self.macros[macro_id] = {
new_data = {
"name": name,
"type": "sequence",
"commands": copy.deepcopy(commands),
"category": category,
"image_path": image_path_reference,
"last_used": macro.get("last_used", 0)
"last_used": existing_last_used
}
self.save_macros()
with self._lock:
if macro_id not in self.macros:
return False
self.macros[macro_id] = new_data
self._save_macros_locked()
return True
def _process_image(self, image_path: str) -> str:
@@ -233,37 +294,50 @@ class MacroManager:
def delete_macro(self, macro_id: str) -> bool:
"""Delete a macro."""
if macro_id not in self.macros:
return False
with self._lock:
if macro_id not in self.macros:
return False
macro = self.macros[macro_id]
macro = self.macros[macro_id]
image_path = macro.get("image_path")
del self.macros[macro_id]
self._save_macros_locked()
# Delete associated image file
if macro.get("image_path"):
# Delete associated image file (outside the lock; pure file IO)
if image_path:
try:
img_path = os.path.join(self.app_dir, macro["image_path"])
img_path = os.path.join(self.app_dir, image_path)
if os.path.exists(img_path):
os.remove(img_path)
except Exception as e:
print(f"Error removing image file: {e}")
del self.macros[macro_id]
self.save_macros()
return True
def execute_macro(self, macro_id: str) -> bool:
"""Execute a macro by ID."""
if macro_id not in self.macros:
return False
"""Execute a macro by ID.
macro = self.macros[macro_id]
The macro's commands are copied under the lock, then executed *outside*
the lock so long-running wait/keyboard steps don't block other threads
(web server, GUI). last_used is updated in memory and only persisted at
most every LAST_USED_SAVE_INTERVAL seconds to avoid rewriting the whole
file on every press.
"""
with self._lock:
if macro_id not in self.macros:
return False
# Update last_used timestamp
self.macros[macro_id]["last_used"] = time.time()
self.save_macros()
macro = self.macros[macro_id]
commands = copy.deepcopy(macro.get("commands", []))
# Update last_used in memory; debounce the disk write.
now = time.time()
macro["last_used"] = now
if now - self._last_used_save_time >= LAST_USED_SAVE_INTERVAL:
self._last_used_save_time = now
self._save_macros_locked()
try:
commands = macro.get("commands", [])
for cmd in commands:
self._execute_command(cmd)
return True
@@ -271,6 +345,22 @@ class MacroManager:
print(f"Error executing macro: {e}")
return False
@staticmethod
def _valid_keys(keys) -> list:
"""Filter a list of key names to those pyautogui recognises."""
result = []
for k in keys:
if not isinstance(k, str):
continue
name = k.strip().lower()
if not name:
continue
if VALID_KEYS and name not in VALID_KEYS:
print(f"Skipping invalid key: {name!r}")
continue
result.append(name)
return result
def _execute_command(self, cmd: dict):
"""Execute a single command from a sequence."""
cmd_type = cmd.get("type", "")
@@ -293,10 +383,11 @@ class MacroManager:
pyautogui.typewrite(value, interval=0.02)
elif cmd_type == "key":
# Press a single key
# Press a single key (validated against pyautogui's known keys)
key = cmd.get("value", "")
if key:
pyautogui.press(key)
valid = self._valid_keys([key]) if key else []
if valid:
pyautogui.press(valid[0])
elif cmd_type == "hotkey":
# Press key combination
@@ -305,21 +396,37 @@ class MacroManager:
# Ensure keys is a list, not a string
if isinstance(keys, str):
keys = [k.strip().lower() for k in keys.split(",")]
# Small delay before hotkey for reliability on Windows
time.sleep(0.05)
pyautogui.hotkey(*keys, interval=0.05)
valid = self._valid_keys(keys)
# Only fire if every requested key was valid; a partial hotkey
# is worse than nothing.
if valid and len(valid) == len([k for k in keys if str(k).strip()]):
# Small delay before hotkey for reliability on Windows
time.sleep(0.05)
pyautogui.hotkey(*valid, interval=0.05)
elif cmd_type == "wait":
# Delay in milliseconds
ms = cmd.get("ms", 0)
# Delay in milliseconds (bounded, non-negative)
try:
ms = int(cmd.get("ms", 0))
except (TypeError, ValueError):
ms = 0
if ms > 0:
ms = min(ms, MAX_WAIT_MS)
time.sleep(ms / 1000.0)
elif cmd_type == "app":
# Launch application
# Launch application.
# SECURITY: shell=True was removed to kill shell-metacharacter
# injection (no ; && | $() chaining). We tokenize the command
# and exec the program directly without a shell.
command = cmd.get("command", "")
if command:
subprocess.Popen(command, shell=True)
try:
args = shlex.split(command, posix=(os.name != "nt"))
if args:
subprocess.Popen(args)
except Exception as e:
print(f"Error launching app command: {e}")
# Legacy API compatibility methods
def add_macro_legacy(
@@ -357,8 +464,11 @@ class MacroManager:
def get_macro(self, macro_id: str) -> Optional[dict]:
"""Get a macro by ID."""
return self.macros.get(macro_id)
with self._lock:
macro = self.macros.get(macro_id)
return copy.deepcopy(macro) if macro is not None else None
def get_all_macros(self) -> dict:
"""Get all macros."""
return self.macros.copy()
with self._lock:
return copy.deepcopy(self.macros)
+30 -5
View File
@@ -9,12 +9,37 @@ NODE_ENV=production
# Security
BCRYPT_ROUNDS=10
# Session ID length (default: 6 characters)
SESSION_ID_LENGTH=6
# Session ID length (default: 12 characters)
SESSION_ID_LENGTH=12
# Rate limiting
RATE_LIMIT_WINDOW_MS=90
RATE_LIMIT_MAX=1024
# Minimum password length enforced on session creation
MIN_PASSWORD_LENGTH=6
# Maximum number of concurrent sessions
MAX_SESSIONS=1000
# Prune sessions unused for this many days
SESSION_TTL_DAYS=90
# HTTP rate limiting (15 minute window, 300 requests per window)
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=300
# WebSocket upgrade rate limiting (per IP)
WS_UPGRADE_WINDOW_MS=60000
WS_UPGRADE_MAX=60
# Auth brute-force protection
AUTH_MAX_SOCKET_FAILURES=5
AUTH_LOCKOUT_MAX_ATTEMPTS=10
AUTH_LOCKOUT_WINDOW_MS=900000
AUTH_LOCKOUT_DURATION_MS=900000
# Allowed origins for CORS + WebSocket (comma-separated). Empty = same-host only.
# ALLOWED_ORIGINS=https://macropad.example.com
# Trust reverse proxy (number of hops, or true/false). Default: 1
TRUST_PROXY=1
# WebSocket timeouts (in milliseconds)
PING_INTERVAL=30000
+19 -3
View File
@@ -9,9 +9,12 @@ For AnHonestHost cloud-node-container deployment:
```bash
cd /home/jknapp/code/macropad/macropad-relay
npm install
npm run build
npm run build # required: the build is no longer triggered automatically on install
```
> Note: the `postinstall` build hook was removed. Always run `npm run build`
> explicitly (locally or in CI) after `npm install` to produce `dist/`.
### 2. Prepare Deployment Package
The build outputs to `dist/` with public files copied. Upload:
@@ -77,6 +80,19 @@ Set these in your container configuration:
- `DATA_DIR` - Data storage path (default: ./data)
- `NODE_ENV` - production or development
- `LOG_LEVEL` - info, debug, error
- `ALLOWED_ORIGINS` - Comma-separated list of allowed browser origins for the
WebSocket Origin check (e.g. `https://macropad.example.com`). **Set this to
your public origin(s) when running behind a reverse proxy** (see note below).
> **Reverse proxy note (`ALLOWED_ORIGINS`):** When `ALLOWED_ORIGINS` is unset,
> the WebSocket upgrade Origin check falls back to comparing the browser's
> `Origin` host against the raw `Host` header seen by the app. Behind a reverse
> proxy the internal `Host` (e.g. `localhost:3000`) often differs from the
> public origin the browser sends (e.g. `https://macropad.example.com`), so
> legitimate browser WebSocket upgrades get **rejected**. Set `ALLOWED_ORIGINS`
> to your public origin(s) to fix this. (Forwarding the public host via
> `proxy_set_header Host $host;`, as in the Nginx example below, also helps, but
> setting `ALLOWED_ORIGINS` explicitly is the reliable fix.)
## Test It Works
@@ -84,8 +100,8 @@ Set these in your container configuration:
# Test health endpoint
curl http://localhost:3000/health
# Should return:
# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]}
# Should return (counts only - no session ids are exposed):
# {"status":"ok","desktops":0,"webClients":0,"uptime":1.23}
```
## Nginx/Reverse Proxy (for HTTPS)
+2484
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -4,7 +4,6 @@
"description": "Relay server for MacroPad remote access",
"main": "dist/index.js",
"scripts": {
"postinstall": "npm run build",
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts",
@@ -21,7 +20,6 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"typescript": "^5.3.2",
"uuid": "^9.0.0",
"winston": "^3.11.0",
"ws": "^8.14.2"
},
@@ -29,7 +27,6 @@
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.16",
"@types/express": "^4.17.21",
"@types/uuid": "^9.0.6",
"@types/ws": "^8.5.9",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2"
+3 -342
View File
@@ -264,8 +264,8 @@
<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" onclick="app.toggleFullscreen()" title="Toggle fullscreen"></button>
<button class="header-btn" onclick="app.refresh()">Refresh</button>
<button class="header-btn icon-btn" id="fullscreen-btn" title="Toggle fullscreen"></button>
<button class="header-btn" id="refresh-btn">Refresh</button>
</div>
</header>
@@ -277,345 +277,6 @@
<div class="toast-container" id="toast-container"></div>
<script>
// Inline MacroPad App for Relay Mode
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.desktopConnected = false;
this.wsAuthenticated = false;
// Get session ID from URL
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
this.sessionId = pathMatch ? pathMatch[1] : null;
// Get password from URL or sessionStorage
const urlParams = new URLSearchParams(window.location.search);
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
if (this.password) {
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
if (urlParams.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
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}`);
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);
setTimeout(() => this.setupWebSocket(), 3000);
};
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;
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.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}" data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
const entries = Object.entries(this.macros);
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No macros found</p></div>';
return;
}
container.innerHTML = entries.map(([id, macro]) => {
// Include password as query param for image authentication
const imageSrc = macro.image_path
? `/${this.sessionId}/api/image/${macro.image_path}?password=${encodeURIComponent(this.password)}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${imageSrc ? `<img src="${imageSrc}" 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('');
}
setupEventListeners() {
document.getElementById('tabs-container').addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
}
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();
});
</script>
<script src="/static/app.js"></script>
</body>
</html>
+420
View File
@@ -0,0 +1,420 @@
// 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;
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.
async 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;
try {
const response = await fetch(
`/${this.sessionId}/api/image/${macro.image_path}`,
{ headers: this.getApiHeaders() }
);
if (!response.ok) continue; // keep placeholder
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
img.onload = () => URL.revokeObjectURL(objectUrl);
img.src = objectUrl;
img.style.display = '';
const placeholder = img.nextElementSibling;
if (placeholder) placeholder.style.display = 'none';
} catch (error) {
// Leave the placeholder visible on failure.
}
}
}
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();
});
+1 -87
View File
@@ -158,92 +158,6 @@
<p class="status" id="status">Checking connection...</p>
</div>
<script>
const sessionId = window.location.pathname.split('/')[1];
const form = document.getElementById('loginForm');
const passwordInput = document.getElementById('password');
const rememberCheckbox = document.getElementById('remember');
const submitBtn = document.getElementById('submitBtn');
const errorDiv = document.getElementById('error');
const statusDiv = document.getElementById('status');
let desktopConnected = false;
// Check for saved password
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`);
if (savedPassword) {
passwordInput.value = savedPassword;
}
// Connect to WebSocket to check desktop status
function checkStatus() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'desktop_status') {
desktopConnected = data.status === 'connected';
updateStatus();
}
};
ws.onerror = () => {
statusDiv.textContent = 'Connection error';
statusDiv.className = 'status disconnected';
};
ws.onclose = () => {
setTimeout(checkStatus, 5000);
};
}
function updateStatus() {
if (desktopConnected) {
statusDiv.textContent = 'Desktop connected';
statusDiv.className = 'status connected';
submitBtn.disabled = false;
} else {
statusDiv.textContent = 'Desktop not connected';
statusDiv.className = 'status disconnected';
submitBtn.disabled = true;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorDiv.style.display = 'none';
const password = passwordInput.value;
try {
// Test password with a simple API call
const response = await fetch(`/${sessionId}/api/tabs`, {
headers: {
'X-MacroPad-Password': password
}
});
if (response.ok) {
// Save password if remember is checked
if (rememberCheckbox.checked) {
sessionStorage.setItem(`macropad_${sessionId}`, password);
}
// Redirect to the PWA with password
window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`;
} else {
const data = await response.json();
errorDiv.textContent = data.error || 'Invalid password';
errorDiv.style.display = 'block';
}
} catch (error) {
errorDiv.textContent = 'Connection failed';
errorDiv.style.display = 'block';
}
});
checkStatus();
</script>
<script src="/static/login.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
// MacroPad login page logic (externalized so the CSP can drop 'unsafe-inline'
// for scripts).
const sessionId = window.location.pathname.split('/')[1];
const form = document.getElementById('loginForm');
const passwordInput = document.getElementById('password');
const rememberCheckbox = document.getElementById('remember');
const submitBtn = document.getElementById('submitBtn');
const errorDiv = document.getElementById('error');
const statusDiv = document.getElementById('status');
let desktopConnected = false;
// Check for saved password (session first, then "remembered" store)
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`)
|| localStorage.getItem(`macropad_${sessionId}`);
if (savedPassword) {
passwordInput.value = savedPassword;
}
// Connect to WebSocket to check desktop status
function checkStatus() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'desktop_status') {
desktopConnected = data.status === 'connected';
updateStatus();
}
};
ws.onerror = () => {
statusDiv.textContent = 'Connection error';
statusDiv.className = 'status disconnected';
};
ws.onclose = () => {
setTimeout(checkStatus, 5000);
};
}
function updateStatus() {
if (desktopConnected) {
statusDiv.textContent = 'Desktop connected';
statusDiv.className = 'status connected';
submitBtn.disabled = false;
} else {
statusDiv.textContent = 'Desktop not connected';
statusDiv.className = 'status disconnected';
submitBtn.disabled = true;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorDiv.style.display = 'none';
const password = passwordInput.value;
try {
// Test password with a simple API call
const response = await fetch(`/${sessionId}/api/tabs`, {
headers: {
'X-MacroPad-Password': password
}
});
if (response.ok) {
// Hand the credential to the app via storage (never the URL).
sessionStorage.setItem(`macropad_${sessionId}`, password);
if (rememberCheckbox.checked) {
localStorage.setItem(`macropad_${sessionId}`, password);
} else {
localStorage.removeItem(`macropad_${sessionId}`);
}
// Redirect to the PWA (credential is read from storage).
window.location.href = `/${sessionId}/app`;
} else {
const data = await response.json();
errorDiv.textContent = data.error || 'Invalid password';
errorDiv.style.display = 'block';
}
} catch (error) {
errorDiv.textContent = 'Connection failed';
errorDiv.style.display = 'block';
}
});
checkStatus();
+39 -3
View File
@@ -5,6 +5,14 @@ import path from 'path';
dotenv.config();
function parseTrustProxy(): boolean | number | string {
const v = process.env.TRUST_PROXY ?? '1';
if (v === 'false') return false;
if (v === 'true') return true;
const n = Number(v);
return Number.isNaN(n) ? v : n;
}
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
@@ -13,19 +21,47 @@ export const config = {
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
// Session settings
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10),
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '12', 10),
// Maximum number of sessions that may exist at once
maxSessions: parseInt(process.env.MAX_SESSIONS || '1000', 10),
// Minimum password length enforced when creating a new session
minPasswordLength: parseInt(process.env.MIN_PASSWORD_LENGTH || '6', 10),
// Sessions unused for this many days are pruned
sessionTtlDays: parseInt(process.env.SESSION_TTL_DAYS || '90', 10),
// Security
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
// Rate limiting
// Rate limiting (HTTP)
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
// WebSocket upgrade rate limiting (per IP)
wsUpgradeWindowMs: parseInt(process.env.WS_UPGRADE_WINDOW_MS || '60000', 10), // 1 minute
wsUpgradeMax: parseInt(process.env.WS_UPGRADE_MAX || '60', 10), // max upgrades per IP per window
// Auth brute-force protection
authMaxSocketFailures: parseInt(process.env.AUTH_MAX_SOCKET_FAILURES || '5', 10),
authLockoutMaxAttempts: parseInt(process.env.AUTH_LOCKOUT_MAX_ATTEMPTS || '10', 10),
authLockoutWindowMs: parseInt(process.env.AUTH_LOCKOUT_WINDOW_MS || '900000', 10), // 15 minutes
authLockoutDurationMs: parseInt(process.env.AUTH_LOCKOUT_DURATION_MS || '900000', 10), // 15 minutes
// WebSocket
pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
// Origin / CORS allowlist (comma-separated). Empty = same-host only.
allowedOrigins: (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0),
// Trust the X-Forwarded-* headers from a reverse proxy
trustProxy: parseTrustProxy(),
// Logging
logLevel: process.env.LOG_LEVEL || 'info',
+14 -18
View File
@@ -12,26 +12,22 @@ export function createApiProxy(
return async (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.params.sessionId;
// Check session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
// Credentials must be supplied via the header (never the URL/query,
// which leaks into logs, history and referrers).
const password = req.headers['x-macropad-password'] as string;
// Uniform auth failure. A missing session, a missing password, and a
// wrong password all return the SAME status + generic message so a
// caller cannot infer whether a given session id exists (no
// enumeration oracle). validatePassword() safely returns false for an
// unknown session id.
if (!password || !(await sessionManager.validatePassword(sessionId, password))) {
return res.status(401).json({ error: 'Authentication failed' });
}
// Check password in header or query
const password = req.headers['x-macropad-password'] as string ||
req.query.password as string;
if (!password) {
return res.status(401).json({ error: 'Password required' });
}
const valid = await sessionManager.validatePassword(sessionId, password);
if (!valid) {
return res.status(401).json({ error: 'Invalid password' });
}
// Check desktop is connected
// Only AFTER successful auth do we reveal desktop connectivity, so the
// 503 "not connected" signal cannot itself be used to probe which
// session ids exist.
const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (!desktop) {
return res.status(503).json({ error: 'Desktop not connected' });
+51 -12
View File
@@ -4,6 +4,11 @@ import WebSocket from 'ws';
import { ConnectionManager } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
import { config } from '../config';
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
// Close code 1008 = policy violation (used for auth abuse).
const WS_POLICY_VIOLATION = 1008;
interface AuthMessage {
type: 'auth';
@@ -32,20 +37,33 @@ type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | Po
export function handleDesktopConnection(
socket: WebSocket,
connectionManager: ConnectionManager,
sessionManager: SessionManager
sessionManager: SessionManager,
clientIp: string
): void {
let authenticatedSessionId: string | null = null;
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
socket.on('message', async (data) => {
try {
const message: DesktopMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth':
await handleAuth(socket, message, sessionManager, connectionManager, (sessionId) => {
case 'auth': {
const failed = await handleAuth(socket, message, sessionManager, connectionManager, clientIp, (sessionId) => {
authenticatedSessionId = sessionId;
});
if (failed) {
socketAuthFailures++;
if (socketAuthFailures >= config.authMaxSocketFailures) {
logger.warn('Desktop exceeded auth attempts, closing socket');
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
}
} else {
socketAuthFailures = 0;
}
break;
}
case 'api_response':
if (authenticatedSessionId) {
@@ -87,32 +105,53 @@ export function handleDesktopConnection(
});
}
// Returns true if the auth attempt failed (so the caller can count it).
async function handleAuth(
socket: WebSocket,
message: AuthMessage,
sessionManager: SessionManager,
connectionManager: ConnectionManager,
clientIp: string,
setSessionId: (id: string) => void
): Promise<void> {
): Promise<boolean> {
try {
let sessionId = message.sessionId;
let session;
if (sessionId) {
// Namespace the brute-force lockout key to the desktop keyspace and
// include the client IP. This keeps the desktop's lockout state entirely
// separate from the web-client keyspace, so a web-auth flood can never
// lock the desktop out of (re)authenticating.
const throttleKey = `desktop:${sessionId}:${clientIp}`;
// Reject early if this key is currently locked out.
const lockRemaining = getLockoutRemaining(throttleKey);
if (lockRemaining > 0) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Too many failed attempts, try again later',
retryAfterMs: lockRemaining
}));
return true;
}
// Validate existing session
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (!valid) {
recordFailure(throttleKey);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid session ID or password'
}));
socket.close();
return;
return true;
}
recordSuccess(throttleKey);
session = sessionManager.getSession(sessionId);
} else {
// Create new session
// Create new session (may throw on limit / short password)
session = await sessionManager.createSession(message.password);
sessionId = session.id;
}
@@ -123,8 +162,7 @@ async function handleAuth(
success: false,
error: 'Failed to create session'
}));
socket.close();
return;
return true;
}
// Add to connection manager
@@ -139,14 +177,15 @@ async function handleAuth(
}));
logger.info(`Desktop authenticated: ${sessionId}`);
} catch (error) {
return false;
} catch (error: any) {
logger.error('Desktop auth error:', error);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Authentication failed'
error: error?.message || 'Authentication failed'
}));
socket.close();
return true;
}
}
+59 -24
View File
@@ -4,6 +4,11 @@ import WebSocket from 'ws';
import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
import { config } from '../config';
import { getLockoutRemaining, recordFailure, recordSuccess } from '../utils/authThrottle';
// Close code 1008 = policy violation (used for auth abuse).
const WS_POLICY_VIOLATION = 1008;
interface AuthMessage {
type: 'auth';
@@ -20,30 +25,26 @@ export function handleWebClientConnection(
socket: WebSocket,
sessionId: string,
connectionManager: ConnectionManager,
sessionManager: SessionManager
sessionManager: SessionManager,
clientIp: string
): void {
// Check if session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
socket.send(JSON.stringify({
type: 'error',
error: 'Session not found'
}));
socket.close();
return;
}
// Namespace the brute-force lockout key to the web keyspace and include the
// client IP. This prevents one abusive IP (which knows the shareable session
// id) from locking out other web clients or the desktop for this session.
const throttleKey = `web:${sessionId}:${clientIp}`;
// Add client (not authenticated yet)
// Do NOT reveal whether the session exists. A non-existent session id and
// an existing one must be indistinguishable to an unauthenticated client so
// session ids cannot be enumerated: we always run the same handshake and let
// the (uniform) auth response be the only signal. validatePassword() safely
// returns false for an unknown session id, and desktop connectivity is only
// disclosed AFTER a successful auth (see the 'auth' case below).
const client = connectionManager.addWebClient(sessionId, socket, false);
// Check if desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
type: 'desktop_status',
status: desktop ? 'connected' : 'disconnected'
}));
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
// Request authentication
// Request authentication.
socket.send(JSON.stringify({
type: 'auth_required'
}));
@@ -53,9 +54,25 @@ export function handleWebClientConnection(
const message: WebClientMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth':
await handleAuth(socket, client, message, sessionId, sessionManager);
case 'auth': {
const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey);
if (failed) {
socketAuthFailures++;
if (socketAuthFailures >= config.authMaxSocketFailures) {
logger.warn(`Web client exceeded auth attempts for session ${sessionId}, closing socket`);
socket.close(WS_POLICY_VIOLATION, 'Too many failed attempts');
}
} else {
socketAuthFailures = 0;
// Authenticated: only now do we disclose desktop connectivity.
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
type: 'desktop_status',
status: desktop ? 'connected' : 'disconnected'
}));
}
break;
}
case 'ping':
socket.send(JSON.stringify({ type: 'pong' }));
@@ -82,28 +99,46 @@ export function handleWebClientConnection(
});
}
// Returns true if the auth attempt failed (so the caller can count it).
async function handleAuth(
socket: WebSocket,
client: WebClientConnection,
message: AuthMessage,
sessionId: string,
sessionManager: SessionManager
): Promise<void> {
sessionManager: SessionManager,
throttleKey: string
): Promise<boolean> {
// Reject early if this key is currently locked out.
const lockRemaining = getLockoutRemaining(throttleKey);
if (lockRemaining > 0) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Too many failed attempts, try again later',
retryAfterMs: lockRemaining
}));
return true;
}
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (valid) {
recordSuccess(throttleKey);
client.authenticated = true;
socket.send(JSON.stringify({
type: 'auth_response',
success: true
}));
logger.debug(`Web client authenticated for session: ${sessionId}`);
return false;
} else {
recordFailure(throttleKey);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid password'
error: 'Authentication failed'
}));
return true;
}
}
+115 -17
View File
@@ -3,7 +3,7 @@
import express from 'express';
import fs from 'fs';
import http from 'http';
import WebSocket, { WebSocketServer } from 'ws';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
@@ -17,19 +17,104 @@ import { handleDesktopConnection } from './handlers/desktopHandler';
import { handleWebClientConnection } from './handlers/webClientHandler';
import { createApiProxy } from './handlers/apiProxy';
// Resolve the client IP for an upgrade request, honouring the proxy's
// X-Forwarded-For first hop when we trust the proxy.
function getClientIp(request: http.IncomingMessage): string {
if (config.trustProxy) {
const xff = request.headers['x-forwarded-for'];
if (typeof xff === 'string' && xff.length > 0) {
return xff.split(',')[0].trim();
}
if (Array.isArray(xff) && xff.length > 0) {
return xff[0].split(',')[0].trim();
}
}
return request.socket.remoteAddress || 'unknown';
}
// Simple in-memory sliding-window rate limiter for WS upgrades, keyed by IP.
function createUpgradeRateLimiter() {
const hits = new Map<string, number[]>();
const cleanup = setInterval(() => {
const cutoff = Date.now() - config.wsUpgradeWindowMs;
for (const [ip, times] of hits) {
const recent = times.filter((t) => t > cutoff);
if (recent.length === 0) {
hits.delete(ip);
} else {
hits.set(ip, recent);
}
}
}, config.wsUpgradeWindowMs);
if (typeof cleanup.unref === 'function') cleanup.unref();
return function allow(ip: string): boolean {
const now = Date.now();
const cutoff = now - config.wsUpgradeWindowMs;
const times = (hits.get(ip) || []).filter((t) => t > cutoff);
times.push(now);
hits.set(ip, times);
return times.length <= config.wsUpgradeMax;
};
}
// Validate the Origin header for a WS upgrade against the allowlist.
// Native (desktop) clients send no Origin and are permitted.
function isOriginAllowed(origin: string | undefined, host: string | undefined): boolean {
if (!origin) return true; // non-browser clients (e.g. desktop app)
if (config.allowedOrigins.length > 0) {
return config.allowedOrigins.includes(origin);
}
// Default: allow only same-host origins.
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
export function createServer() {
const app = express();
const server = http.createServer(app);
// Trust the reverse proxy so client IPs (rate limiting) are accurate.
app.set('trust proxy', config.trustProxy);
// Initialize managers
const sessionManager = new SessionManager();
const connectionManager = new ConnectionManager();
// Middleware
app.use(helmet({
contentSecurityPolicy: false // Allow inline scripts for login page
// Page scripts are served as external files (/static/app.js,
// /static/login.js) with no inline handlers, so scripts are restricted to
// 'self' (no 'unsafe-inline'). The pages still use inline <style> blocks,
// so style-src keeps 'unsafe-inline'. Images may be data:/blob: (macro
// images are loaded as blob object URLs) and WS connections need ws:/wss:.
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'", 'ws:', 'wss:'],
manifestSrc: ["'self'", 'blob:'],
objectSrc: ["'none'"],
frameAncestors: ["'self'"],
},
},
}));
app.use(cors());
// Restrict CORS to the configured origins. When none are configured we
// disallow cross-origin requests (the first-party app is same-origin).
const corsOptions: cors.CorsOptions =
config.allowedOrigins.length > 0
? { origin: config.allowedOrigins }
: { origin: false };
app.use(cors(corsOptions));
app.use(express.json());
// Rate limiting
@@ -46,12 +131,14 @@ export function createServer() {
: path.join(__dirname, '..', 'public');
app.use('/static', express.static(publicPath));
// Health check
// Health check - counts only, never leak session ids.
app.get('/health', (req, res) => {
const stats = connectionManager.getStats();
res.json({
status: 'ok',
...stats
desktops: stats.desktopConnections,
webClients: stats.webClients,
uptime: process.uptime()
});
});
@@ -65,21 +152,14 @@ export function createServer() {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Login page for session
// Login page for session. Served uniformly regardless of whether the
// session exists so validity cannot be probed via this endpoint.
app.get('/:sessionId', (req, res) => {
const session = sessionManager.getSession(req.params.sessionId);
if (!session) {
return res.status(404).send('Session not found');
}
res.sendFile(path.join(publicPath, 'login.html'));
});
// PWA app page (after authentication)
// PWA app page (served uniformly; auth is enforced by the API/WS layer).
app.get('/:sessionId/app', (req, res) => {
const session = sessionManager.getSession(req.params.sessionId);
if (!session) {
return res.status(404).send('Session not found');
}
res.sendFile(path.join(publicPath, 'app.html'));
});
@@ -90,15 +170,32 @@ export function createServer() {
// WebSocket server
const wss = new WebSocketServer({ noServer: true });
const allowUpgrade = createUpgradeRateLimiter();
// Handle HTTP upgrade for WebSocket
server.on('upgrade', (request, socket, head) => {
// IP-based upgrade rate limiting (WS upgrades bypass Express middleware).
const ip = getClientIp(request);
if (!allowUpgrade(ip)) {
logger.warn(`WS upgrade rate limit exceeded for ${ip}`);
socket.destroy();
return;
}
// Origin validation.
if (!isOriginAllowed(request.headers.origin, request.headers.host)) {
logger.warn(`WS upgrade rejected: bad origin ${request.headers.origin}`);
socket.destroy();
return;
}
const url = new URL(request.url || '', `http://${request.headers.host}`);
const pathname = url.pathname;
// Desktop connection: /desktop
if (pathname === '/desktop') {
wss.handleUpgrade(request, socket, head, (ws) => {
handleDesktopConnection(ws, connectionManager, sessionManager);
handleDesktopConnection(ws, connectionManager, sessionManager, ip);
});
return;
}
@@ -108,7 +205,7 @@ export function createServer() {
if (webClientMatch) {
const sessionId = webClientMatch[1];
wss.handleUpgrade(request, socket, head, (ws) => {
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager);
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager, ip);
});
return;
}
@@ -121,6 +218,7 @@ export function createServer() {
const shutdown = () => {
logger.info('Shutting down...');
connectionManager.shutdown();
sessionManager.shutdown();
server.close(() => {
logger.info('Server closed');
process.exit(0);
+114 -7
View File
@@ -18,13 +18,38 @@ interface SessionStore {
sessions: Record<string, Session>;
}
// Flush pending (debounced) writes at most this often.
const FLUSH_INTERVAL_MS = 60 * 1000;
// Prune expired sessions on this cadence.
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
export class SessionManager {
private sessionsFile: string;
private sessions: Map<string, Session> = new Map();
private dirty = false;
private flushTimer: NodeJS.Timeout | null = null;
private pruneTimer: NodeJS.Timeout | null = null;
constructor() {
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
this.load();
this.pruneExpired();
this.startBackgroundTasks();
}
private startBackgroundTasks(): void {
this.flushTimer = setInterval(() => {
if (this.dirty) {
this.save();
this.dirty = false;
}
}, FLUSH_INTERVAL_MS);
if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref();
this.pruneTimer = setInterval(() => {
this.pruneExpired();
}, PRUNE_INTERVAL_MS);
if (typeof this.pruneTimer.unref === 'function') this.pruneTimer.unref();
}
private load(): void {
@@ -41,23 +66,69 @@ export class SessionManager {
}
}
// Atomic write: write to a temp file in the same directory, then rename.
private save(): void {
try {
const store: SessionStore = {
sessions: Object.fromEntries(this.sessions)
};
fs.mkdirSync(path.dirname(this.sessionsFile), { recursive: true });
fs.writeFileSync(this.sessionsFile, JSON.stringify(store, null, 2));
const dir = path.dirname(this.sessionsFile);
fs.mkdirSync(dir, { recursive: true });
const tmpFile = path.join(
dir,
`.sessions.${process.pid}.${Date.now()}.tmp`
);
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2));
fs.renameSync(tmpFile, this.sessionsFile);
} catch (error) {
logger.error('Failed to save sessions:', error);
}
}
// Prune sessions that have been unused past the configured TTL.
private pruneExpired(): void {
const ttlMs = config.sessionTtlDays * 24 * 60 * 60 * 1000;
if (ttlMs <= 0) return;
const now = Date.now();
let pruned = 0;
for (const [id, session] of this.sessions) {
const last = Date.parse(session.lastConnected || session.createdAt);
if (!Number.isNaN(last) && now - last > ttlMs) {
this.sessions.delete(id);
pruned++;
}
}
if (pruned > 0) {
logger.info(`Pruned ${pruned} expired session(s)`);
this.save();
this.dirty = false;
}
}
async exists(sessionId: string): Promise<boolean> {
return this.sessions.has(sessionId);
}
async createSession(password: string): Promise<Session> {
// Reject empty / short passwords before doing any hashing.
if (!password || password.length < config.minPasswordLength) {
throw new Error(
`Password must be at least ${config.minPasswordLength} characters`
);
}
// Cap total sessions to prevent unbounded resource exhaustion.
if (this.sessions.size >= config.maxSessions) {
// Try to reclaim space by pruning expired sessions first.
this.pruneExpired();
if (this.sessions.size >= config.maxSessions) {
throw new Error('Session limit reached');
}
}
// Generate unique session ID
let id: string;
do {
@@ -65,6 +136,21 @@ export class SessionManager {
} while (this.sessions.has(id));
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
// Re-check the cap AFTER the (awaited) hash. bcrypt.hash is the only
// suspension point between the first cap check and the set() below, so
// concurrent createSession calls could each have passed the earlier check
// and then filled the map while we were hashing. Re-checking here — with
// no await between this check and the synchronous set() — makes the
// check-and-insert atomic under Node's single-threaded model, so the cap
// cannot be overshot.
if (this.sessions.size >= config.maxSessions) {
this.pruneExpired();
if (this.sessions.size >= config.maxSessions) {
throw new Error('Session limit reached');
}
}
const now = new Date().toISOString();
const session: Session = {
@@ -75,7 +161,7 @@ export class SessionManager {
};
this.sessions.set(id, session);
this.save();
this.save(); // creation is important, persist immediately
logger.info(`Created new session: ${id}`);
return session;
@@ -90,9 +176,14 @@ export class SessionManager {
const valid = await bcrypt.compare(password, session.passwordHash);
if (valid) {
// Update last connected time
session.lastConnected = new Date().toISOString();
this.save();
// Update last-connected time, but only mark dirty (debounced flush)
// if it moved by more than a minute to avoid writing on every auth.
const now = Date.now();
const prev = Date.parse(session.lastConnected);
if (Number.isNaN(prev) || now - prev > 60 * 1000) {
session.lastConnected = new Date(now).toISOString();
this.dirty = true;
}
}
return valid;
@@ -106,7 +197,7 @@ export class SessionManager {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.save();
this.dirty = true;
}
}
@@ -118,4 +209,20 @@ export class SessionManager {
}
return deleted;
}
// Flush any pending writes and stop background timers.
shutdown(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
this.pruneTimer = null;
}
if (this.dirty) {
this.save();
this.dirty = false;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
// Auth brute-force lockout (in-memory).
//
// Tracks failed authentication attempts keyed by an opaque, namespaced key
// (e.g. `web:${sessionId}:${clientIp}` or `desktop:${sessionId}:${clientIp}`).
// Namespacing by path and client IP keeps the web and desktop keyspaces
// separate so one abusive web client cannot lock out other users or the
// desktop for the same session. After `authLockoutMaxAttempts` failures within
// `authLockoutWindowMs`, further attempts are rejected until
// `authLockoutDurationMs` has elapsed.
import { config } from '../config';
interface AttemptRecord {
count: number;
windowStart: number;
lockedUntil: number;
}
const records = new Map<string, AttemptRecord>();
/**
* Returns the number of milliseconds remaining on a lockout for this key,
* or 0 if the key is not currently locked.
*/
export function getLockoutRemaining(key: string): number {
const rec = records.get(key);
if (!rec) return 0;
const now = Date.now();
if (rec.lockedUntil > now) {
return rec.lockedUntil - now;
}
return 0;
}
/**
* Record a failed auth attempt. Locks the key out for
* `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate
* within `authLockoutWindowMs`. Callers query the resulting lockout via
* getLockoutRemaining(), so this returns nothing.
*/
export function recordFailure(key: string): void {
const now = Date.now();
let rec = records.get(key);
// Start a fresh window if none exists or the current one has expired.
if (!rec || now - rec.windowStart > config.authLockoutWindowMs) {
rec = { count: 0, windowStart: now, lockedUntil: 0 };
records.set(key, rec);
}
rec.count += 1;
if (rec.count >= config.authLockoutMaxAttempts) {
rec.lockedUntil = now + config.authLockoutDurationMs;
}
}
/**
* Clear any recorded failures for this key (call on successful auth).
*/
export function recordSuccess(key: string): void {
records.delete(key);
}
// Periodically drop stale records so the map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, rec] of records) {
const windowExpired = now - rec.windowStart > config.authLockoutWindowMs;
const notLocked = rec.lockedUntil <= now;
if (windowExpired && notLocked) {
records.delete(key);
}
}
}, Math.max(60000, config.authLockoutWindowMs));
// Do not keep the process alive solely for this timer.
if (typeof cleanup.unref === 'function') {
cleanup.unref();
}
+26 -13
View File
@@ -1,31 +1,44 @@
// Unique ID generation utilities
import { randomBytes } from 'crypto';
import { randomBytes, randomUUID } from 'crypto';
const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Largest multiple of 62 that fits in a byte (62 * 4 = 248). Bytes >= 248
// are rejected so every character is drawn with uniform probability
// (avoids modulo bias from `byte % 62`).
const REJECTION_THRESHOLD = Math.floor(256 / BASE62_CHARS.length) * BASE62_CHARS.length;
/**
* Generate a random base62 string of specified length.
* Uses cryptographically secure random bytes.
* Generate a random base62 string of the specified length.
* Uses cryptographically secure random bytes with rejection sampling
* to eliminate modulo bias.
*/
export function generateSessionId(length: number = 6): string {
const bytes = randomBytes(length);
export function generateSessionId(length: number = 12): string {
let result = '';
for (let i = 0; i < length; i++) {
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length];
while (result.length < length) {
// Fetch exactly `length` random bytes per pass. Bytes at or above the
// rejection threshold are skipped to avoid modulo bias, so a pass may
// yield fewer than `length` characters; the outer while then runs another
// pass until we have enough. (No over-fetching — each pass draws exactly
// `length` bytes.)
const bytes = randomBytes(length);
for (let i = 0; i < bytes.length && result.length < length; i++) {
const byte = bytes[i];
if (byte >= REJECTION_THRESHOLD) {
continue; // reject to avoid bias
}
result += BASE62_CHARS[byte % BASE62_CHARS.length];
}
}
return result;
}
/**
* Generate a UUID v4 for request IDs.
* Generate a UUID v4 for request IDs using the crypto module.
*/
export function generateRequestId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
return randomUUID();
}
+22 -3
View File
@@ -20,7 +20,8 @@ class RelayClient:
local_port: int = 40000,
on_connected: Optional[Callable] = None,
on_disconnected: Optional[Callable] = None,
on_session_id: Optional[Callable[[str], None]] = None
on_session_id: Optional[Callable[[str], None]] = None,
local_auth_token: str = ""
):
self.relay_url = relay_url.rstrip('/')
if not self.relay_url.endswith('/desktop'):
@@ -28,6 +29,7 @@ class RelayClient:
self.password = password
self.session_id = session_id
self.local_url = f"http://localhost:{local_port}"
self.local_auth_token = local_auth_token
# Callbacks
self.on_connected = on_connected
@@ -174,16 +176,33 @@ class RelayClient:
method = msg.get("method", "GET").upper()
path = msg.get("path", "/")
body = msg.get("body")
headers = msg.get("headers", {})
# Restrict the relay to API routes only. This prevents the relay from
# reaching static/other paths on the local server.
if not path.startswith("/api/"):
await self._ws.send_json({
"type": "api_response",
"requestId": request_id,
"status": 403,
"body": {"error": "forbidden path"}
})
return
url = f"{self.local_url}{path}"
# Build a fresh headers dict. We do NOT forward attacker-supplied
# headers from the relay; we inject the local auth token ourselves.
sending_body = bool(body) and method in ("POST", "PUT", "PATCH")
headers = {"X-MacroPad-Token": self.local_auth_token}
if sending_body:
headers["Content-Type"] = "application/json"
try:
# Forward request to local server
async with self._session.request(
method,
url,
json=body if body and method in ("POST", "PUT", "PATCH") else None,
json=body if sending_body else None,
headers=headers
) as response:
# Handle binary responses (images)
+696 -400
View File
File diff suppressed because it is too large Load Diff
+49 -11
View File
@@ -2,8 +2,10 @@
<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="theme-color" content="#007acc">
<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'; object-src 'none'; base-uri 'none'">
<meta name="theme-color" content="#15161a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)">
<meta name="description" content="Remote macro control for your desktop">
<!-- PWA / iOS specific -->
@@ -12,7 +14,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MacroPad">
<meta name="application-name" content="MacroPad">
<meta name="msapplication-TileColor" content="#007acc">
<meta name="msapplication-TileColor" content="#7c5cff">
<meta name="msapplication-tap-highlight" content="no">
<title>MacroPad</title>
@@ -29,20 +31,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 +55,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>
+630 -62
View File
@@ -1,4 +1,14 @@
// MacroPad PWA Application (Execute-only)
// MacroPad PWA Application
// Supported command types for the macro builder. Kept in sync with the
// server-side Command model: {type, value?, keys?, ms?, command?}.
const COMMAND_TYPES = [
{ value: 'text', label: 'Text', placeholder: 'Text to type', numeric: false },
{ value: 'key', label: 'Key', placeholder: 'Key name, e.g. enter', numeric: false },
{ value: 'hotkey', label: 'Hotkey', placeholder: 'Keys, e.g. ctrl, c', numeric: false },
{ value: 'wait', label: 'Wait', placeholder: 'Milliseconds', numeric: true },
{ value: 'app', label: 'App', placeholder: 'App or command to launch', numeric: false }
];
class MacroPadApp {
constructor() {
@@ -12,11 +22,32 @@ class MacroPadApp {
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;
// Blob object URLs created for macro images (revoked on re-render)
this._objectUrls = [];
// 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();
@@ -51,6 +82,20 @@ class MacroPadApp {
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}`;
@@ -62,15 +107,17 @@ class MacroPadApp {
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.setupEventListeners();
this.setupWakeLock();
this.checkInstallPrompt();
}
@@ -98,6 +145,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'
@@ -105,6 +155,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();
@@ -117,19 +168,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(),
@@ -137,28 +190,65 @@ 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);
}
}
handleAuthError() {
this.showToast('Authentication failed', 'error');
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() {
@@ -168,7 +258,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;
@@ -176,12 +296,18 @@ class MacroPadApp {
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);
}
@@ -191,19 +317,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();
}
}
@@ -212,7 +346,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
@@ -249,13 +383,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
@@ -280,71 +415,491 @@ 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;
// 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.
async loadMacroImage(url, img, placeholder) {
try {
const res = await fetch(url, { headers: this.getAuthHeaders() });
if (!res.ok) throw new Error('image request failed');
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
// Track for best-effort revocation on the next render.
this._objectUrls.push(objectUrl);
img.src = objectUrl;
} catch (e) {
// Fall back to the placeholder if the image can't be loaded.
img.style.display = 'none';
placeholder.style.display = '';
}
}
// 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);
// Best-effort: revoke object URLs from the previous render to avoid leaks.
if (this._objectUrls && this._objectUrls.length) {
this._objectUrls.forEach((u) => URL.revokeObjectURL(u));
}
this._objectUrls = [];
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 password as query param for relay mode (img tags can't use headers)
if (this.relayMode && this.password) {
imageSrc = `${basePath}?password=${encodeURIComponent(this.password)}`;
} 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 = '';
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) => {
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
@@ -376,13 +931,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;
}
@@ -409,7 +977,7 @@ class MacroPadApp {
// Fullscreen
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
document.documentElement.requestFullscreen().catch((err) => {
console.log('Fullscreen error:', err);
});
} else {
+3 -2
View File
@@ -6,9 +6,10 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#2e2e2e",
"theme_color": "#007acc",
"background_color": "#15161a",
"theme_color": "#7c5cff",
"orientation": "any",
"//": "Maskable icons ideally need ~10% transparent padding so they aren't cropped inside the platform safe zone; the current PNGs are reused as-is without new art.",
"icons": [
{
"src": "/static/icons/icon-192.png",
+51 -33
View File
@@ -1,5 +1,5 @@
// MacroPad PWA Service Worker
const CACHE_NAME = 'macropad-v3';
const CACHE_NAME = 'macropad-v4';
const ASSETS_TO_CACHE = [
'/',
'/static/css/styles.css',
@@ -9,7 +9,7 @@ const ASSETS_TO_CACHE = [
'/manifest.json'
];
// Install event - cache assets
// Install event - pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
@@ -34,39 +34,57 @@ self.addEventListener('activate', (event) => {
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Network-first for navigations + app code so deployed fixes reach clients.
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (err) {
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Offline fallback for navigations: serve the cached shell.
if (request.mode === 'navigate') {
const shell = await cache.match('/');
if (shell) {
return shell;
}
}
return new Response('Offline', {
status: 503,
statusText: 'Offline',
headers: { 'Content-Type': 'text/plain' }
});
}
}
// Always fetch API requests from network
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
event.respondWith(fetch(event.request));
self.addEventListener('fetch', (event) => {
const request = event.request;
// Only handle GET; let the browser deal with POST/PUT/DELETE directly.
if (request.method !== 'GET') {
return;
}
// For other requests, try cache first, then network
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((networkResponse) => {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return networkResponse;
});
})
.catch(() => {
// Return offline fallback for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
})
);
const url = new URL(request.url);
// API requests always go to the network; never serve stale data.
// Return a synthetic offline JSON response instead of an unhandled undefined.
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request).catch(() => new Response(
JSON.stringify({ error: 'offline', detail: 'Network unavailable' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
))
);
return;
}
// Navigations and static app assets: network-first with cache fallback.
event.respondWith(networkFirst(request));
});
+141 -24
View File
@@ -1,19 +1,35 @@
# FastAPI web server for MacroPad
import io
import os
import sys
import uuid
import hmac
import asyncio
from typing import List, Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel
from PIL import Image
import uvicorn
from config import DEFAULT_PORT, VERSION
# Max accepted upload size for images (bytes).
MAX_IMAGE_UPLOAD_BYTES = 5 * 1024 * 1024
# Map Pillow image formats to safe file extensions we're willing to persist.
_IMAGE_FORMAT_EXT = {
"PNG": ".png",
"JPEG": ".jpg",
"GIF": ".gif",
"BMP": ".bmp",
"WEBP": ".webp",
}
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
@@ -72,21 +88,28 @@ class ConnectionManager:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
"""Send message to all connected clients."""
for connection in self.active_connections:
"""Send message to all connected clients, pruning dead sockets."""
dead = []
# Iterate over a snapshot so the list isn't mutated during iteration.
for connection in list(self.active_connections):
try:
await connection.send_json(message)
except Exception:
pass
dead.append(connection)
for connection in dead:
self.disconnect(connection)
class WebServer:
"""FastAPI-based web server for MacroPad."""
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT):
def __init__(self, macro_manager, app_dir: str, port: int = DEFAULT_PORT,
auth_token: str = "", host: str = "127.0.0.1"):
self.macro_manager = macro_manager
self.app_dir = app_dir
self.port = port
self.auth_token = auth_token
self.host = host
self.app = None
self.manager = ConnectionManager()
self.server = None
@@ -114,6 +137,36 @@ class WebServer:
if os.path.exists(images_dir):
app.mount("/images", StaticFiles(directory=images_dir), name="images")
# Paths served without a token (PWA shell + static assets)
public_paths = {"/", "/manifest.json", "/service-worker.js"}
def _add_security_headers(response):
# Anti-clickjacking / MIME-sniffing. frame-ancestors can only be set
# via a real response header (it is ignored inside a <meta> CSP), so
# we enforce it here rather than in index.html.
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
return response
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Require a valid auth token for all non-public paths."""
path = request.url.path
if path in public_paths or path.startswith("/static/"):
return _add_security_headers(await call_next(request))
# Fail-open only when no token is configured (shouldn't happen in prod)
if self.auth_token:
provided = request.headers.get("X-MacroPad-Token") \
or request.query_params.get("token") or ""
if not hmac.compare_digest(provided, self.auth_token):
return _add_security_headers(
JSONResponse({"detail": "Unauthorized"}, status_code=401)
)
return _add_security_headers(await call_next(request))
@app.get("/", response_class=HTMLResponse)
async def index():
"""Serve the main PWA page."""
@@ -167,7 +220,12 @@ class WebServer:
@app.post("/api/execute")
async def execute_macro(request: ExecuteRequest):
"""Execute a macro by ID."""
success = self.macro_manager.execute_macro(request.macro_id)
# execute_macro is synchronous and may sleep (wait steps); run it in
# a thread executor so it doesn't block the event loop / other traffic.
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None, self.macro_manager.execute_macro, request.macro_id
)
if success:
# Broadcast execution to all connected clients
await self.manager.broadcast({
@@ -216,38 +274,97 @@ class WebServer:
@app.post("/api/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for a macro."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
"""Upload an image for a macro.
# Save to temp location
import tempfile
import shutil
Hardened: enforces a size cap, validates the bytes are a real image
with Pillow (content_type is untrusted), and saves into the app's
macro_images/ directory under a server-generated uuid name. Returns
only a relative reference, never an absolute server path.
"""
# Read with a hard size cap (reject oversize before buffering it all).
data = b""
chunk_size = 64 * 1024
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
data += chunk
if len(data) > MAX_IMAGE_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Image too large")
ext = os.path.splitext(file.filename)[1] if file.filename else ".png"
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
shutil.copyfileobj(file.file, tmp)
return {"path": tmp.name}
if not data:
raise HTTPException(status_code=400, detail="Empty upload")
# Validate it's really an image (don't trust content_type).
try:
Image.open(io.BytesIO(data)).verify()
# verify() leaves the image unusable; reopen to read the format.
img_format = (Image.open(io.BytesIO(data)).format or "").upper()
except Exception:
raise HTTPException(status_code=400, detail="File is not a valid image")
ext = _IMAGE_FORMAT_EXT.get(img_format)
if not ext:
raise HTTPException(status_code=400, detail="Unsupported image format")
images_dir = os.path.join(self.app_dir, "macro_images")
os.makedirs(images_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(images_dir, filename)
rel_path = os.path.join("macro_images", filename)
try:
with open(dest_path, "wb") as out:
out.write(data)
except Exception:
# Clean up a partial file on failure.
try:
if os.path.exists(dest_path):
os.remove(dest_path)
except OSError:
pass
raise HTTPException(status_code=500, detail="Failed to save image")
# Return only a relative, opaque reference.
return {"path": rel_path.replace(os.sep, "/")}
@app.get("/api/image/{image_path:path}")
async def get_image(image_path: str):
"""Get macro image (legacy compatibility)."""
full_path = os.path.join(self.app_dir, image_path)
if os.path.exists(full_path):
return FileResponse(full_path)
"""Get macro image (legacy compatibility, path-traversal safe)."""
images_dir = os.path.join(self.app_dir, "macro_images")
requested = os.path.realpath(os.path.join(self.app_dir, image_path))
images_real = os.path.realpath(images_dir)
# Only serve files that resolve to inside the macro_images directory
if (os.path.commonpath([requested, images_real]) == images_real
and os.path.isfile(requested)):
return FileResponse(requested)
raise HTTPException(status_code=404, detail="Image not found")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time updates."""
# Require a valid token before accepting the connection
if self.auth_token:
provided = websocket.query_params.get("token") or ""
if not hmac.compare_digest(provided, self.auth_token):
await websocket.close(code=1008)
return
await self.manager.connect(websocket)
try:
while True:
# receive_json raises on malformed input / non-JSON frames;
# any such error must still tear the connection down cleanly.
data = await websocket.receive_json()
# Handle incoming messages if needed
if data.get("type") == "ping":
if isinstance(data, dict) and data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception:
# Malformed input or any other error: don't leak the connection.
pass
finally:
self.manager.disconnect(websocket)
self.app = app
@@ -260,7 +377,7 @@ class WebServer:
config = uvicorn.Config(
self.app,
host="0.0.0.0",
host=self.host,
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility
@@ -280,7 +397,7 @@ class WebServer:
config = uvicorn.Config(
self.app,
host="0.0.0.0",
host=self.host,
port=self.port,
log_level="warning",
log_config=None # Disable default logging config for PyInstaller compatibility