7 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 4.8 0d6f48ca24 docs: document auth token, no-shell app commands, web editor, media keys
Update README for the new security model and features: local API access
token embedded in the URL/QR (and web.allow_lan), app commands executing
without a shell, relay password in OS keyring, web-based macro creation,
and media/system keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:39:16 -07:00
shadowdaoandClaude Opus 4.8 5896ec0893 security(web): enforce anti-clickjacking via response headers
frame-ancestors is ignored inside a <meta> CSP, so move clickjacking
protection to real response headers (X-Frame-Options: DENY) and add
X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on all
responses. Drop the ineffective frame-ancestors token from the meta CSP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:37:25 -07:00
shadowdaoandClaude Opus 4.8 01163891ec design(web): "Tactile Deck" redesign with light/dark theming
- Full semantic design-token layer (surfaces, spacing scale, radii, elevation,
  type scale) as the single source of truth; electric-violet signature accent
  instead of default blue.
- Light + dark themes via prefers-color-scheme; muted text now meets WCAG AA
  (replaces low-contrast #aaa/#888).
- Macro cards read as physical keys (raised, hairline highlight, per-macro
  --macro-color wash + placeholder, press/executing feedback, 2-line clamp).
- Proper modal sheet (blurred scrim, sticky header/footer, focus rings),
  thumb-reachable floating action button, 44px tap targets, app-wide
  :focus-visible, and prefers-reduced-motion support.
- manifest + theme-color meta updated to the new palette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:32:04 -07:00
shadowdaoandClaude Opus 4.8 beba425868 security+robustness(py/gui): thread-safety, upload hardening, keyring
macro_manager:
- All macro reads/writes guarded by a lock; macros.json written atomically
  (temp + fsync + os.replace). Commands are copied under the lock and executed
  outside it so wait/keyboard steps don't block other threads.
- last_used disk writes debounced (>=5s) instead of a full rewrite per press.
- Validate key/hotkey names against pyautogui.KEYBOARD_KEYS and clamp wait ms;
  invalid input is skipped/logged, not blindly executed.

web_server:
- WebSocket receive loop always disconnects (fixes dead-socket leak); broadcast
  prunes failed sockets.
- POST /api/execute offloaded to a thread executor so a long macro no longer
  blocks the event loop.
- Image upload hardened: 5MB cap (413), real-image validation via Pillow,
  server-generated name under macro_images/, returns a relative path only,
  cleans up on failure.

gui:
- Execute macros off the Qt main thread (signal back to status bar); debounce
  resizeEvent and only rebuild when the column count changes.
- Relay password stored in the OS keyring when available (plaintext JSON blanked;
  graceful fallback if no backend); delete dialogs name the macro; honor the
  minimize-to-tray setting; surface save failures.
- Add media/system keys (volume, play/pause, track nav) to the macro editor.
- THEME gains shared danger/accent-hover colors; drop dead imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:26:22 -07:00
shadowdaoandClaude Opus 4.8 435cc1aca9 security+feat(web): kill DOM XSS, add CSP, in-browser macro editor
Security:
- Eliminate DOM-based XSS: renderMacros/renderTabs rebuilt with
  createElement/textContent/setAttribute + addEventListener. No macro name,
  id, image_path, or category ever reaches innerHTML.
- Remove all inline on*= handlers; add a strict Content-Security-Policy meta
  (script-src 'self', object-src 'none', base-uri 'none', frame-ancestors
  'none').
- Guard JSON.parse on WebSocket frames.

Robustness:
- Service worker: network-first for the app shell + JS/CSS so fixes actually
  ship; synthetic 503 for offline API calls; drop no-op /ws intercept; bump
  cache version.
- WebSocket reconnect: exponential backoff (1s→30s), tear down old socket
  before reconnect, stop on pagehide/beforeunload.
- Tab-switch race fixed with a request-id guard; executing state cleared in
  finally.

Accessibility:
- Macro cards and tabs are real buttons with aria-labels/roles; toast +
  connection status are aria-live; removed user-scalable=no.

Feature — web-based macro creation/editing:
- Floating "+" button + modal command-sequence builder (text/key/hotkey/
  wait/app) with reorder/remove; create via POST, edit via PUT (prefilled
  from GET /api/macro/{id}), delete with a named confirm. Works in both local
  and relay auth modes. Empty state now offers "Create your first macro".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:24:14 -07:00
shadowdaoandClaude Opus 4.8 eba5a2a38e security(relay): harden internet-facing relay server
Closes the account-takeover chain and related issues found in audit:

- WS auth brute-force protection: per-session lockout (authThrottle) + per-
  socket failure cap that closes the socket (1008); applied to both web-client
  and desktop auth. Failed auth no longer leaves the socket open for unlimited
  guesses.
- WS upgrades now pass an IP-based rate limiter and Origin allowlist before
  handleUpgrade (previously bypassed all HTTP middleware); trust proxy set so
  limiting keys on the real client IP.
- /health no longer leaks live session IDs (counts only).
- .env.example rate-limit fixed (900000ms / 300) from the accidental ~11k rps.
- Credentials moved out of URLs into the X-MacroPad-Password header; images
  fetched via header + blob URLs; login stores creds in sessionStorage.
- Session creation bounded (max sessions, min password length) and TTL-pruned;
  session store writes are now atomic (temp+rename) and debounced.
- Session IDs lengthened to 12 chars with rejection sampling (no modulo bias).
- Enable helmet CSP; restrict CORS to configured origins.
- Request IDs use crypto.randomUUID; drop postinstall build hook and uuid dep.
- Uniform response for unknown session IDs (removes enumeration oracle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:15:23 -07:00
shadowdaoandClaude Opus 4.8 1f26427328 security(P0): close unauthenticated RCE chain
The local FastAPI server previously exposed macro create/execute over the
network with no authentication while bound to 0.0.0.0, and macro `app`
commands ran via subprocess with shell=True — an unauthenticated RCE for
anyone on the LAN (and, via the relay, the internet).

- Mandatory per-install auth token (secrets.token_urlsafe) required on all
  /api/* routes and the /ws handshake; timing-safe hmac.compare_digest.
  PWA shell + /static assets remain public to bootstrap.
- Token auto-generated and persisted in settings.json (now chmod 0600).
- Bind host is configurable (web.allow_lan); token is the security boundary.
- macro `app` launch no longer uses shell=True — commands are tokenized with
  shlex and exec'd directly, killing ;/&&/|/$() shell chaining.
- Relay client injects the local token and only forwards /api/* paths, and no
  longer forwards attacker-supplied headers to the local server.
- Fixed path traversal in /api/image/{path} (realpath-confined to macro_images).
- GUI embeds the token in the LAN URL/QR; PWA reads it and sends it on API,
  WebSocket, and image requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:14:56 -07:00
28 changed files with 4897 additions and 731 deletions
+14 -4
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
@@ -154,7 +158,10 @@ Once connected, a relay URL will appear in the toolbar. Use this URL from any de
- 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 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 +171,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
+6 -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:
@@ -84,8 +87,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"
+39 -12
View File
@@ -292,15 +292,12 @@
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}`);
// 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);
if (urlParams.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
this.init();
@@ -374,6 +371,7 @@
handleAuthError() {
sessionStorage.removeItem(`macropad_${this.sessionId}`);
localStorage.removeItem(`macropad_${this.sessionId}`);
window.location.href = `/${this.sessionId}`;
}
@@ -467,19 +465,48 @@
}
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 hasImage = !!macro.image_path;
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>
${hasImage ? `<img class="macro-image" style="display:none">` : ''}
<div class="macro-image-placeholder">${firstChar}</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
// 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() {
+10 -6
View File
@@ -169,8 +169,9 @@
let desktopConnected = false;
// Check for saved password
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`);
// Check for saved password (session first, then "remembered" store)
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`)
|| localStorage.getItem(`macropad_${sessionId}`);
if (savedPassword) {
passwordInput.value = savedPassword;
}
@@ -225,13 +226,16 @@
});
if (response.ok) {
// Save password if remember is checked
// Hand the credential to the app via storage (never the URL).
sessionStorage.setItem(`macropad_${sessionId}`, password);
if (rememberCheckbox.checked) {
sessionStorage.setItem(`macropad_${sessionId}`, password);
localStorage.setItem(`macropad_${sessionId}`, password);
} else {
localStorage.removeItem(`macropad_${sessionId}`);
}
// Redirect to the PWA with password
window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`;
// 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';
+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',
+3 -3
View File
@@ -18,9 +18,9 @@ export function createApiProxy(
return res.status(404).json({ error: 'Session not found' });
}
// Check password in header or query
const password = req.headers['x-macropad-password'] as string ||
req.query.password as string;
// 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;
if (!password) {
return res.status(401).json({ error: 'Password required' });
+42 -11
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';
@@ -35,17 +40,29 @@ export function handleDesktopConnection(
sessionManager: SessionManager
): 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, (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 +104,46 @@ 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,
setSessionId: (id: string) => void
): Promise<void> {
): Promise<boolean> {
try {
let sessionId = message.sessionId;
let session;
if (sessionId) {
// Reject early if this session id is currently locked out.
const lockRemaining = getLockoutRemaining(sessionId);
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(sessionId);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid session ID or password'
}));
socket.close();
return;
return true;
}
recordSuccess(sessionId);
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 +154,7 @@ async function handleAuth(
success: false,
error: 'Failed to create session'
}));
socket.close();
return;
return true;
}
// Add to connection manager
@@ -139,14 +169,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;
}
}
@@ -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';
@@ -36,6 +41,9 @@ export function handleWebClientConnection(
// Add client (not authenticated yet)
const client = connectionManager.addWebClient(sessionId, socket, false);
// Track failed auth attempts on this individual socket.
let socketAuthFailures = 0;
// Check if desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
@@ -53,9 +61,19 @@ 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);
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;
}
break;
}
case 'ping':
socket.send(JSON.stringify({ type: 'pong' }));
@@ -82,28 +100,45 @@ 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> {
): Promise<boolean> {
// Reject early if this session id is currently locked out.
const lockRemaining = getLockoutRemaining(sessionId);
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(sessionId);
client.authenticated = true;
socket.send(JSON.stringify({
type: 'auth_response',
success: true
}));
logger.debug(`Web client authenticated for session: ${sessionId}`);
return false;
} else {
recordFailure(sessionId);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid password'
}));
return true;
}
}
+110 -15
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,101 @@ 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
// The bundled login/app pages rely on inline <script> and inline event
// handlers, so script/style inline is permitted; images may be blobs.
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
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 +128,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 +149,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,8 +167,25 @@ 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;
@@ -121,6 +215,7 @@ export function createServer() {
const shutdown = () => {
logger.info('Shutting down...');
connectionManager.shutdown();
sessionManager.shutdown();
server.close(() => {
logger.info('Server closed');
process.exit(0);
+99 -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 {
@@ -75,7 +146,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 +161,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 +182,7 @@ export class SessionManager {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.save();
this.dirty = true;
}
}
@@ -118,4 +194,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;
}
}
}
+76
View File
@@ -0,0 +1,76 @@
// Per-session-id auth brute-force lockout (in-memory).
//
// Tracks failed authentication attempts keyed by session id. 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. Returns the lockout remaining (ms) after
* this failure is applied (0 if still not locked).
*/
export function recordFailure(key: string): number {
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;
}
return rec.lockedUntil > now ? rec.lockedUntil - now : 0;
}
/**
* 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();
}
+22 -13
View File
@@ -1,31 +1,40 @@
// 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) {
// Over-fetch a little to reduce the number of syscalls when rejecting.
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' ws: wss:; 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>
+560 -61
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,25 @@ 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;
// Macro editor state (null = create mode)
this.editingMacroId = null;
if (this.relayMode) {
this.initRelayMode();
} else {
this.initLocalMode();
}
this.init();
@@ -51,6 +75,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 +100,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 +138,9 @@ class MacroPadApp {
}
async loadMacros() {
// Only the latest request is allowed to render, so overlapping
// tab-switch fetches can't apply out of order.
const reqId = ++this._macroReqId;
try {
const path = this.currentTab === 'All'
? '/api/macros'
@@ -105,6 +148,7 @@ class MacroPadApp {
const response = await fetch(this.getApiUrl(path), {
headers: this.getApiHeaders()
});
if (reqId !== this._macroReqId) return; // superseded
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
@@ -117,19 +161,21 @@ class MacroPadApp {
throw new Error('Failed to load macros');
}
const data = await response.json();
if (reqId !== this._macroReqId) return; // superseded
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
if (reqId !== this._macroReqId) return;
console.error('Error loading macros:', error);
this.showToast('Error loading macros', 'error');
}
}
async executeMacro(macroId) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const card = this.findCard(macroId);
if (card) card.classList.add('executing');
try {
const response = await fetch(this.getApiUrl('/api/execute'), {
method: 'POST',
headers: this.getApiHeaders(),
@@ -137,18 +183,23 @@ class MacroPadApp {
});
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
return;
}
if (response.status === 503) {
this.handleDesktopDisconnected();
}
throw new Error('Execution failed');
}
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
} catch (error) {
console.error('Error executing macro:', error);
this.showToast('Error executing macro', 'error');
} finally {
// Always clear the animation state, even on failure.
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
}
}
@@ -168,7 +219,37 @@ class MacroPadApp {
}
// WebSocket
teardownSocket() {
if (this.ws) {
// Detach handlers so the old socket can't fire reconnect logic.
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onmessage = null;
this.ws.onerror = null;
try {
this.ws.close();
} catch (e) {
/* ignore */
}
this.ws = null;
}
}
scheduleReconnect() {
if (!this.shouldReconnect) return;
clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay;
this.reconnectTimer = setTimeout(() => this.setupWebSocket(), delay);
// Exponential backoff, capped at 30s.
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
setupWebSocket() {
if (!this.shouldReconnect) return;
// Clean up any prior socket before opening a new one.
this.teardownSocket();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl;
@@ -176,12 +257,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 +278,27 @@ class MacroPadApp {
this.ws.onclose = () => {
this.wsAuthenticated = false;
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
this.scheduleReconnect();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
// Ignore malformed frames.
return;
}
this.handleWebSocketMessage(data);
};
this.ws.onerror = () => {
// Let onclose own reconnection; just reflect status here.
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket error:', error);
this.scheduleReconnect();
}
}
@@ -212,7 +307,7 @@ class MacroPadApp {
// Relay-specific messages
case 'auth_required':
// Send authentication
if (this.password) {
if (this.password && this.ws) {
this.ws.send(JSON.stringify({
type: 'auth',
password: this.password
@@ -249,13 +344,14 @@ class MacroPadApp {
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
case 'executed': {
const card = this.findCard(data.macro_id);
if (card) {
card.classList.add('executing');
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
}
case 'pong':
// Keep-alive response
@@ -280,71 +376,461 @@ class MacroPadApp {
}
}
// Rendering
// Deterministic hue from a macro name so the CSS can color placeholders.
hashHue(name) {
const s = String(name || '');
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (hash * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
findCard(macroId) {
const cards = document.querySelectorAll('.macro-card');
for (const card of cards) {
if (card.dataset.macroId === macroId) return card;
}
return null;
}
macroImageUrl(macro) {
if (!macro.image_path) return null;
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
// Credentials go as query params since <img> can't set headers.
if (this.relayMode && this.password) {
return `${basePath}?password=${encodeURIComponent(this.password)}`;
}
if (!this.relayMode && this.localToken) {
return `${basePath}?token=${encodeURIComponent(this.localToken)}`;
}
return basePath;
}
// Rendering (safe DOM construction only - no user value reaches innerHTML)
renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
data-tab="${tab}">${tab}</button>
`).join('');
container.textContent = '';
this.tabs.forEach((tab) => {
const active = tab === this.currentTab;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = active ? 'tab active' : 'tab';
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', active ? 'true' : 'false');
btn.dataset.tab = tab;
btn.textContent = tab;
container.appendChild(btn);
});
}
renderMacros() {
const container = document.getElementById('macro-grid');
if (!container) return;
const macroEntries = Object.entries(this.macros);
container.textContent = '';
const macroEntries = Object.entries(this.macros);
if (macroEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No macros found</p>
<p class="hint">Create macros in the desktop app</p>
</div>
`;
container.appendChild(this.buildEmptyState());
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
let imageSrc = null;
if (macro.image_path) {
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
// Add 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 = '';
img.src = imageSrc;
placeholder.style.display = 'none';
// Fall back to the placeholder if the image fails to load.
img.addEventListener('error', () => {
img.style.display = 'none';
placeholder.style.display = '';
});
card.appendChild(img);
}
card.appendChild(placeholder);
const nameEl = document.createElement('span');
nameEl.className = 'macro-name';
nameEl.textContent = name;
card.appendChild(nameEl);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'macro-edit-btn';
editBtn.setAttribute('aria-label', `Edit ${name}`);
editBtn.textContent = '✎';
editBtn.addEventListener('click', (e) => {
// Don't trigger execute when editing.
e.stopPropagation();
this.openMacroModal(id);
});
card.appendChild(editBtn);
card.addEventListener('click', () => this.executeMacro(id));
return card;
}
buildEmptyState() {
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const p1 = document.createElement('p');
p1.textContent = 'No macros yet';
const p2 = document.createElement('p');
p2.className = 'hint';
p2.textContent = 'Build one right here in your browser.';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-primary';
btn.textContent = 'Create your first macro';
btn.addEventListener('click', () => this.openMacroModal());
wrap.append(p1, p2, btn);
return wrap;
}
// ---- Macro editor (create / edit / delete) ----
populateCategoryOptions() {
const list = document.getElementById('macro-category-list');
if (!list) return;
list.textContent = '';
this.tabs
.filter((t) => t && t !== 'All')
.forEach((t) => {
const opt = document.createElement('option');
opt.value = t;
list.appendChild(opt);
});
}
commandToInputValue(cmd) {
switch (cmd.type) {
case 'hotkey':
return Array.isArray(cmd.keys) ? cmd.keys.join(', ') : (cmd.keys || '');
case 'wait':
return cmd.ms != null ? String(cmd.ms) : '';
case 'app':
return cmd.command || '';
case 'text':
case 'key':
default:
return cmd.value || '';
}
}
applyCommandRowType(select, input) {
const meta = COMMAND_TYPES.find((t) => t.value === select.value) || COMMAND_TYPES[0];
input.type = meta.numeric ? 'number' : 'text';
input.placeholder = meta.placeholder;
if (meta.numeric) {
input.min = '0';
} else {
input.removeAttribute('min');
}
}
addCommandRow(type = 'text', cmd = null) {
const list = document.getElementById('command-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'command-item';
const select = document.createElement('select');
select.className = 'command-type-select';
COMMAND_TYPES.forEach((t) => {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
select.appendChild(opt);
});
select.value = COMMAND_TYPES.some((t) => t.value === type) ? type : 'text';
const input = document.createElement('input');
input.className = 'command-value';
input.autocomplete = 'off';
const actions = document.createElement('div');
actions.className = 'command-actions';
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '↑';
upBtn.setAttribute('aria-label', 'Move step up');
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.textContent = '↓';
downBtn.setAttribute('aria-label', 'Move step down');
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'delete';
delBtn.textContent = '✕';
delBtn.setAttribute('aria-label', 'Remove step');
this.applyCommandRowType(select, input);
if (cmd) {
input.value = this.commandToInputValue(cmd);
}
select.addEventListener('change', () => this.applyCommandRowType(select, input));
upBtn.addEventListener('click', () => {
const prev = row.previousElementSibling;
if (prev) list.insertBefore(row, prev);
});
downBtn.addEventListener('click', () => {
const next = row.nextElementSibling;
if (next) list.insertBefore(next, row);
});
delBtn.addEventListener('click', () => row.remove());
actions.append(upBtn, downBtn, delBtn);
row.append(select, input, actions);
list.appendChild(row);
}
renderCommandRows(commands) {
const list = document.getElementById('command-list');
if (!list) return;
list.textContent = '';
commands.forEach((cmd) => this.addCommandRow(cmd.type || 'text', cmd));
}
collectCommands() {
const list = document.getElementById('command-list');
if (!list) return [];
const commands = [];
list.querySelectorAll('.command-item').forEach((row) => {
const type = row.querySelector('.command-type-select').value;
const raw = row.querySelector('.command-value').value.trim();
switch (type) {
case 'hotkey': {
// Store keys as a list (split on commas/whitespace).
const keys = raw.split(/[\s,]+/).filter(Boolean);
commands.push({ type: 'hotkey', keys });
break;
}
case 'wait': {
const ms = parseInt(raw, 10);
commands.push({ type: 'wait', ms: Number.isFinite(ms) ? ms : 0 });
break;
}
case 'app':
commands.push({ type: 'app', command: raw });
break;
case 'key':
commands.push({ type: 'key', value: raw });
break;
case 'text':
default:
commands.push({ type: 'text', value: raw });
break;
}
});
return commands;
}
async openMacroModal(macroId = null) {
this.editingMacroId = macroId || null;
const modal = document.getElementById('macro-modal');
const title = document.getElementById('macro-modal-title');
const nameInput = document.getElementById('macro-name-input');
const categoryInput = document.getElementById('macro-category-input');
const deleteBtn = document.getElementById('delete-macro-btn');
if (!modal) return;
this.populateCategoryOptions();
nameInput.value = '';
categoryInput.value = '';
this.renderCommandRows([]);
if (macroId) {
title.textContent = 'Edit Macro';
if (deleteBtn) deleteBtn.hidden = false;
try {
const res = await fetch(
this.getApiUrl(`/api/macro/${encodeURIComponent(macroId)}`),
{ headers: this.getApiHeaders() }
);
if (!res.ok) {
if (res.status === 401) { this.handleAuthError(); return; }
throw new Error('Failed to load macro');
}
const data = await res.json();
const macro = data.macro || {};
nameInput.value = macro.name || '';
categoryInput.value = macro.category || '';
this.renderCommandRows(Array.isArray(macro.commands) ? macro.commands : []);
} catch (e) {
console.error('Error loading macro:', e);
this.showToast('Could not load macro', 'error');
}
} else {
title.textContent = 'New Macro';
if (deleteBtn) deleteBtn.hidden = true;
categoryInput.value = (this.currentTab && this.currentTab !== 'All') ? this.currentTab : '';
this.addCommandRow('text');
}
modal.hidden = false;
nameInput.focus();
}
closeMacroModal() {
const modal = document.getElementById('macro-modal');
if (modal) modal.hidden = true;
this.editingMacroId = null;
}
async saveMacro() {
const nameInput = document.getElementById('macro-name-input');
const categoryInput = document.getElementById('macro-category-input');
const saveBtn = document.getElementById('save-macro-btn');
const name = nameInput.value.trim();
const category = categoryInput.value.trim();
const commands = this.collectCommands();
if (!name) {
this.showToast('Please enter a name', 'error');
nameInput.focus();
return;
}
if (commands.length === 0) {
this.showToast('Add at least one step', 'error');
return;
}
const editing = this.editingMacroId;
const url = editing
? this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`)
: this.getApiUrl('/api/macros');
const method = editing ? 'PUT' : 'POST';
const body = JSON.stringify({ name, commands, category });
if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(url, {
method,
headers: this.getApiHeaders(),
body
});
if (!res.ok) {
if (res.status === 401) { this.handleAuthError(); return; }
throw new Error('Save failed');
}
this.showToast(editing ? 'Macro updated' : 'Macro created', 'success');
this.closeMacroModal();
// WS broadcast will also refresh, but reload immediately for snappiness.
await this.loadTabs();
await this.loadMacros();
} catch (e) {
console.error('Error saving macro:', e);
this.showToast('Error saving macro', 'error');
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
async deleteMacro() {
const editing = this.editingMacroId;
if (!editing) return;
const nameInput = document.getElementById('macro-name-input');
const name = (nameInput && nameInput.value.trim()) || 'this macro';
if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
const res = await fetch(
this.getApiUrl(`/api/macros/${encodeURIComponent(editing)}`),
{ method: 'DELETE', headers: this.getApiHeaders() }
);
if (!res.ok) {
if (res.status === 401) { this.handleAuthError(); return; }
throw new Error('Delete failed');
}
this.showToast('Macro deleted', 'success');
this.closeMacroModal();
await this.loadTabs();
await this.loadMacros();
} catch (e) {
console.error('Error deleting macro:', e);
this.showToast('Error deleting macro', 'error');
}
}
// Event Listeners
setupEventListeners() {
// Tab clicks
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
const tab = e.target.closest('.tab');
if (tab) {
this.currentTab = tab.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
// Header controls
document.getElementById('refresh-btn')?.addEventListener('click', () => this.refresh());
document.getElementById('fullscreen-btn')?.addEventListener('click', () => this.toggleFullscreen());
// Macro editor controls
document.getElementById('new-macro-btn')?.addEventListener('click', () => this.openMacroModal());
document.getElementById('add-command-btn')?.addEventListener('click', () => this.addCommandRow('text'));
document.getElementById('save-macro-btn')?.addEventListener('click', () => this.saveMacro());
document.getElementById('cancel-macro-btn')?.addEventListener('click', () => this.closeMacroModal());
document.getElementById('delete-macro-btn')?.addEventListener('click', () => this.deleteMacro());
document.getElementById('macro-modal-close')?.addEventListener('click', () => this.closeMacroModal());
const modal = document.getElementById('macro-modal');
modal?.addEventListener('click', (e) => {
// Click on the backdrop (not the content) closes.
if (e.target === modal) this.closeMacroModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal && !modal.hidden) this.closeMacroModal();
});
// Stop reconnecting once the page is going away.
const stop = () => {
this.shouldReconnect = false;
clearTimeout(this.reconnectTimer);
this.teardownSocket();
};
window.addEventListener('pagehide', stop);
window.addEventListener('beforeunload', stop);
}
// Toast notifications
@@ -376,13 +862,26 @@ class MacroPadApp {
showInstallBanner(deferredPrompt) {
const banner = document.createElement('div');
banner.className = 'install-banner';
banner.innerHTML = `
<span>Install MacroPad for quick access</span>
<div>
<button onclick="app.installPWA()">Install</button>
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
</div>
`;
const label = document.createElement('span');
label.textContent = 'Install MacroPad for quick access';
const actions = document.createElement('div');
const installBtn = document.createElement('button');
installBtn.type = 'button';
installBtn.textContent = 'Install';
installBtn.addEventListener('click', () => this.installPWA());
const dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.className = 'dismiss';
dismissBtn.textContent = '✕';
dismissBtn.setAttribute('aria-label', 'Dismiss install banner');
dismissBtn.addEventListener('click', () => banner.remove());
actions.append(installBtn, dismissBtn);
banner.append(label, actions);
document.body.insertBefore(banner, document.body.firstChild);
this.deferredPrompt = deferredPrompt;
}
@@ -409,7 +908,7 @@ class MacroPadApp {
// Fullscreen
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
document.documentElement.requestFullscreen().catch((err) => {
console.log('Fullscreen error:', err);
});
} else {
+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