16 Commits

Author SHA1 Message Date
7922910bd8 Update README and version for v1.0.0 release
- Add relay server documentation and setup instructions
- Add relay_client.py to project structure
- Add aiohttp to core dependencies
- Update version.txt to 1.0.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 14:06:52 -08:00
664d652e9e Fix Copied! visibility and remove URL hover effect
- Use visible gray (#888888) for "Copied!" text
- Remove hover color change from URL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:48:45 -08:00
10971e6a02 Remove version from title bar, improve copy feedback
- Remove version number from window title (still shown in About)
- Show "Copied!" over URL text with dimmed style instead of status bar message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:44:31 -08:00
17f4bc0c5f Make URL clickable to copy instead of separate button
Cleaner interface - URL text now highlights on hover and copies to
clipboard when clicked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:33:14 -08:00
44c21e68d8 Release v1.0.0 with UI improvements and bug fixes
- Fix window restore when clicking Show from system tray or double-clicking
- Add Copy button for web interface URL
- Fix image loading when app starts with Windows startup
- Remove debug print statements
- Bump version to 1.0.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:26:29 -08:00
6f3823eccf Add PWA, wake lock, and fullscreen to relay app
- Add wake lock (keep screen awake) functionality
- Add fullscreen toggle button
- Add dynamic PWA manifest generation
- Add favicon and icons for all relay pages
- Copy icons from main web folder

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:04:03 -08:00
4a93f94b8c Fix relay URL update using Qt signals for thread safety
QTimer.singleShot doesn't work properly from non-Qt threads.
Use Qt signals instead which are thread-safe and properly marshal
calls to the main thread.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:00:30 -08:00
f87dab6bc2 Add debug output for relay URL update issue
- Always trigger on_session_id callback when server returns session ID
- Add debug prints to trace URL update flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:56:30 -08:00
5b6eb33bad Fix image authentication in relay app.html
Add password query parameter to image URLs in the relay server's
app.html - this file has its own inline JavaScript separate from
the main web/js/app.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:51:37 -08:00
7d95d47c73 Update relay server defaults and repo links
- Update index.html links to repo.anhonesthost.net
- Adjust rate limit defaults in .env.example

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:47:32 -08:00
59254383ad Fix relay server issues: images and URL display
1. Images not showing through relay:
   - Use getApiUrl() for image paths in relay mode
   - Add password as query param for img tags (can't use headers)

2. URL not updating in desktop app:
   - Set _connected=True before on_session_id callback fires
   - Ensures update_ip_label() shows relay URL immediately

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:42:45 -08:00
5aed19564c Add index landing page for relay server
- Add index.html explaining what MacroPad Relay is
- Add /ping endpoint for container health checks
- Add route for index page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:33:03 -08:00
6e76d469c8 Add .env.example for relay server configuration
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:27:06 -08:00
ff3c7b990c Fix relay server for cloud-node-container deployment
- Add postinstall script to build TypeScript automatically
- Move typescript to dependencies (needed during npm install)
- Update main and start to point to dist/index.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:23:31 -08:00
1d7f18018d Add relay server for remote HTTPS access
Node.js/TypeScript relay server that enables remote access to MacroPad:
- WebSocket-based communication between desktop and relay
- Password authentication with bcrypt hashing
- Session management with consistent IDs
- REST API proxying to desktop app
- Web client WebSocket relay
- Login page and PWA-ready app page
- Designed for cloud-node-container deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:46:33 -08:00
8e4c32fea4 Add v0.9.5 features: minimize to tray, settings, relay support
## New Features

### Minimize to Tray
- Window minimizes to system tray instead of taskbar
- Tray notification shown when minimized
- Double-click tray icon to restore

### Settings System
- New settings dialog (Edit > Settings or Ctrl+,)
- JSON-based settings persistence
- General tab: minimize to tray toggle
- Relay Server tab: enable/configure relay connection

### Relay Server Support
- New relay_client.py for connecting to relay server
- WebSocket client with auto-reconnection
- Forwards API requests to local server
- Updates QR code/URL when relay connected

### PWA Updates
- Added relay mode detection and authentication
- Password passed via header for API requests
- WebSocket authentication for relay connections
- Desktop status handling (connected/disconnected)
- Wake lock icon now always visible with status indicator

## Files Added
- gui/settings_manager.py
- gui/settings_dialog.py
- relay_client.py

## Dependencies
- Added aiohttp>=3.9.0 for relay client

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:33:07 -08:00
31 changed files with 3522 additions and 32 deletions

View File

@@ -20,6 +20,7 @@ A cross-platform macro management application with desktop and web interfaces. C
- **System Tray**: Minimize to tray, always accessible
### Additional Features
- **Relay Server Support**: Access your macros securely over HTTPS from anywhere
- **QR Code Generation**: Quickly connect mobile devices
- **Real-time Sync**: WebSocket updates across all connected devices
- **Offline Support**: PWA caches for offline macro viewing
@@ -39,6 +40,7 @@ A cross-platform macro management application with desktop and web interfaces. C
- pystray (System tray)
- netifaces (Network detection)
- qrcode (QR code generation)
- aiohttp (Relay server client)
## Installation
@@ -136,6 +138,24 @@ The web interface provides full macro management:
- **Show**: Restore window
- **Quit**: Exit application
### Relay Server (Remote Access)
Access your macros from outside your local network using a relay server:
1. Click **Settings** (gear icon) in the toolbar
2. Check **Enable Relay Server**
3. Enter your relay server URL and password
4. Click **Save**
Once connected, a relay URL will appear in the toolbar. Use this URL from any device with internet access. The relay provides:
- Secure HTTPS connection
- Full macro execution and management
- PWA installation support
- Wake lock and fullscreen mode
> [!NOTE]
> You need access to a relay server. See [MP-Relay](https://repo.anhonesthost.net/MacroPad/MP-Relay) for self-hosting instructions.
## Command Types Reference
| Type | Description | Parameters |
@@ -213,6 +233,7 @@ MP-Server/
├── config.py # Configuration constants
├── macro_manager.py # Macro storage and execution
├── web_server.py # FastAPI web server
├── relay_client.py # Relay server WebSocket client
├── pyproject.toml # Dependencies and build config
├── gui/ # PySide6 desktop interface
│ ├── main_window.py

View File

@@ -1,7 +1,8 @@
# Configuration and constants for MacroPad Server
VERSION = "0.9.3"
VERSION = "1.0.0"
DEFAULT_PORT = 40000
SETTINGS_FILE = "settings.json"
# UI Theme colors
THEME = {

View File

@@ -5,6 +5,10 @@ import sys
import threading
from typing import Optional
# Windows startup management
if sys.platform == 'win32':
import winreg
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
@@ -20,12 +24,13 @@ from PySide6.QtWidgets import (
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize
from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
from config import VERSION, THEME, DEFAULT_PORT
from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE
from macro_manager import MacroManager
from web_server import WebServer
from .settings_manager import SettingsManager
class MacroButton(QPushButton):
@@ -35,7 +40,7 @@ class MacroButton(QPushButton):
edit_requested = Signal(str)
delete_requested = Signal(str)
def __init__(self, macro_id: str, macro: dict, parent=None):
def __init__(self, macro_id: str, macro: dict, app_dir: str, parent=None):
super().__init__(parent)
self.macro_id = macro_id
self.macro = macro
@@ -70,7 +75,9 @@ class MacroButton(QPushButton):
image_label.setAlignment(Qt.AlignCenter)
if macro.get("image_path"):
pixmap = QPixmap(macro["image_path"])
# Resolve relative image path against app directory
image_path = os.path.join(app_dir, macro["image_path"])
pixmap = QPixmap(image_path)
if not pixmap.isNull():
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
image_label.setPixmap(pixmap)
@@ -116,6 +123,10 @@ class MainWindow(QMainWindow):
"""Main application window."""
macros_changed = Signal()
# Signals for thread-safe relay status updates
relay_session_received = Signal(str)
relay_connected_signal = Signal()
relay_disconnected_signal = Signal()
def __init__(self, app_dir: str):
super().__init__()
@@ -123,6 +134,10 @@ class MainWindow(QMainWindow):
self.current_tab = "All"
self.sort_by = "name"
# Initialize settings manager
settings_file = os.path.join(app_dir, SETTINGS_FILE)
self.settings_manager = SettingsManager(settings_file)
# Initialize macro manager
data_file = os.path.join(app_dir, "macros.json")
images_dir = os.path.join(app_dir, "macro_images")
@@ -134,6 +149,9 @@ class MainWindow(QMainWindow):
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
self.server_thread = None
# Relay client (initialized later if enabled)
self.relay_client = None
# Setup UI
self.setup_ui()
self.setup_menu()
@@ -142,8 +160,15 @@ class MainWindow(QMainWindow):
# Start web server
self.start_server()
# Start relay client if enabled
if self.settings_manager.get_relay_enabled():
self.start_relay_client()
# Connect signals
self.macros_changed.connect(self.refresh_macros)
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))
# Load initial data
self.refresh_tabs()
@@ -151,7 +176,7 @@ class MainWindow(QMainWindow):
def setup_ui(self):
"""Setup the main UI components."""
self.setWindowTitle(f"MacroPad Server v{VERSION}")
self.setWindowTitle("MacroPad Server")
self.setMinimumSize(600, 400)
self.setStyleSheet(f"background-color: {THEME['bg_color']};")
@@ -187,9 +212,18 @@ class MainWindow(QMainWindow):
toolbar_layout.addStretch()
# IP address label
self.ip_label = QLabel()
self.ip_label.setStyleSheet(f"color: {THEME['fg_color']};")
# Clickable IP address label (click to copy)
self.ip_label = QPushButton()
self.ip_label.setCursor(Qt.PointingHandCursor)
self.ip_label.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
}}
""")
self.ip_label.clicked.connect(self.copy_url_to_clipboard)
self.update_ip_label()
toolbar_layout.addWidget(self.ip_label)
@@ -275,11 +309,28 @@ class MainWindow(QMainWindow):
file_menu.addSeparator()
# Windows startup option (only on Windows)
if sys.platform == 'win32':
self.startup_action = QAction("Start on Windows Startup", self)
self.startup_action.setCheckable(True)
self.startup_action.setChecked(self.get_startup_enabled())
self.startup_action.triggered.connect(self.toggle_startup)
file_menu.addAction(self.startup_action)
file_menu.addSeparator()
quit_action = QAction("Quit", self)
quit_action.setShortcut("Ctrl+Q")
quit_action.triggered.connect(self.close)
file_menu.addAction(quit_action)
# Edit menu
edit_menu = menubar.addMenu("Edit")
settings_action = QAction("Settings...", self)
settings_action.setShortcut("Ctrl+,")
settings_action.triggered.connect(self.show_settings)
edit_menu.addAction(settings_action)
# View menu
view_menu = menubar.addMenu("View")
@@ -318,7 +369,7 @@ class MainWindow(QMainWindow):
# Tray menu
tray_menu = QMenu()
show_action = tray_menu.addAction("Show")
show_action.triggered.connect(self.show)
show_action.triggered.connect(self.restore_window)
quit_action = tray_menu.addAction("Quit")
quit_action.triggered.connect(self.close)
@@ -329,8 +380,15 @@ class MainWindow(QMainWindow):
def on_tray_activated(self, reason):
"""Handle tray icon activation."""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self.show()
self.activateWindow()
self.restore_window()
def restore_window(self):
"""Restore and bring window to front."""
# Clear minimized state and show
self.setWindowState(Qt.WindowNoState)
self.show()
self.raise_()
self.activateWindow()
def start_server(self):
"""Start the web server in a background thread."""
@@ -379,6 +437,20 @@ class MainWindow(QMainWindow):
def update_ip_label(self):
"""Update the IP address label."""
# Check if relay is connected and has a session ID
relay_connected = self.relay_client and self.relay_client.is_connected()
session_id = self.settings_manager.get_relay_session_id()
if relay_connected and session_id:
relay_url = self.settings_manager.get_relay_url()
# Convert wss:// to https:// for display
base_url = relay_url.replace('wss://', 'https://').replace('ws://', 'http://')
base_url = base_url.replace('/desktop', '').rstrip('/')
full_url = f"{base_url}/{session_id}"
self.ip_label.setText(full_url)
return
# Fall back to local IP
try:
import netifaces
for iface in netifaces.interfaces():
@@ -393,6 +465,40 @@ class MainWindow(QMainWindow):
pass
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}")
def copy_url_to_clipboard(self):
"""Copy the web interface URL to clipboard."""
url = self.ip_label.text()
if url == "Copied!":
return # Already showing copied feedback
clipboard = QApplication.clipboard()
clipboard.setText(url)
# Show "Copied!" feedback with dimmed style
self.ip_label.setText("Copied!")
self.ip_label.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #888888;
border: none;
padding: 4px 8px;
}
""")
# Restore original URL after delay
QTimer.singleShot(1500, self._restore_url_label)
def _restore_url_label(self):
"""Restore URL label after copy feedback."""
self.ip_label.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
}}
""")
self.update_ip_label()
def refresh_tabs(self):
"""Refresh the tab widget."""
self.tab_widget.blockSignals(True)
@@ -443,7 +549,7 @@ class MainWindow(QMainWindow):
# Add macro buttons
cols = max(1, (self.width() - 40) // 130)
for i, (macro_id, macro) in enumerate(filtered):
btn = MacroButton(macro_id, macro)
btn = MacroButton(macro_id, macro, self.app_dir)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
btn.edit_requested.connect(self.edit_macro)
btn.delete_requested.connect(self.delete_macro)
@@ -555,8 +661,153 @@ class MainWindow(QMainWindow):
about_box.setStandardButtons(QMessageBox.Ok)
about_box.exec()
def show_settings(self):
"""Show settings dialog."""
from .settings_dialog import SettingsDialog
dialog = SettingsDialog(self.settings_manager, parent=self)
dialog.relay_settings_changed.connect(self.on_relay_settings_changed)
dialog.exec_()
def on_relay_settings_changed(self):
"""Handle relay settings changes."""
# Stop existing relay client if running
self.stop_relay_client()
# Start new relay client if enabled
if self.settings_manager.get_relay_enabled():
self.start_relay_client()
# Update IP label to show relay URL if connected
self.update_ip_label()
def start_relay_client(self):
"""Start the relay client in a background thread."""
try:
from relay_client import RelayClient
except ImportError:
self.status_bar.showMessage("Relay client not available", 3000)
return
url = self.settings_manager.get_relay_url()
password = self.settings_manager.get_relay_password()
session_id = self.settings_manager.get_relay_session_id()
if not url or not password:
self.status_bar.showMessage("Relay not configured", 3000)
return
self.relay_client = RelayClient(
relay_url=url,
password=password,
session_id=session_id,
local_port=DEFAULT_PORT,
on_connected=self.on_relay_connected,
on_disconnected=self.on_relay_disconnected,
on_session_id=self.on_relay_session_id
)
self.relay_client.start()
self.status_bar.showMessage("Connecting to relay server...")
def stop_relay_client(self):
"""Stop the relay client."""
if self.relay_client:
self.relay_client.stop()
self.relay_client = None
self.status_bar.showMessage("Relay disconnected", 2000)
def on_relay_connected(self):
"""Handle relay connection established (called from background thread)."""
self.relay_connected_signal.emit()
def on_relay_disconnected(self):
"""Handle relay disconnection (called from background thread)."""
self.relay_disconnected_signal.emit()
def on_relay_session_id(self, session_id: str):
"""Handle receiving session ID from relay (called from background thread)."""
self.relay_session_received.emit(session_id)
def _handle_relay_session(self, session_id: str):
"""Handle relay session on main thread."""
self.settings_manager.set_relay_session_id(session_id)
self.update_ip_label()
def _update_relay_status(self, connected: bool):
"""Update UI for relay status (called on main thread)."""
if connected:
self.status_bar.showMessage("Connected to relay server")
else:
self.status_bar.showMessage("Relay disconnected - reconnecting...")
self.update_ip_label()
# Windows startup management
def get_startup_enabled(self) -> bool:
"""Check if app is set to start on Windows startup."""
if sys.platform != 'win32':
return False
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_READ
)
try:
winreg.QueryValueEx(key, "MacroPad Server")
return True
except FileNotFoundError:
return False
finally:
winreg.CloseKey(key)
except Exception:
return False
def set_startup_enabled(self, enabled: bool):
"""Enable or disable starting on Windows startup."""
if sys.platform != 'win32':
return
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0, winreg.KEY_SET_VALUE
)
try:
if enabled:
# Get the executable path
if getattr(sys, 'frozen', False):
# Running as compiled executable
exe_path = sys.executable
else:
# Running as script - use pythonw to avoid console
exe_path = f'"{sys.executable}" "{os.path.abspath(sys.argv[0])}"'
winreg.SetValueEx(key, "MacroPad Server", 0, winreg.REG_SZ, exe_path)
self.status_bar.showMessage("Added to Windows startup", 3000)
else:
try:
winreg.DeleteValue(key, "MacroPad Server")
self.status_bar.showMessage("Removed from Windows startup", 3000)
except FileNotFoundError:
pass
finally:
winreg.CloseKey(key)
except Exception as e:
QMessageBox.warning(self, "Error", f"Failed to update startup settings: {e}")
def toggle_startup(self):
"""Toggle the startup setting."""
current = self.get_startup_enabled()
self.set_startup_enabled(not current)
# Update menu checkmark
if hasattr(self, 'startup_action'):
self.startup_action.setChecked(not current)
def closeEvent(self, event):
"""Handle window close."""
# Stop the relay client
self.stop_relay_client()
# Stop the web server
self.stop_server()
@@ -569,3 +820,19 @@ class MainWindow(QMainWindow):
"""Handle window resize."""
super().resizeEvent(event)
self.refresh_macros()
def changeEvent(self, event):
"""Handle window state changes - minimize to tray."""
if event.type() == QEvent.Type.WindowStateChange:
if self.windowState() & Qt.WindowMinimized:
# Hide instead of minimize (goes to tray)
event.ignore()
self.hide()
self.tray_icon.showMessage(
"MacroPad Server",
"Running in system tray. Double-click to restore.",
QSystemTrayIcon.Information,
2000
)
return
super().changeEvent(event)

344
gui/settings_dialog.py Normal file
View File

@@ -0,0 +1,344 @@
# Settings Dialog for MacroPad Server
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget,
QWidget, QLabel, QLineEdit, QCheckBox, QPushButton,
QGroupBox, QFormLayout, QMessageBox
)
from PySide6.QtCore import Qt, Signal
from config import THEME
class SettingsDialog(QDialog):
"""Settings dialog for application preferences."""
relay_settings_changed = Signal()
def __init__(self, settings_manager, parent=None):
super().__init__(parent)
self.settings_manager = settings_manager
self.setup_ui()
self.load_settings()
def setup_ui(self):
"""Setup the dialog UI."""
self.setWindowTitle("Settings")
self.setMinimumSize(500, 400)
self.setStyleSheet(f"""
QDialog {{
background-color: {THEME['bg_color']};
color: {THEME['fg_color']};
}}
QTabWidget::pane {{
border: 1px solid {THEME['highlight_color']};
background: {THEME['bg_color']};
}}
QTabBar::tab {{
background: {THEME['tab_bg']};
color: {THEME['fg_color']};
padding: 8px 16px;
margin-right: 2px;
}}
QTabBar::tab:selected {{
background: {THEME['tab_selected']};
}}
QGroupBox {{
border: 1px solid {THEME['highlight_color']};
border-radius: 4px;
margin-top: 12px;
padding-top: 8px;
color: {THEME['fg_color']};
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
}}
QLabel {{
color: {THEME['fg_color']};
}}
QLineEdit {{
background-color: {THEME['highlight_color']};
color: {THEME['fg_color']};
border: 1px solid {THEME['button_bg']};
border-radius: 4px;
padding: 6px;
}}
QLineEdit:focus {{
border-color: {THEME['accent_color']};
}}
QLineEdit:read-only {{
background-color: {THEME['bg_color']};
}}
QCheckBox {{
color: {THEME['fg_color']};
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
}}
QPushButton {{
background-color: {THEME['button_bg']};
color: {THEME['fg_color']};
border: none;
padding: 8px 16px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {THEME['highlight_color']};
}}
QPushButton:pressed {{
background-color: {THEME['accent_color']};
}}
""")
layout = QVBoxLayout(self)
# Tab widget
self.tab_widget = QTabWidget()
# General tab
general_tab = self.create_general_tab()
self.tab_widget.addTab(general_tab, "General")
# Relay Server tab
relay_tab = self.create_relay_tab()
self.tab_widget.addTab(relay_tab, "Relay Server")
layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setStyleSheet(f"""
QPushButton {{
background-color: {THEME['accent_color']};
color: white;
}}
QPushButton:hover {{
background-color: #0096ff;
}}
""")
save_btn.clicked.connect(self.save_settings)
button_layout.addWidget(save_btn)
layout.addLayout(button_layout)
def create_general_tab(self) -> QWidget:
"""Create the general settings tab."""
tab = QWidget()
layout = QVBoxLayout(tab)
# Behavior group
behavior_group = QGroupBox("Behavior")
behavior_layout = QVBoxLayout(behavior_group)
self.minimize_to_tray_cb = QCheckBox("Minimize to system tray")
self.minimize_to_tray_cb.setToolTip(
"When enabled, minimizing the window will hide it to the system tray\n"
"instead of the taskbar. Double-click the tray icon to restore."
)
behavior_layout.addWidget(self.minimize_to_tray_cb)
layout.addWidget(behavior_group)
layout.addStretch()
return tab
def create_relay_tab(self) -> QWidget:
"""Create the relay server settings tab."""
tab = QWidget()
layout = QVBoxLayout(tab)
# Info label
info_label = QLabel(
"The relay server allows you to access MacroPad from anywhere\n"
"via a secure HTTPS connection. Your macros will be accessible\n"
"through a unique URL that you can share with your devices."
)
info_label.setWordWrap(True)
info_label.setStyleSheet(f"color: #aaa; margin-bottom: 10px;")
layout.addWidget(info_label)
# Connection group
connection_group = QGroupBox("Connection")
connection_layout = QFormLayout(connection_group)
self.relay_enabled_cb = QCheckBox("Enable relay server")
self.relay_enabled_cb.stateChanged.connect(self.on_relay_enabled_changed)
connection_layout.addRow("", self.relay_enabled_cb)
self.relay_url_edit = QLineEdit()
self.relay_url_edit.setPlaceholderText("wss://relay.example.com")
connection_layout.addRow("Server URL:", self.relay_url_edit)
layout.addWidget(connection_group)
# Authentication group
auth_group = QGroupBox("Authentication")
auth_layout = QFormLayout(auth_group)
self.relay_password_edit = QLineEdit()
self.relay_password_edit.setEchoMode(QLineEdit.Password)
self.relay_password_edit.setPlaceholderText("Required - minimum 8 characters")
auth_layout.addRow("Password:", self.relay_password_edit)
# Show password toggle
show_password_cb = QCheckBox("Show password")
show_password_cb.stateChanged.connect(
lambda state: self.relay_password_edit.setEchoMode(
QLineEdit.Normal if state else QLineEdit.Password
)
)
auth_layout.addRow("", show_password_cb)
layout.addWidget(auth_group)
# Status group
status_group = QGroupBox("Status")
status_layout = QFormLayout(status_group)
self.relay_session_id_edit = QLineEdit()
self.relay_session_id_edit.setReadOnly(True)
self.relay_session_id_edit.setPlaceholderText("Not connected")
status_layout.addRow("Session ID:", self.relay_session_id_edit)
self.relay_full_url_edit = QLineEdit()
self.relay_full_url_edit.setReadOnly(True)
self.relay_full_url_edit.setPlaceholderText("Connect to see your URL")
status_layout.addRow("Your URL:", self.relay_full_url_edit)
# Regenerate button
regen_btn = QPushButton("Generate New URL")
regen_btn.setToolTip("Generate a new unique URL (invalidates the old one)")
regen_btn.clicked.connect(self.regenerate_session_id)
status_layout.addRow("", regen_btn)
layout.addWidget(status_group)
layout.addStretch()
return tab
def load_settings(self):
"""Load current settings into the UI."""
# General
self.minimize_to_tray_cb.setChecked(
self.settings_manager.get_minimize_to_tray()
)
# Relay
self.relay_enabled_cb.setChecked(
self.settings_manager.get_relay_enabled()
)
self.relay_url_edit.setText(
self.settings_manager.get_relay_url()
)
self.relay_password_edit.setText(
self.settings_manager.get_relay_password()
)
session_id = self.settings_manager.get_relay_session_id()
if session_id:
self.relay_session_id_edit.setText(session_id)
base_url = self.relay_url_edit.text().replace('wss://', 'https://').replace('ws://', 'http://')
base_url = base_url.replace('/desktop', '')
self.relay_full_url_edit.setText(f"{base_url}/{session_id}")
self.on_relay_enabled_changed()
def on_relay_enabled_changed(self):
"""Handle relay enabled checkbox change."""
enabled = self.relay_enabled_cb.isChecked()
self.relay_url_edit.setEnabled(enabled)
self.relay_password_edit.setEnabled(enabled)
def validate_settings(self) -> bool:
"""Validate settings before saving."""
if self.relay_enabled_cb.isChecked():
url = self.relay_url_edit.text().strip()
password = self.relay_password_edit.text()
if not url:
QMessageBox.warning(
self, "Validation Error",
"Relay server URL is required when relay is enabled."
)
return False
if not url.startswith(('ws://', 'wss://')):
QMessageBox.warning(
self, "Validation Error",
"Relay server URL must start with ws:// or wss://"
)
return False
if len(password) < 8:
QMessageBox.warning(
self, "Validation Error",
"Password must be at least 8 characters."
)
return False
return True
def save_settings(self):
"""Save settings and close dialog."""
if not self.validate_settings():
return
# General
self.settings_manager.set(
'minimize_to_tray',
self.minimize_to_tray_cb.isChecked()
)
# Relay - check if settings changed
old_enabled = self.settings_manager.get_relay_enabled()
old_url = self.settings_manager.get_relay_url()
old_password = self.settings_manager.get_relay_password()
new_enabled = self.relay_enabled_cb.isChecked()
new_url = self.relay_url_edit.text().strip()
new_password = self.relay_password_edit.text()
relay_changed = (
old_enabled != new_enabled or
old_url != new_url or
old_password != new_password
)
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()
if relay_changed:
self.relay_settings_changed.emit()
self.accept()
def regenerate_session_id(self):
"""Clear session ID to force regeneration on next connect."""
reply = QMessageBox.question(
self, "Regenerate URL",
"This will generate a new URL. The old URL will stop working.\n\n"
"Are you sure you want to continue?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.settings_manager.set('relay.session_id', None)
self.relay_session_id_edit.setText("")
self.relay_full_url_edit.setText("")
self.settings_manager.save()
QMessageBox.information(
self, "URL Regenerated",
"A new URL will be generated when you reconnect to the relay server."
)

106
gui/settings_manager.py Normal file
View File

@@ -0,0 +1,106 @@
# Settings Manager for MacroPad Server
import os
import json
from typing import Any, Optional
DEFAULT_SETTINGS = {
"relay": {
"enabled": False,
"server_url": "wss://relay.macropad.example.com",
"session_id": None,
"password": ""
},
"minimize_to_tray": True
}
class SettingsManager:
"""Manages application settings with JSON persistence."""
def __init__(self, settings_file: str):
self.settings_file = settings_file
self.settings = {}
self.load()
def load(self):
"""Load settings from file."""
if os.path.exists(self.settings_file):
try:
with open(self.settings_file, 'r', encoding='utf-8') as f:
self.settings = json.load(f)
except (json.JSONDecodeError, IOError):
self.settings = {}
# Merge with defaults to ensure all keys exist
self.settings = self._merge_defaults(DEFAULT_SETTINGS, self.settings)
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)
return True
except IOError:
return False
def _merge_defaults(self, defaults: dict, current: dict) -> dict:
"""Merge current settings with defaults, keeping current values."""
result = defaults.copy()
for key, value in current.items():
if key in result:
if isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._merge_defaults(result[key], value)
else:
result[key] = value
else:
result[key] = value
return result
def get(self, key: str, default: Any = None) -> Any:
"""Get a setting value by key (supports dot notation)."""
keys = key.split('.')
value = self.settings
try:
for k in keys:
value = value[k]
return value
except (KeyError, TypeError):
return default
def set(self, key: str, value: Any):
"""Set a setting value by key (supports dot notation)."""
keys = key.split('.')
target = self.settings
for k in keys[:-1]:
if k not in target:
target[k] = {}
target = target[k]
target[keys[-1]] = value
def get_relay_enabled(self) -> bool:
"""Check if relay server is enabled."""
return self.get('relay.enabled', False)
def get_relay_url(self) -> str:
"""Get the relay server URL."""
return self.get('relay.server_url', '')
def get_relay_session_id(self) -> Optional[str]:
"""Get the stored relay session ID."""
return self.get('relay.session_id')
def get_relay_password(self) -> str:
"""Get the relay password."""
return self.get('relay.password', '')
def set_relay_session_id(self, session_id: str):
"""Store the relay session ID."""
self.set('relay.session_id', session_id)
self.save()
def get_minimize_to_tray(self) -> bool:
"""Check if minimize to tray is enabled."""
return self.get('minimize_to_tray', True)

View File

@@ -0,0 +1,27 @@
# MacroPad Relay Server Configuration
# Copy this file to .env and adjust values as needed
# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
# Security
BCRYPT_ROUNDS=10
# Session ID length (default: 6 characters)
SESSION_ID_LENGTH=6
# Rate limiting
RATE_LIMIT_WINDOW_MS=90
RATE_LIMIT_MAX=1024
# WebSocket timeouts (in milliseconds)
PING_INTERVAL=30000
REQUEST_TIMEOUT=30000
# Logging (error, warn, info, debug)
LOG_LEVEL=info
# Data directory (default: ./data)
# DATA_DIR=/path/to/data

26
macropad-relay/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# Data
data/sessions.json
# Environment
.env
# Logs
*.log
error.log
combined.log
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

124
macropad-relay/DEPLOY.md Normal file
View File

@@ -0,0 +1,124 @@
# MacroPad Relay Server - Deployment Guide
## Cloud Node Container Deployment
For AnHonestHost cloud-node-container deployment:
### 1. Build Locally
```bash
cd /home/jknapp/code/macropad/macropad-relay
npm install
npm run build
```
### 2. Prepare Deployment Package
The build outputs to `dist/` with public files copied. Upload:
```bash
# Upload built files to your node container app directory
rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' \
dist/ package.json public/ \
user@YOUR_SERVER:/path/to/app/
```
### 3. On Server
The cloud-node-container will automatically:
- Install dependencies from package.json
- Start the app using PM2
- Configure the process from package.json settings
### 4. Create Data Directory
```bash
mkdir -p /path/to/app/data
```
## Directory Structure on Server
```
app/
├── index.js # Main entry (compiled)
├── config.js
├── server.js
├── services/
├── handlers/
├── utils/
├── public/
│ ├── login.html
│ └── app.html
├── data/
│ └── sessions.json # Created automatically
└── package.json
```
## Update After Code Changes
```bash
# On local machine:
cd /home/jknapp/code/macropad/macropad-relay
npm run build
rsync -avz --exclude 'node_modules' --exclude 'src' --exclude '.git' --exclude 'data' \
dist/ package.json public/ \
user@YOUR_SERVER:/path/to/app/
# On server - restart via your container's control panel or:
pm2 restart macropad-relay
```
## Environment Variables
Set these in your container configuration:
- `PORT` - Server port (default: 3000)
- `DATA_DIR` - Data storage path (default: ./data)
- `NODE_ENV` - production or development
- `LOG_LEVEL` - info, debug, error
## Test It Works
```bash
# Test health endpoint
curl http://localhost:3000/health
# Should return:
# {"status":"ok","desktopConnections":0,"webClients":0,"sessions":[]}
```
## Nginx/Reverse Proxy (for HTTPS)
```nginx
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeout (24 hours)
proxy_read_timeout 86400;
}
```
## Troubleshooting
**Check logs:**
```bash
pm2 logs macropad-relay
```
**Check sessions:**
```bash
cat /path/to/app/data/sessions.json
```
**Port in use:**
```bash
lsof -i :3000
```

View File

@@ -0,0 +1,37 @@
{
"name": "macropad-relay",
"version": "1.0.0",
"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",
"clean": "rm -rf dist"
},
"keywords": ["macropad", "relay", "websocket", "proxy"],
"author": "ShadowDao",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"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"
},
"devDependencies": {
"@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"
}
}

View File

@@ -0,0 +1,621 @@
<!DOCTYPE html>
<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="description" content="Remote macro control for your desktop">
<!-- PWA / iOS specific -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MacroPad">
<title>MacroPad</title>
<!-- PWA manifest will be dynamically set -->
<link rel="manifest" id="manifest-link">
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
:root {
--bg-color: #2e2e2e;
--fg-color: #ffffff;
--highlight-color: #3e3e3e;
--accent-color: #007acc;
--button-bg: #505050;
--button-hover: #606060;
--tab-bg: #404040;
--tab-selected: #007acc;
--danger-color: #dc3545;
--success-color: #28a745;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--fg-color);
min-height: 100vh;
min-height: 100dvh;
}
.header {
background-color: var(--highlight-color);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.header h1 { font-size: 1.5rem; }
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #aaa;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger-color);
}
.status-dot.connected { background: var(--success-color); }
.header-btn {
background: var(--button-bg);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.header-btn:hover { background: var(--button-hover); }
.header-btn.icon-btn {
padding: 0.5rem 0.75rem;
font-size: 1.2rem;
}
.wake-lock-status {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.wake-lock-status:hover { background: var(--button-bg); }
.wake-lock-status.active .wake-icon { color: var(--success-color); }
.wake-lock-status.unsupported { opacity: 0.3; cursor: default; }
.wake-lock-status.unsupported .wake-icon { color: #888; text-decoration: line-through; }
.wake-icon {
font-size: 1.2rem;
color: #888;
transition: color 0.2s;
}
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
overflow-x: auto;
}
.tab {
background: var(--tab-bg);
color: var(--fg-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.tab:hover { background: var(--button-hover); }
.tab.active { background: var(--tab-selected); }
.macro-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
padding: 1rem;
}
.macro-card {
background: var(--button-bg);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.1s, background 0.2s;
min-height: 120px;
}
.macro-card:hover { background: var(--button-hover); transform: translateY(-2px); }
.macro-card:active { transform: translateY(0); }
.macro-card.executing {
animation: pulse 0.3s ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(0.95); background: var(--accent-color); }
100% { transform: scale(1); }
}
.macro-image {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.macro-image-placeholder {
width: 64px;
height: 64px;
background: var(--highlight-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
.macro-name {
text-align: center;
font-size: 0.9rem;
word-break: break-word;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #888;
grid-column: 1 / -1;
}
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 300;
}
.toast {
background: var(--highlight-color);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 0.5rem;
animation: slideIn 0.3s ease-out;
}
.toast.success { border-left: 4px solid var(--success-color); }
.toast.error { border-left: 4px solid var(--danger-color); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.loading {
display: flex;
justify-content: center;
padding: 2rem;
grid-column: 1 / -1;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--button-bg);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.offline-banner {
background: var(--danger-color);
color: white;
text-align: center;
padding: 0.5rem;
display: none;
}
.offline-banner.visible { display: block; }
</style>
</head>
<body>
<div class="offline-banner" id="offline-banner">
Desktop is offline - waiting for reconnection...
</div>
<header class="header">
<h1>MacroPad</h1>
<div class="header-actions">
<div class="connection-status">
<div class="status-dot"></div>
<span>Connecting...</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" onclick="app.toggleFullscreen()" title="Toggle fullscreen"></button>
<button class="header-btn" onclick="app.refresh()">Refresh</button>
</div>
</header>
<nav class="tabs" id="tabs-container"></nav>
<main class="macro-grid" id="macro-grid">
<div class="loading"><div class="spinner"></div></div>
</main>
<div class="toast-container" id="toast-container"></div>
<script>
// Inline MacroPad App for Relay Mode
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.desktopConnected = false;
this.wsAuthenticated = false;
// Get session ID from URL
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
this.sessionId = pathMatch ? pathMatch[1] : null;
// Get password from URL or sessionStorage
const urlParams = new URLSearchParams(window.location.search);
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
if (this.password) {
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
if (urlParams.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
this.init();
}
async init() {
this.wakeLock = null;
this.wakeLockEnabled = false;
this.setupPWA();
this.setupWebSocket();
this.setupEventListeners();
this.setupWakeLock();
}
getApiHeaders() {
return {
'Content-Type': 'application/json',
'X-MacroPad-Password': this.password || ''
};
}
async loadTabs() {
try {
const response = await fetch(`/${this.sessionId}/api/tabs`, {
headers: this.getApiHeaders()
});
if (response.status === 401) return this.handleAuthError();
if (response.status === 503) return this.handleDesktopOffline();
const data = await response.json();
this.tabs = data.tabs || [];
this.renderTabs();
} catch (error) {
console.error('Error loading tabs:', error);
}
}
async loadMacros() {
try {
const path = this.currentTab === 'All' ? '/api/macros' : `/api/macros/${encodeURIComponent(this.currentTab)}`;
const response = await fetch(`/${this.sessionId}${path}`, {
headers: this.getApiHeaders()
});
if (response.status === 401) return this.handleAuthError();
if (response.status === 503) return this.handleDesktopOffline();
const data = await response.json();
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
console.error('Error loading macros:', error);
}
}
async executeMacro(macroId) {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
try {
const response = await fetch(`/${this.sessionId}/api/execute`, {
method: 'POST',
headers: this.getApiHeaders(),
body: JSON.stringify({ macro_id: macroId })
});
if (!response.ok) throw new Error('Failed');
} catch (error) {
this.showToast('Execution failed', 'error');
}
setTimeout(() => card?.classList.remove('executing'), 300);
}
handleAuthError() {
sessionStorage.removeItem(`macropad_${this.sessionId}`);
window.location.href = `/${this.sessionId}`;
}
handleDesktopOffline() {
this.desktopConnected = false;
this.updateConnectionStatus(false);
document.getElementById('offline-banner').classList.add('visible');
}
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onclose = () => {
this.wsAuthenticated = false;
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
};
this.ws.onerror = () => this.updateConnectionStatus(false);
}
handleMessage(data) {
switch (data.type) {
case 'auth_required':
if (this.password) {
this.ws.send(JSON.stringify({ type: 'auth', password: this.password }));
}
break;
case 'auth_response':
if (data.success) {
this.wsAuthenticated = true;
this.updateConnectionStatus(this.desktopConnected);
} else {
this.handleAuthError();
}
break;
case 'desktop_status':
this.desktopConnected = data.status === 'connected';
this.updateConnectionStatus(this.desktopConnected);
document.getElementById('offline-banner').classList.toggle('visible', !this.desktopConnected);
if (this.desktopConnected) {
this.loadTabs();
this.loadMacros();
}
break;
case 'macro_created':
case 'macro_updated':
case 'macro_deleted':
this.loadTabs();
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
if (card) {
card.classList.add('executing');
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
}
}
updateConnectionStatus(connected) {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.connection-status span');
if (dot) dot.classList.toggle('connected', connected);
if (text) text.textContent = connected ? 'Connected' : 'Disconnected';
}
renderTabs() {
const container = document.getElementById('tabs-container');
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}" data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
const entries = Object.entries(this.macros);
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No macros found</p></div>';
return;
}
container.innerHTML = entries.map(([id, macro]) => {
// Include password as query param for image authentication
const imageSrc = macro.image_path
? `/${this.sessionId}/api/image/${macro.image_path}?password=${encodeURIComponent(this.password)}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
${imageSrc ? `<img src="${imageSrc}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` : ''}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>${firstChar}</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
}
setupEventListeners() {
document.getElementById('tabs-container').addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
refresh() {
this.loadTabs();
this.loadMacros();
}
// Fullscreen
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.log('Fullscreen error:', err);
});
} else {
document.exitFullscreen();
}
}
// Wake Lock
async setupWakeLock() {
const status = document.getElementById('wake-lock-status');
if (!('wakeLock' in navigator)) {
console.log('Wake Lock API not supported');
if (status) {
status.classList.add('unsupported');
status.title = 'Wake lock not available (requires HTTPS)';
}
return;
}
if (status) {
status.style.cursor = 'pointer';
status.addEventListener('click', () => this.toggleWakeLock());
}
await this.requestWakeLock();
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
await this.requestWakeLock();
}
});
}
async toggleWakeLock() {
if (this.wakeLock) {
await this.wakeLock.release();
this.wakeLock = null;
this.wakeLockEnabled = false;
this.updateWakeLockStatus(false);
this.showToast('Screen can now sleep', 'info');
} else {
this.wakeLockEnabled = true;
await this.requestWakeLock();
if (this.wakeLock) {
this.showToast('Screen will stay awake', 'success');
}
}
}
async requestWakeLock() {
try {
this.wakeLock = await navigator.wakeLock.request('screen');
this.wakeLockEnabled = true;
this.updateWakeLockStatus(true);
this.wakeLock.addEventListener('release', () => {
this.updateWakeLockStatus(false);
});
} catch (err) {
console.log('Wake Lock error:', err);
this.updateWakeLockStatus(false);
const status = document.getElementById('wake-lock-status');
if (status && !status.classList.contains('unsupported')) {
status.title = 'Wake lock failed: ' + err.message;
}
}
}
updateWakeLockStatus(active) {
const status = document.getElementById('wake-lock-status');
if (status) {
status.classList.toggle('active', active);
if (!status.classList.contains('unsupported')) {
status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)';
}
}
}
// PWA manifest setup
setupPWA() {
// Create dynamic manifest for this session
const manifest = {
name: 'MacroPad',
short_name: 'MacroPad',
description: 'Remote macro control',
start_url: `/${this.sessionId}/app`,
display: 'standalone',
background_color: '#2e2e2e',
theme_color: '#007acc',
icons: [
{ src: '/static/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/static/icons/icon-512.png', sizes: '512x512', type: 'image/png' }
]
};
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
const manifestUrl = URL.createObjectURL(blob);
document.getElementById('manifest-link').setAttribute('href', manifestUrl);
}
}
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new MacroPadApp();
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad Relay</title>
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 600px;
text-align: center;
}
.logo {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
color: #fff;
}
.tagline {
font-size: 1.2rem;
color: #888;
margin-bottom: 40px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 30px;
margin-bottom: 30px;
}
.card h2 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #4a9eff;
}
.card p {
line-height: 1.6;
color: #b0b0b0;
}
.features {
display: grid;
gap: 20px;
text-align: left;
}
.feature {
display: flex;
align-items: flex-start;
gap: 15px;
}
.feature-icon {
font-size: 24px;
flex-shrink: 0;
}
.feature-text h3 {
font-size: 1rem;
margin-bottom: 5px;
color: #fff;
}
.feature-text p {
font-size: 0.9rem;
color: #888;
}
.cta {
margin-top: 30px;
}
.cta a {
display: inline-block;
background: #4a9eff;
color: #fff;
text-decoration: none;
padding: 12px 30px;
border-radius: 8px;
font-weight: 500;
transition: background 0.2s;
}
.cta a:hover {
background: #3a8eef;
}
.footer {
margin-top: 40px;
font-size: 0.85rem;
color: #666;
}
.footer a {
color: #4a9eff;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">&#9000;</div>
<h1>MacroPad Relay</h1>
<p class="tagline">Secure remote access to your MacroPad</p>
<div class="card">
<h2>What is this?</h2>
<p>
MacroPad Relay enables secure remote access to your MacroPad desktop application
from anywhere. Control your macros from your phone or tablet over HTTPS,
even when you're away from your local network.
</p>
</div>
<div class="card features">
<div class="feature">
<span class="feature-icon">&#128274;</span>
<div class="feature-text">
<h3>Secure Connection</h3>
<p>Password-protected sessions with encrypted WebSocket communication.</p>
</div>
</div>
<div class="feature">
<span class="feature-icon">&#127760;</span>
<div class="feature-text">
<h3>Access Anywhere</h3>
<p>Use your macros from any device with a web browser.</p>
</div>
</div>
<div class="feature">
<span class="feature-icon">&#9889;</span>
<div class="feature-text">
<h3>Real-time Sync</h3>
<p>Changes sync instantly between your desktop and mobile devices.</p>
</div>
</div>
</div>
<div class="cta">
<a href="https://repo.anhonesthost.net/MacroPad/MP-Server" target="_blank">Learn More</a>
</div>
<p class="footer">
Part of the <a href="https://repo.anhonesthost.net/MacroPad/MP-Server" target="_blank">MacroPad</a> project
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MacroPad - Login</title>
<link rel="icon" type="image/png" href="/static/icons/favicon.png">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #2e2e2e;
color: #ffffff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-container {
background-color: #3e3e3e;
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
margin-bottom: 0.5rem;
color: #007acc;
}
.subtitle {
text-align: center;
color: #aaa;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
}
input[type="password"] {
width: 100%;
padding: 0.75rem;
background-color: #2e2e2e;
border: 1px solid #505050;
border-radius: 4px;
color: #fff;
font-size: 1rem;
}
input[type="password"]:focus {
outline: none;
border-color: #007acc;
}
button {
width: 100%;
padding: 0.75rem;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #0096ff;
}
button:disabled {
background-color: #505050;
cursor: not-allowed;
}
.error {
background-color: rgba(220, 53, 69, 0.2);
border: 1px solid #dc3545;
color: #dc3545;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.status {
text-align: center;
margin-top: 1rem;
color: #aaa;
font-size: 0.85rem;
}
.status.connected {
color: #28a745;
}
.status.disconnected {
color: #dc3545;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.checkbox-group input {
width: auto;
}
.checkbox-group label {
margin: 0;
color: #aaa;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="login-container">
<h1>MacroPad</h1>
<p class="subtitle">Enter password to access your macros</p>
<div class="error" id="error"></div>
<form id="loginForm">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" required autofocus>
</div>
<div class="checkbox-group">
<input type="checkbox" id="remember">
<label for="remember">Remember on this device</label>
</div>
<button type="submit" id="submitBtn">Connect</button>
</form>
<p class="status" id="status">Checking connection...</p>
</div>
<script>
const sessionId = window.location.pathname.split('/')[1];
const form = document.getElementById('loginForm');
const passwordInput = document.getElementById('password');
const rememberCheckbox = document.getElementById('remember');
const submitBtn = document.getElementById('submitBtn');
const errorDiv = document.getElementById('error');
const statusDiv = document.getElementById('status');
let desktopConnected = false;
// Check for saved password
const savedPassword = sessionStorage.getItem(`macropad_${sessionId}`);
if (savedPassword) {
passwordInput.value = savedPassword;
}
// Connect to WebSocket to check desktop status
function checkStatus() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/${sessionId}/ws`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'desktop_status') {
desktopConnected = data.status === 'connected';
updateStatus();
}
};
ws.onerror = () => {
statusDiv.textContent = 'Connection error';
statusDiv.className = 'status disconnected';
};
ws.onclose = () => {
setTimeout(checkStatus, 5000);
};
}
function updateStatus() {
if (desktopConnected) {
statusDiv.textContent = 'Desktop connected';
statusDiv.className = 'status connected';
submitBtn.disabled = false;
} else {
statusDiv.textContent = 'Desktop not connected';
statusDiv.className = 'status disconnected';
submitBtn.disabled = true;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorDiv.style.display = 'none';
const password = passwordInput.value;
try {
// Test password with a simple API call
const response = await fetch(`/${sessionId}/api/tabs`, {
headers: {
'X-MacroPad-Password': password
}
});
if (response.ok) {
// Save password if remember is checked
if (rememberCheckbox.checked) {
sessionStorage.setItem(`macropad_${sessionId}`, password);
}
// Redirect to the PWA with password
window.location.href = `/${sessionId}/app?auth=${encodeURIComponent(password)}`;
} else {
const data = await response.json();
errorDiv.textContent = data.error || 'Invalid password';
errorDiv.style.display = 'block';
}
} catch (error) {
errorDiv.textContent = 'Connection failed';
errorDiv.style.display = 'block';
}
});
checkStatus();
</script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
// Configuration for MacroPad Relay Server
import dotenv from 'dotenv';
import path from 'path';
dotenv.config();
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
// Data storage
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
// Session settings
sessionIdLength: parseInt(process.env.SESSION_ID_LENGTH || '6', 10),
// Security
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
// Rate limiting
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
// WebSocket
pingInterval: parseInt(process.env.PING_INTERVAL || '30000', 10), // 30 seconds
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30000', 10), // 30 seconds
// Logging
logLevel: process.env.LOG_LEVEL || 'info',
// Environment
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV !== 'production',
};

View File

@@ -0,0 +1,88 @@
// API Proxy Handler - proxies REST requests to desktop apps
import { Request, Response, NextFunction } from 'express';
import { ConnectionManager } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
export function createApiProxy(
connectionManager: ConnectionManager,
sessionManager: SessionManager
) {
return async (req: Request, res: Response, next: NextFunction) => {
const sessionId = req.params.sessionId;
// Check session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Check password in header or query
const password = req.headers['x-macropad-password'] as string ||
req.query.password as string;
if (!password) {
return res.status(401).json({ error: 'Password required' });
}
const valid = await sessionManager.validatePassword(sessionId, password);
if (!valid) {
return res.status(401).json({ error: 'Invalid password' });
}
// Check desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (!desktop) {
return res.status(503).json({ error: 'Desktop not connected' });
}
// Extract the API path (remove the session ID prefix)
const apiPath = req.path.replace(`/${sessionId}`, '');
try {
const response = await connectionManager.forwardApiRequest(
sessionId,
req.method,
apiPath,
req.body,
filterHeaders(req.headers)
);
// Handle binary responses (images)
if (response.body?.base64 && response.body?.contentType) {
const buffer = Buffer.from(response.body.base64, 'base64');
res.set('Content-Type', response.body.contentType);
res.send(buffer);
} else {
res.status(response.status).json(response.body);
}
} catch (error: any) {
logger.error(`API proxy error for ${sessionId}:`, error);
if (error.message === 'Request timeout') {
return res.status(504).json({ error: 'Desktop request timeout' });
}
if (error.message === 'Desktop not connected' || error.message === 'Desktop disconnected') {
return res.status(503).json({ error: 'Desktop not connected' });
}
res.status(500).json({ error: 'Proxy error' });
}
};
}
function filterHeaders(headers: any): Record<string, string> {
// Only forward relevant headers
const allowed = ['content-type', 'accept', 'accept-language'];
const filtered: Record<string, string> = {};
for (const key of allowed) {
if (headers[key]) {
filtered[key] = headers[key];
}
}
return filtered;
}

View File

@@ -0,0 +1,168 @@
// Desktop WebSocket Handler
import WebSocket from 'ws';
import { ConnectionManager } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
interface AuthMessage {
type: 'auth';
sessionId: string | null;
password: string;
}
interface ApiResponseMessage {
type: 'api_response';
requestId: string;
status: number;
body: any;
}
interface WsBroadcastMessage {
type: 'ws_broadcast';
data: any;
}
interface PongMessage {
type: 'pong';
}
type DesktopMessage = AuthMessage | ApiResponseMessage | WsBroadcastMessage | PongMessage;
export function handleDesktopConnection(
socket: WebSocket,
connectionManager: ConnectionManager,
sessionManager: SessionManager
): void {
let authenticatedSessionId: string | null = null;
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) => {
authenticatedSessionId = sessionId;
});
break;
case 'api_response':
if (authenticatedSessionId) {
handleApiResponse(message, authenticatedSessionId, connectionManager);
}
break;
case 'ws_broadcast':
if (authenticatedSessionId) {
handleWsBroadcast(message, authenticatedSessionId, connectionManager);
}
break;
case 'pong':
if (authenticatedSessionId) {
connectionManager.updateDesktopPing(authenticatedSessionId);
}
break;
default:
logger.warn('Unknown message type from desktop:', (message as any).type);
}
} catch (error) {
logger.error('Error handling desktop message:', error);
}
});
socket.on('close', () => {
if (authenticatedSessionId) {
connectionManager.disconnectDesktop(authenticatedSessionId);
}
});
socket.on('error', (error) => {
logger.error('Desktop WebSocket error:', error);
if (authenticatedSessionId) {
connectionManager.disconnectDesktop(authenticatedSessionId);
}
});
}
async function handleAuth(
socket: WebSocket,
message: AuthMessage,
sessionManager: SessionManager,
connectionManager: ConnectionManager,
setSessionId: (id: string) => void
): Promise<void> {
try {
let sessionId = message.sessionId;
let session;
if (sessionId) {
// Validate existing session
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (!valid) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid session ID or password'
}));
socket.close();
return;
}
session = sessionManager.getSession(sessionId);
} else {
// Create new session
session = await sessionManager.createSession(message.password);
sessionId = session.id;
}
if (!session || !sessionId) {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Failed to create session'
}));
socket.close();
return;
}
// Add to connection manager
connectionManager.addDesktopConnection(sessionId, socket);
setSessionId(sessionId);
// Send success response
socket.send(JSON.stringify({
type: 'auth_response',
success: true,
sessionId: sessionId
}));
logger.info(`Desktop authenticated: ${sessionId}`);
} catch (error) {
logger.error('Desktop auth error:', error);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Authentication failed'
}));
socket.close();
}
}
function handleApiResponse(
message: ApiResponseMessage,
sessionId: string,
connectionManager: ConnectionManager
): void {
connectionManager.handleApiResponse(sessionId, message.requestId, message.status, message.body);
}
function handleWsBroadcast(
message: WsBroadcastMessage,
sessionId: string,
connectionManager: ConnectionManager
): void {
// Forward the broadcast to all web clients for this session
connectionManager.broadcastToWebClients(sessionId, message.data);
}

View File

@@ -0,0 +1,122 @@
// Web Client WebSocket Handler
import WebSocket from 'ws';
import { ConnectionManager, WebClientConnection } from '../services/ConnectionManager';
import { SessionManager } from '../services/SessionManager';
import { logger } from '../utils/logger';
interface AuthMessage {
type: 'auth';
password: string;
}
interface PingMessage {
type: 'ping';
}
type WebClientMessage = AuthMessage | PingMessage | any;
export function handleWebClientConnection(
socket: WebSocket,
sessionId: string,
connectionManager: ConnectionManager,
sessionManager: SessionManager
): void {
// Check if session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
socket.send(JSON.stringify({
type: 'error',
error: 'Session not found'
}));
socket.close();
return;
}
// Add client (not authenticated yet)
const client = connectionManager.addWebClient(sessionId, socket, false);
// Check if desktop is connected
const desktop = connectionManager.getDesktopBySessionId(sessionId);
socket.send(JSON.stringify({
type: 'desktop_status',
status: desktop ? 'connected' : 'disconnected'
}));
// Request authentication
socket.send(JSON.stringify({
type: 'auth_required'
}));
socket.on('message', async (data) => {
try {
const message: WebClientMessage = JSON.parse(data.toString());
switch (message.type) {
case 'auth':
await handleAuth(socket, client, message, sessionId, sessionManager);
break;
case 'ping':
socket.send(JSON.stringify({ type: 'pong' }));
break;
default:
// Forward other messages to desktop if authenticated
if (client.authenticated) {
forwardToDesktop(message, sessionId, connectionManager);
}
}
} catch (error) {
logger.error('Error handling web client message:', error);
}
});
socket.on('close', () => {
connectionManager.removeWebClient(sessionId, client);
});
socket.on('error', (error) => {
logger.error('Web client WebSocket error:', error);
connectionManager.removeWebClient(sessionId, client);
});
}
async function handleAuth(
socket: WebSocket,
client: WebClientConnection,
message: AuthMessage,
sessionId: string,
sessionManager: SessionManager
): Promise<void> {
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (valid) {
client.authenticated = true;
socket.send(JSON.stringify({
type: 'auth_response',
success: true
}));
logger.debug(`Web client authenticated for session: ${sessionId}`);
} else {
socket.send(JSON.stringify({
type: 'auth_response',
success: false,
error: 'Invalid password'
}));
}
}
function forwardToDesktop(
message: any,
sessionId: string,
connectionManager: ConnectionManager
): void {
const desktop = connectionManager.getDesktopBySessionId(sessionId);
if (desktop && desktop.socket.readyState === WebSocket.OPEN) {
desktop.socket.send(JSON.stringify({
type: 'ws_message',
data: message
}));
}
}

View File

@@ -0,0 +1,21 @@
// MacroPad Relay Server Entry Point
import { createServer } from './server';
import { config } from './config';
import { logger } from './utils/logger';
async function main() {
logger.info('Starting MacroPad Relay Server...');
const { server } = createServer();
server.listen(config.port, config.host, () => {
logger.info(`Server running on http://${config.host}:${config.port}`);
logger.info(`Environment: ${config.nodeEnv}`);
});
}
main().catch((error) => {
logger.error('Failed to start server:', error);
process.exit(1);
});

View File

@@ -0,0 +1,134 @@
// Express + WebSocket Server Setup
import express from 'express';
import fs from 'fs';
import http from 'http';
import WebSocket, { WebSocketServer } from 'ws';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import path from 'path';
import { config } from './config';
import { logger } from './utils/logger';
import { SessionManager } from './services/SessionManager';
import { ConnectionManager } from './services/ConnectionManager';
import { handleDesktopConnection } from './handlers/desktopHandler';
import { handleWebClientConnection } from './handlers/webClientHandler';
import { createApiProxy } from './handlers/apiProxy';
export function createServer() {
const app = express();
const server = http.createServer(app);
// Initialize managers
const sessionManager = new SessionManager();
const connectionManager = new ConnectionManager();
// Middleware
app.use(helmet({
contentSecurityPolicy: false // Allow inline scripts for login page
}));
app.use(cors());
app.use(express.json());
// Rate limiting
const limiter = rateLimit({
windowMs: config.rateLimitWindowMs,
max: config.rateLimitMax,
message: { error: 'Too many requests, please try again later' }
});
app.use(limiter);
// Static files - check both locations (dev vs production)
const publicPath = fs.existsSync(path.join(__dirname, 'public'))
? path.join(__dirname, 'public')
: path.join(__dirname, '..', 'public');
app.use('/static', express.static(publicPath));
// Health check
app.get('/health', (req, res) => {
const stats = connectionManager.getStats();
res.json({
status: 'ok',
...stats
});
});
// Ping endpoint for container health checks
app.get('/ping', (req, res) => {
res.json({ status: 'ok' });
});
// Index page
app.get('/', (req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
// Login page for session
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)
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'));
});
// API proxy routes
const apiProxy = createApiProxy(connectionManager, sessionManager);
app.all('/:sessionId/api/*', apiProxy);
// WebSocket server
const wss = new WebSocketServer({ noServer: true });
// Handle HTTP upgrade for WebSocket
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`);
const pathname = url.pathname;
// Desktop connection: /desktop
if (pathname === '/desktop') {
wss.handleUpgrade(request, socket, head, (ws) => {
handleDesktopConnection(ws, connectionManager, sessionManager);
});
return;
}
// Web client connection: /:sessionId/ws
const webClientMatch = pathname.match(/^\/([a-zA-Z0-9]+)\/ws$/);
if (webClientMatch) {
const sessionId = webClientMatch[1];
wss.handleUpgrade(request, socket, head, (ws) => {
handleWebClientConnection(ws, sessionId, connectionManager, sessionManager);
});
return;
}
// Invalid path
socket.destroy();
});
// Graceful shutdown
const shutdown = () => {
logger.info('Shutting down...');
connectionManager.shutdown();
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
return { app, server, sessionManager, connectionManager };
}

View File

@@ -0,0 +1,275 @@
// Connection Manager - manages desktop and web client connections
import WebSocket from 'ws';
import { logger } from '../utils/logger';
import { generateRequestId } from '../utils/idGenerator';
import { config } from '../config';
export interface PendingRequest {
resolve: (response: any) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
export interface DesktopConnection {
sessionId: string;
socket: WebSocket;
authenticated: boolean;
connectedAt: Date;
lastPing: Date;
pendingRequests: Map<string, PendingRequest>;
}
export interface WebClientConnection {
socket: WebSocket;
sessionId: string;
authenticated: boolean;
}
export class ConnectionManager {
// Desktop connections: sessionId -> connection
private desktopConnections: Map<string, DesktopConnection> = new Map();
// Web clients: sessionId -> set of connections
private webClients: Map<string, Set<WebClientConnection>> = new Map();
// Ping interval handle
private pingInterval: NodeJS.Timeout | null = null;
constructor() {
this.startPingInterval();
}
private startPingInterval(): void {
this.pingInterval = setInterval(() => {
this.pingDesktops();
}, config.pingInterval);
}
private pingDesktops(): void {
const now = Date.now();
for (const [sessionId, desktop] of this.desktopConnections) {
// Check if desktop hasn't responded in too long
if (now - desktop.lastPing.getTime() > config.pingInterval * 2) {
logger.warn(`Desktop ${sessionId} not responding, disconnecting`);
this.disconnectDesktop(sessionId);
continue;
}
// Send ping
try {
desktop.socket.send(JSON.stringify({ type: 'ping' }));
} catch (error) {
logger.error(`Failed to ping desktop ${sessionId}:`, error);
this.disconnectDesktop(sessionId);
}
}
}
// Desktop connection methods
addDesktopConnection(sessionId: string, socket: WebSocket): DesktopConnection {
// Close existing connection if any
const existing = this.desktopConnections.get(sessionId);
if (existing) {
try {
existing.socket.close();
} catch (e) {
// Ignore
}
}
const connection: DesktopConnection = {
sessionId,
socket,
authenticated: true,
connectedAt: new Date(),
lastPing: new Date(),
pendingRequests: new Map()
};
this.desktopConnections.set(sessionId, connection);
logger.info(`Desktop connected: ${sessionId}`);
// Notify web clients
this.broadcastToWebClients(sessionId, {
type: 'desktop_status',
status: 'connected'
});
return connection;
}
disconnectDesktop(sessionId: string): void {
const connection = this.desktopConnections.get(sessionId);
if (connection) {
// Reject all pending requests
for (const [requestId, pending] of connection.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error('Desktop disconnected'));
}
try {
connection.socket.close();
} catch (e) {
// Ignore
}
this.desktopConnections.delete(sessionId);
logger.info(`Desktop disconnected: ${sessionId}`);
// Notify web clients
this.broadcastToWebClients(sessionId, {
type: 'desktop_status',
status: 'disconnected'
});
}
}
getDesktopBySessionId(sessionId: string): DesktopConnection | undefined {
return this.desktopConnections.get(sessionId);
}
updateDesktopPing(sessionId: string): void {
const connection = this.desktopConnections.get(sessionId);
if (connection) {
connection.lastPing = new Date();
}
}
// Web client connection methods
addWebClient(sessionId: string, socket: WebSocket, authenticated: boolean = false): WebClientConnection {
if (!this.webClients.has(sessionId)) {
this.webClients.set(sessionId, new Set());
}
const client: WebClientConnection = {
socket,
sessionId,
authenticated
};
this.webClients.get(sessionId)!.add(client);
logger.debug(`Web client connected to session: ${sessionId}`);
return client;
}
removeWebClient(sessionId: string, client: WebClientConnection): void {
const clients = this.webClients.get(sessionId);
if (clients) {
clients.delete(client);
if (clients.size === 0) {
this.webClients.delete(sessionId);
}
}
logger.debug(`Web client disconnected from session: ${sessionId}`);
}
getWebClientsBySessionId(sessionId: string): Set<WebClientConnection> {
return this.webClients.get(sessionId) || new Set();
}
broadcastToWebClients(sessionId: string, message: object): void {
const clients = this.webClients.get(sessionId);
if (!clients) return;
const data = JSON.stringify(message);
for (const client of clients) {
if (client.authenticated && client.socket.readyState === WebSocket.OPEN) {
try {
client.socket.send(data);
} catch (error) {
logger.error('Failed to send to web client:', error);
}
}
}
}
// API request forwarding
async forwardApiRequest(
sessionId: string,
method: string,
path: string,
body?: any,
headers?: Record<string, string>
): Promise<{ status: number; body: any }> {
const desktop = this.desktopConnections.get(sessionId);
if (!desktop) {
throw new Error('Desktop not connected');
}
const requestId = generateRequestId();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
desktop.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, config.requestTimeout);
desktop.pendingRequests.set(requestId, { resolve, reject, timeout });
try {
desktop.socket.send(JSON.stringify({
type: 'api_request',
requestId,
method,
path,
body,
headers
}));
} catch (error) {
desktop.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(error);
}
});
}
handleApiResponse(sessionId: string, requestId: string, status: number, body: any): void {
const desktop = this.desktopConnections.get(sessionId);
if (!desktop) return;
const pending = desktop.pendingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
desktop.pendingRequests.delete(requestId);
pending.resolve({ status, body });
}
}
// Cleanup
shutdown(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
for (const [sessionId] of this.desktopConnections) {
this.disconnectDesktop(sessionId);
}
for (const [sessionId, clients] of this.webClients) {
for (const client of clients) {
try {
client.socket.close();
} catch (e) {
// Ignore
}
}
}
this.webClients.clear();
}
// Stats
getStats() {
return {
desktopConnections: this.desktopConnections.size,
webClients: Array.from(this.webClients.values()).reduce((sum, set) => sum + set.size, 0),
sessions: Array.from(this.desktopConnections.keys())
};
}
}

View File

@@ -0,0 +1,121 @@
// Session Manager - handles session storage and authentication
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcrypt';
import { config } from '../config';
import { generateSessionId } from '../utils/idGenerator';
import { logger } from '../utils/logger';
export interface Session {
id: string;
passwordHash: string;
createdAt: string;
lastConnected: string;
}
interface SessionStore {
sessions: Record<string, Session>;
}
export class SessionManager {
private sessionsFile: string;
private sessions: Map<string, Session> = new Map();
constructor() {
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
this.load();
}
private load(): void {
try {
if (fs.existsSync(this.sessionsFile)) {
const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
for (const [id, session] of Object.entries(data.sessions || {})) {
this.sessions.set(id, session);
}
logger.info(`Loaded ${this.sessions.size} sessions`);
}
} catch (error) {
logger.error('Failed to load sessions:', error);
}
}
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));
} catch (error) {
logger.error('Failed to save sessions:', error);
}
}
async exists(sessionId: string): Promise<boolean> {
return this.sessions.has(sessionId);
}
async createSession(password: string): Promise<Session> {
// Generate unique session ID
let id: string;
do {
id = generateSessionId(config.sessionIdLength);
} while (this.sessions.has(id));
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
const now = new Date().toISOString();
const session: Session = {
id,
passwordHash,
createdAt: now,
lastConnected: now
};
this.sessions.set(id, session);
this.save();
logger.info(`Created new session: ${id}`);
return session;
}
async validatePassword(sessionId: string, password: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
const valid = await bcrypt.compare(password, session.passwordHash);
if (valid) {
// Update last connected time
session.lastConnected = new Date().toISOString();
this.save();
}
return valid;
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
updateLastConnected(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.lastConnected = new Date().toISOString();
this.save();
}
}
deleteSession(sessionId: string): boolean {
const deleted = this.sessions.delete(sessionId);
if (deleted) {
this.save();
logger.info(`Deleted session: ${sessionId}`);
}
return deleted;
}
}

View File

@@ -0,0 +1,31 @@
// Unique ID generation utilities
import { randomBytes } from 'crypto';
const BASE62_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
/**
* Generate a random base62 string of specified length.
* Uses cryptographically secure random bytes.
*/
export function generateSessionId(length: number = 6): string {
const bytes = randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length];
}
return result;
}
/**
* Generate a UUID v4 for request IDs.
*/
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);
});
}

View File

@@ -0,0 +1,36 @@
// Logger utility using Winston
import winston from 'winston';
import { config } from '../config';
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ level, message, timestamp, stack }) => {
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
})
);
export const logger = winston.createLogger({
level: config.logLevel,
format: logFormat,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
logFormat
)
})
]
});
// Add file transport in production
if (!config.isDevelopment) {
logger.add(new winston.transports.File({
filename: 'error.log',
level: 'error'
}));
logger.add(new winston.transports.File({
filename: 'combined.log'
}));
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,6 +1,6 @@
[project]
name = "macropad-server"
version = "0.9.0"
version = "0.9.5"
description = "A cross-platform macro management application with desktop and web interfaces"
readme = "README.md"
requires-python = ">=3.11"
@@ -29,6 +29,8 @@ dependencies = [
"qrcode>=7.4.2",
# Desktop GUI
"PySide6>=6.6.0",
# Relay client
"aiohttp>=3.9.0",
]
[project.optional-dependencies]

235
relay_client.py Normal file
View File

@@ -0,0 +1,235 @@
# Relay Client for MacroPad Server
# Connects to relay server and forwards API requests to local server
import asyncio
import json
import threading
import time
from typing import Optional, Callable
import aiohttp
class RelayClient:
"""WebSocket client that connects to relay server and proxies requests."""
def __init__(
self,
relay_url: str,
password: str,
session_id: Optional[str] = None,
local_port: int = 40000,
on_connected: Optional[Callable] = None,
on_disconnected: Optional[Callable] = None,
on_session_id: Optional[Callable[[str], None]] = None
):
self.relay_url = relay_url.rstrip('/')
if not self.relay_url.endswith('/desktop'):
self.relay_url += '/desktop'
self.password = password
self.session_id = session_id
self.local_url = f"http://localhost:{local_port}"
# Callbacks
self.on_connected = on_connected
self.on_disconnected = on_disconnected
self.on_session_id = on_session_id
# State
self._ws = None
self._session = None
self._running = False
self._connected = False
self._thread = None
self._loop = None
self._reconnect_delay = 1
def start(self):
"""Start the relay client in a background thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run_async_loop, daemon=True)
self._thread.start()
def stop(self):
"""Stop the relay client."""
self._running = False
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread:
self._thread.join(timeout=2)
self._thread = None
def is_connected(self) -> bool:
"""Check if connected to relay server."""
return self._connected
def _run_async_loop(self):
"""Run the asyncio event loop in the background thread."""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._connection_loop())
except Exception as e:
print(f"Relay client error: {e}")
finally:
self._loop.close()
async def _connection_loop(self):
"""Main connection loop with reconnection logic."""
while self._running:
try:
await self._connect_and_run()
except Exception as e:
print(f"Relay connection error: {e}")
if self._running:
# Exponential backoff for reconnection
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(self._reconnect_delay * 2, 30)
async def _connect_and_run(self):
"""Connect to relay server and handle messages."""
try:
async with aiohttp.ClientSession() as session:
self._session = session
async with session.ws_connect(self.relay_url) as ws:
self._ws = ws
# Authenticate
if not await self._authenticate():
return
self._connected = True
self._reconnect_delay = 1 # Reset backoff on successful connect
if self.on_connected:
self.on_connected()
# Message handling loop
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_message(json.loads(msg.data))
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f"WebSocket error: {ws.exception()}")
break
elif msg.type == aiohttp.WSMsgType.CLOSED:
break
except aiohttp.ClientError as e:
print(f"Relay connection failed: {e}")
finally:
self._connected = False
self._ws = None
self._session = None
if self.on_disconnected:
self.on_disconnected()
async def _authenticate(self) -> bool:
"""Authenticate with the relay server."""
auth_msg = {
"type": "auth",
"sessionId": self.session_id,
"password": self.password
}
await self._ws.send_json(auth_msg)
# Wait for auth response
response = await self._ws.receive_json()
if response.get("type") == "auth_response":
if response.get("success"):
# Mark as connected before callbacks so update_ip_label works
self._connected = True
new_session_id = response.get("sessionId")
# Always update session_id and trigger callback to ensure URL updates
if new_session_id:
self.session_id = new_session_id
if self.on_session_id:
self.on_session_id(new_session_id)
return True
else:
print(f"Authentication failed: {response.get('error', 'Unknown error')}")
return False
return False
async def _handle_message(self, msg: dict):
"""Handle a message from the relay server."""
msg_type = msg.get("type")
if msg_type == "api_request":
await self._handle_api_request(msg)
elif msg_type == "ws_message":
# Forward WebSocket message from web client
await self._handle_ws_message(msg)
elif msg_type == "ping":
await self._ws.send_json({"type": "pong"})
async def _handle_api_request(self, msg: dict):
"""Forward API request to local server and send response back."""
request_id = msg.get("requestId")
method = msg.get("method", "GET").upper()
path = msg.get("path", "/")
body = msg.get("body")
headers = msg.get("headers", {})
url = f"{self.local_url}{path}"
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,
headers=headers
) as response:
# Handle binary responses (images)
content_type = response.headers.get("Content-Type", "")
if content_type.startswith("image/"):
# Base64 encode binary data
import base64
data = await response.read()
response_body = {
"base64": base64.b64encode(data).decode("utf-8"),
"contentType": content_type
}
else:
try:
response_body = await response.json()
except:
response_body = {"text": await response.text()}
await self._ws.send_json({
"type": "api_response",
"requestId": request_id,
"status": response.status,
"body": response_body
})
except Exception as e:
await self._ws.send_json({
"type": "api_response",
"requestId": request_id,
"status": 500,
"body": {"error": str(e)}
})
async def _handle_ws_message(self, msg: dict):
"""Handle WebSocket message from web client."""
data = msg.get("data", {})
# For now, we don't need to forward messages from web clients
# to the local server because the local server broadcasts changes
# The relay will handle broadcasting back to web clients
pass
async def broadcast(self, data: dict):
"""Broadcast a message to all connected web clients via relay."""
if self._ws and self._connected:
await self._ws.send_json({
"type": "ws_broadcast",
"data": data
})

