Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7922910bd8 | |||
| 664d652e9e | |||
| 10971e6a02 | |||
| 17f4bc0c5f | |||
| 44c21e68d8 | |||
| 6f3823eccf | |||
| 4a93f94b8c | |||
| f87dab6bc2 | |||
| 5b6eb33bad | |||
| 7d95d47c73 | |||
| 59254383ad | |||
| 5aed19564c | |||
| 6e76d469c8 | |||
| ff3c7b990c | |||
| 1d7f18018d | |||
| 8e4c32fea4 |
21
README.md
21
README.md
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
344
gui/settings_dialog.py
Normal 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
106
gui/settings_manager.py
Normal 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)
|
||||
27
macropad-relay/.env.example
Normal file
27
macropad-relay/.env.example
Normal 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
26
macropad-relay/.gitignore
vendored
Normal 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
124
macropad-relay/DEPLOY.md
Normal 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
|
||||
```
|
||||
37
macropad-relay/package.json
Normal file
37
macropad-relay/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
621
macropad-relay/public/app.html
Normal file
621
macropad-relay/public/app.html
Normal 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>
|
||||
BIN
macropad-relay/public/icons/favicon.png
Normal file
BIN
macropad-relay/public/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
macropad-relay/public/icons/icon-192.png
Normal file
BIN
macropad-relay/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
macropad-relay/public/icons/icon-512.png
Normal file
BIN
macropad-relay/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
175
macropad-relay/public/index.html
Normal file
175
macropad-relay/public/index.html
Normal 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">⌨</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">🔒</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">🌐</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">⚡</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>
|
||||
249
macropad-relay/public/login.html
Normal file
249
macropad-relay/public/login.html
Normal 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>
|
||||
35
macropad-relay/src/config.ts
Normal file
35
macropad-relay/src/config.ts
Normal 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',
|
||||
};
|
||||
88
macropad-relay/src/handlers/apiProxy.ts
Normal file
88
macropad-relay/src/handlers/apiProxy.ts
Normal 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;
|
||||
}
|
||||
168
macropad-relay/src/handlers/desktopHandler.ts
Normal file
168
macropad-relay/src/handlers/desktopHandler.ts
Normal 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);
|
||||
}
|
||||
122
macropad-relay/src/handlers/webClientHandler.ts
Normal file
122
macropad-relay/src/handlers/webClientHandler.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
21
macropad-relay/src/index.ts
Normal file
21
macropad-relay/src/index.ts
Normal 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);
|
||||
});
|
||||
134
macropad-relay/src/server.ts
Normal file
134
macropad-relay/src/server.ts
Normal 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 };
|
||||
}
|
||||
275
macropad-relay/src/services/ConnectionManager.ts
Normal file
275
macropad-relay/src/services/ConnectionManager.ts
Normal 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())
|
||||
};
|
||||
}
|
||||
}
|
||||
121
macropad-relay/src/services/SessionManager.ts
Normal file
121
macropad-relay/src/services/SessionManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
macropad-relay/src/utils/idGenerator.ts
Normal file
31
macropad-relay/src/utils/idGenerator.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
36
macropad-relay/src/utils/logger.ts
Normal file
36
macropad-relay/src/utils/logger.ts
Normal 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'
|
||||
}));
|
||||
}
|
||||
19
macropad-relay/tsconfig.json
Normal file
19
macropad-relay/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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
235
relay_client.py
Normal 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
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
0.9.0
|
||||
1.0.0
|
||||
|
||||
@@ -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; }
|
||||
|
||||
230
web/js/app.js
230
web/js/app.js
@@ -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)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user