View File

@@ -1 +1 @@
0.9.0
1.0.0

View File

@@ -581,6 +581,15 @@ body {
animation: pulse-glow 2s ease-in-out infinite;
}
.wake-lock-status.unsupported {
opacity: 0.3;
}
.wake-lock-status.unsupported .wake-icon {
color: #888;
text-decoration: line-through;
}
@keyframes pulse-glow {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }

View File

@@ -8,9 +8,64 @@ class MacroPadApp {
this.ws = null;
this.wakeLock = null;
// Relay mode detection
this.relayMode = this.detectRelayMode();
this.sessionId = null;
this.password = null;
this.desktopConnected = true;
this.wsAuthenticated = false;
if (this.relayMode) {
this.initRelayMode();
}
this.init();
}
detectRelayMode() {
// Check if URL matches relay pattern: /sessionId/app or /sessionId
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})(\/app)?$/);
return pathMatch !== null;
}
initRelayMode() {
// Extract session ID from URL
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]{4,12})/);
if (pathMatch) {
this.sessionId = pathMatch[1];
}
// Get password from URL query param or sessionStorage
const urlParams = new URLSearchParams(window.location.search);
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
if (this.password) {
// Store password for future use
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
// Clear from URL for security
if (urlParams.has('auth')) {
window.history.replaceState({}, '', window.location.pathname);
}
}
console.log('Relay mode enabled, session:', this.sessionId);
}
getApiUrl(path) {
if (this.relayMode && this.sessionId) {
return `/${this.sessionId}${path}`;
}
return path;
}
getApiHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (this.relayMode && this.password) {
headers['X-MacroPad-Password'] = this.password;
}
return headers;
}
async init() {
await this.loadTabs();
await this.loadMacros();
@@ -23,7 +78,16 @@ class MacroPadApp {
// API Methods
async loadTabs() {
try {
const response = await fetch('/api/tabs');
const response = await fetch(this.getApiUrl('/api/tabs'), {
headers: this.getApiHeaders()
});
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
return;
}
throw new Error('Failed to load tabs');
}
const data = await response.json();
this.tabs = data.tabs || [];
this.renderTabs();
@@ -35,10 +99,23 @@ class MacroPadApp {
async loadMacros() {
try {
const url = this.currentTab === 'All'
const path = this.currentTab === 'All'
? '/api/macros'
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
const response = await fetch(url);
const response = await fetch(this.getApiUrl(path), {
headers: this.getApiHeaders()
});
if (!response.ok) {
if (response.status === 401) {
this.handleAuthError();
return;
}
if (response.status === 503) {
this.handleDesktopDisconnected();
return;
}
throw new Error('Failed to load macros');
}
const data = await response.json();
this.macros = data.macros || {};
this.renderMacros();
@@ -53,13 +130,18 @@ class MacroPadApp {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const response = await fetch('/api/execute', {
const response = await fetch(this.getApiUrl('/api/execute'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.getApiHeaders(),
body: JSON.stringify({ macro_id: macroId })
});
if (!response.ok) throw new Error('Execution failed');
if (!response.ok) {
if (response.status === 503) {
this.handleDesktopDisconnected();
}
throw new Error('Execution failed');
}
setTimeout(() => {
if (card) card.classList.remove('executing');
@@ -70,19 +152,44 @@ class MacroPadApp {
}
}
handleAuthError() {
this.showToast('Authentication failed', 'error');
if (this.relayMode) {
// Clear stored password and redirect to login
sessionStorage.removeItem(`macropad_${this.sessionId}`);
window.location.href = `/${this.sessionId}`;
}
}
handleDesktopDisconnected() {
this.desktopConnected = false;
this.updateConnectionStatus(false, 'Desktop offline');
this.showToast('Desktop app is not connected', 'error');
}
// WebSocket
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
let wsUrl;
if (this.relayMode && this.sessionId) {
wsUrl = `${protocol}//${window.location.host}/${this.sessionId}/ws`;
} else {
wsUrl = `${protocol}//${window.location.host}/ws`;
}
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.updateConnectionStatus(true);
if (!this.relayMode) {
this.updateConnectionStatus(true);
}
// In relay mode, wait for auth before showing connected
};
this.ws.onclose = () => {
this.wsAuthenticated = false;
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
};
@@ -102,12 +209,46 @@ class MacroPadApp {
handleWebSocketMessage(data) {
switch (data.type) {
// Relay-specific messages
case 'auth_required':
// Send authentication
if (this.password) {
this.ws.send(JSON.stringify({
type: 'auth',
password: this.password
}));
}
break;
case 'auth_response':
if (data.success) {
this.wsAuthenticated = true;
this.updateConnectionStatus(this.desktopConnected);
} else {
this.handleAuthError();
}
break;
case 'desktop_status':
this.desktopConnected = data.status === 'connected';
this.updateConnectionStatus(this.desktopConnected);
if (!this.desktopConnected) {
this.showToast('Desktop disconnected', 'error');
} else {
this.showToast('Desktop connected', 'success');
this.loadTabs();
this.loadMacros();
}
break;
// Standard MacroPad messages
case 'macro_created':
case 'macro_updated':
case 'macro_deleted':
this.loadTabs();
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
if (card) {
@@ -115,17 +256,27 @@ class MacroPadApp {
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
case 'pong':
// Keep-alive response
break;
}
}
updateConnectionStatus(connected) {
updateConnectionStatus(connected, customText = null) {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.connection-status span');
if (dot) {
dot.classList.toggle('connected', connected);
}
if (text) {
text.textContent = connected ? 'Connected' : 'Disconnected';
if (customText) {
text.textContent = customText;
} else if (this.relayMode) {
text.textContent = connected ? 'Connected (Relay)' : 'Disconnected';
} else {
text.textContent = connected ? 'Connected' : 'Disconnected';
}
}
}
@@ -157,9 +308,16 @@ class MacroPadApp {
}
container.innerHTML = macroEntries.map(([id, macro]) => {
const imageSrc = macro.image_path
? `/api/image/${macro.image_path}`
: null;
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();
return `
@@ -261,26 +419,57 @@ class MacroPadApp {
// Wake Lock - prevents screen from sleeping
async setupWakeLock() {
const status = document.getElementById('wake-lock-status');
if (!('wakeLock' in navigator)) {
console.log('Wake Lock API not supported');
document.getElementById('wake-lock-status')?.remove();
// Don't remove the icon - show it as unsupported instead
if (status) {
status.classList.add('unsupported');
status.title = 'Wake lock not available (requires HTTPS)';
}
return;
}
// Request wake lock
// Make the icon clickable to toggle wake lock
if (status) {
status.style.cursor = 'pointer';
status.addEventListener('click', () => this.toggleWakeLock());
}
// Request wake lock automatically
await this.requestWakeLock();
// Re-acquire wake lock when page becomes visible again
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') {
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
await this.requestWakeLock();
}
});
}
async toggleWakeLock() {
if (this.wakeLock) {
// Release wake lock
await this.wakeLock.release();
this.wakeLock = null;
this.wakeLockEnabled = false;
this.updateWakeLockStatus(false);
this.showToast('Screen can now sleep', 'info');
} else {
// Request wake lock
this.wakeLockEnabled = true;
await this.requestWakeLock();
if (this.wakeLock) {
this.showToast('Screen will stay awake', 'success');
}
}
}
async requestWakeLock() {
try {
this.wakeLock = await navigator.wakeLock.request('screen');
this.wakeLockEnabled = true;
this.updateWakeLockStatus(true);
this.wakeLock.addEventListener('release', () => {
@@ -289,6 +478,11 @@ class MacroPadApp {
} catch (err) {
console.log('Wake Lock error:', err);
this.updateWakeLockStatus(false);
// Show error only if user explicitly tried to enable
const status = document.getElementById('wake-lock-status');
if (status && !status.classList.contains('unsupported')) {
status.title = 'Wake lock failed: ' + err.message;
}
}
}
@@ -296,7 +490,9 @@ class MacroPadApp {
const status = document.getElementById('wake-lock-status');
if (status) {
status.classList.toggle('active', active);
status.title = active ? 'Screen will stay on' : 'Screen may sleep';
if (!status.classList.contains('unsupported')) {
status.title = active ? 'Screen will stay on (click to toggle)' : 'Screen may sleep (click to enable)';
}
}
}
}