Add v0.9.5 features: minimize to tray, settings, relay support
## New Features ### Minimize to Tray - Window minimizes to system tray instead of taskbar - Tray notification shown when minimized - Double-click tray icon to restore ### Settings System - New settings dialog (Edit > Settings or Ctrl+,) - JSON-based settings persistence - General tab: minimize to tray toggle - Relay Server tab: enable/configure relay connection ### Relay Server Support - New relay_client.py for connecting to relay server - WebSocket client with auto-reconnection - Forwards API requests to local server - Updates QR code/URL when relay connected ### PWA Updates - Added relay mode detection and authentication - Password passed via header for API requests - WebSocket authentication for relay connections - Desktop status handling (connected/disconnected) - Wake lock icon now always visible with status indicator ## Files Added - gui/settings_manager.py - gui/settings_dialog.py - relay_client.py ## Dependencies - Added aiohttp>=3.9.0 for relay client 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
# Configuration and constants for MacroPad Server
|
# Configuration and constants for MacroPad Server
|
||||||
|
|
||||||
VERSION = "0.9.3"
|
VERSION = "0.9.5"
|
||||||
DEFAULT_PORT = 40000
|
DEFAULT_PORT = 40000
|
||||||
|
SETTINGS_FILE = "settings.json"
|
||||||
|
|
||||||
# UI Theme colors
|
# UI Theme colors
|
||||||
THEME = {
|
THEME = {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# Windows startup management
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(relative_path):
|
def get_resource_path(relative_path):
|
||||||
"""Get the path to a bundled resource file."""
|
"""Get the path to a bundled resource file."""
|
||||||
@@ -20,12 +24,13 @@ from PySide6.QtWidgets import (
|
|||||||
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
|
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
|
||||||
QMessageBox, QApplication, QSystemTrayIcon, QStyle
|
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 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 macro_manager import MacroManager
|
||||||
from web_server import WebServer
|
from web_server import WebServer
|
||||||
|
from .settings_manager import SettingsManager
|
||||||
|
|
||||||
|
|
||||||
class MacroButton(QPushButton):
|
class MacroButton(QPushButton):
|
||||||
@@ -123,6 +128,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.current_tab = "All"
|
self.current_tab = "All"
|
||||||
self.sort_by = "name"
|
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
|
# Initialize macro manager
|
||||||
data_file = os.path.join(app_dir, "macros.json")
|
data_file = os.path.join(app_dir, "macros.json")
|
||||||
images_dir = os.path.join(app_dir, "macro_images")
|
images_dir = os.path.join(app_dir, "macro_images")
|
||||||
@@ -134,6 +143,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
|
self.web_server = WebServer(self.macro_manager, app_dir, DEFAULT_PORT)
|
||||||
self.server_thread = None
|
self.server_thread = None
|
||||||
|
|
||||||
|
# Relay client (initialized later if enabled)
|
||||||
|
self.relay_client = None
|
||||||
|
|
||||||
# Setup UI
|
# Setup UI
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.setup_menu()
|
self.setup_menu()
|
||||||
@@ -142,6 +154,10 @@ class MainWindow(QMainWindow):
|
|||||||
# Start web server
|
# Start web server
|
||||||
self.start_server()
|
self.start_server()
|
||||||
|
|
||||||
|
# Start relay client if enabled
|
||||||
|
if self.settings_manager.get_relay_enabled():
|
||||||
|
self.start_relay_client()
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self.macros_changed.connect(self.refresh_macros)
|
self.macros_changed.connect(self.refresh_macros)
|
||||||
|
|
||||||
@@ -275,11 +291,28 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
file_menu.addSeparator()
|
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 = QAction("Quit", self)
|
||||||
quit_action.setShortcut("Ctrl+Q")
|
quit_action.setShortcut("Ctrl+Q")
|
||||||
quit_action.triggered.connect(self.close)
|
quit_action.triggered.connect(self.close)
|
||||||
file_menu.addAction(quit_action)
|
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
|
||||||
view_menu = menubar.addMenu("View")
|
view_menu = menubar.addMenu("View")
|
||||||
|
|
||||||
@@ -379,6 +412,18 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def update_ip_label(self):
|
def update_ip_label(self):
|
||||||
"""Update the IP address label."""
|
"""Update the IP address label."""
|
||||||
|
# Check if relay is connected and has a session ID
|
||||||
|
if self.relay_client and self.relay_client.is_connected():
|
||||||
|
session_id = self.settings_manager.get_relay_session_id()
|
||||||
|
if 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('/')
|
||||||
|
self.ip_label.setText(f"{base_url}/{session_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fall back to local IP
|
||||||
try:
|
try:
|
||||||
import netifaces
|
import netifaces
|
||||||
for iface in netifaces.interfaces():
|
for iface in netifaces.interfaces():
|
||||||
@@ -555,8 +600,149 @@ class MainWindow(QMainWindow):
|
|||||||
about_box.setStandardButtons(QMessageBox.Ok)
|
about_box.setStandardButtons(QMessageBox.Ok)
|
||||||
about_box.exec()
|
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."""
|
||||||
|
QTimer.singleShot(0, lambda: self._update_relay_status(True))
|
||||||
|
|
||||||
|
def on_relay_disconnected(self):
|
||||||
|
"""Handle relay disconnection."""
|
||||||
|
QTimer.singleShot(0, lambda: self._update_relay_status(False))
|
||||||
|
|
||||||
|
def on_relay_session_id(self, session_id: str):
|
||||||
|
"""Handle receiving session ID from relay."""
|
||||||
|
self.settings_manager.set_relay_session_id(session_id)
|
||||||
|
QTimer.singleShot(0, 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):
|
def closeEvent(self, event):
|
||||||
"""Handle window close."""
|
"""Handle window close."""
|
||||||
|
# Stop the relay client
|
||||||
|
self.stop_relay_client()
|
||||||
|
|
||||||
# Stop the web server
|
# Stop the web server
|
||||||
self.stop_server()
|
self.stop_server()
|
||||||
|
|
||||||
@@ -569,3 +755,19 @@ class MainWindow(QMainWindow):
|
|||||||
"""Handle window resize."""
|
"""Handle window resize."""
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
self.refresh_macros()
|
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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "macropad-server"
|
name = "macropad-server"
|
||||||
version = "0.9.0"
|
version = "0.9.5"
|
||||||
description = "A cross-platform macro management application with desktop and web interfaces"
|
description = "A cross-platform macro management application with desktop and web interfaces"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -29,6 +29,8 @@ dependencies = [
|
|||||||
"qrcode>=7.4.2",
|
"qrcode>=7.4.2",
|
||||||
# Desktop GUI
|
# Desktop GUI
|
||||||
"PySide6>=6.6.0",
|
"PySide6>=6.6.0",
|
||||||
|
# Relay client
|
||||||
|
"aiohttp>=3.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
232
relay_client.py
Normal file
232
relay_client.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# 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"):
|
||||||
|
new_session_id = response.get("sessionId")
|
||||||
|
if new_session_id and new_session_id != self.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
|
||||||
|
})
|
||||||
@@ -581,6 +581,15 @@ body {
|
|||||||
animation: pulse-glow 2s ease-in-out infinite;
|
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 {
|
@keyframes pulse-glow {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.6; }
|
50% { opacity: 0.6; }
|
||||||
|
|||||||
217
web/js/app.js
217
web/js/app.js
@@ -8,9 +8,64 @@ class MacroPadApp {
|
|||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.wakeLock = 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();
|
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() {
|
async init() {
|
||||||
await this.loadTabs();
|
await this.loadTabs();
|
||||||
await this.loadMacros();
|
await this.loadMacros();
|
||||||
@@ -23,7 +78,16 @@ class MacroPadApp {
|
|||||||
// API Methods
|
// API Methods
|
||||||
async loadTabs() {
|
async loadTabs() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
this.tabs = data.tabs || [];
|
this.tabs = data.tabs || [];
|
||||||
this.renderTabs();
|
this.renderTabs();
|
||||||
@@ -35,10 +99,23 @@ class MacroPadApp {
|
|||||||
|
|
||||||
async loadMacros() {
|
async loadMacros() {
|
||||||
try {
|
try {
|
||||||
const url = this.currentTab === 'All'
|
const path = this.currentTab === 'All'
|
||||||
? '/api/macros'
|
? '/api/macros'
|
||||||
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
|
: `/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();
|
const data = await response.json();
|
||||||
this.macros = data.macros || {};
|
this.macros = data.macros || {};
|
||||||
this.renderMacros();
|
this.renderMacros();
|
||||||
@@ -53,13 +130,18 @@ class MacroPadApp {
|
|||||||
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
|
||||||
if (card) card.classList.add('executing');
|
if (card) card.classList.add('executing');
|
||||||
|
|
||||||
const response = await fetch('/api/execute', {
|
const response = await fetch(this.getApiUrl('/api/execute'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: this.getApiHeaders(),
|
||||||
body: JSON.stringify({ macro_id: macroId })
|
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(() => {
|
setTimeout(() => {
|
||||||
if (card) card.classList.remove('executing');
|
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
|
// WebSocket
|
||||||
setupWebSocket() {
|
setupWebSocket() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
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 {
|
try {
|
||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
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.ws.onclose = () => {
|
||||||
|
this.wsAuthenticated = false;
|
||||||
this.updateConnectionStatus(false);
|
this.updateConnectionStatus(false);
|
||||||
setTimeout(() => this.setupWebSocket(), 3000);
|
setTimeout(() => this.setupWebSocket(), 3000);
|
||||||
};
|
};
|
||||||
@@ -102,12 +209,46 @@ class MacroPadApp {
|
|||||||
|
|
||||||
handleWebSocketMessage(data) {
|
handleWebSocketMessage(data) {
|
||||||
switch (data.type) {
|
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_created':
|
||||||
case 'macro_updated':
|
case 'macro_updated':
|
||||||
case 'macro_deleted':
|
case 'macro_deleted':
|
||||||
this.loadTabs();
|
this.loadTabs();
|
||||||
this.loadMacros();
|
this.loadMacros();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'executed':
|
case 'executed':
|
||||||
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
|
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
|
||||||
if (card) {
|
if (card) {
|
||||||
@@ -115,17 +256,27 @@ class MacroPadApp {
|
|||||||
setTimeout(() => card.classList.remove('executing'), 300);
|
setTimeout(() => card.classList.remove('executing'), 300);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
// Keep-alive response
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateConnectionStatus(connected) {
|
updateConnectionStatus(connected, customText = null) {
|
||||||
const dot = document.querySelector('.status-dot');
|
const dot = document.querySelector('.status-dot');
|
||||||
const text = document.querySelector('.connection-status span');
|
const text = document.querySelector('.connection-status span');
|
||||||
if (dot) {
|
if (dot) {
|
||||||
dot.classList.toggle('connected', connected);
|
dot.classList.toggle('connected', connected);
|
||||||
}
|
}
|
||||||
if (text) {
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,26 +412,57 @@ class MacroPadApp {
|
|||||||
|
|
||||||
// Wake Lock - prevents screen from sleeping
|
// Wake Lock - prevents screen from sleeping
|
||||||
async setupWakeLock() {
|
async setupWakeLock() {
|
||||||
|
const status = document.getElementById('wake-lock-status');
|
||||||
|
|
||||||
if (!('wakeLock' in navigator)) {
|
if (!('wakeLock' in navigator)) {
|
||||||
console.log('Wake Lock API not supported');
|
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;
|
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();
|
await this.requestWakeLock();
|
||||||
|
|
||||||
// Re-acquire wake lock when page becomes visible again
|
// Re-acquire wake lock when page becomes visible again
|
||||||
document.addEventListener('visibilitychange', async () => {
|
document.addEventListener('visibilitychange', async () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible' && this.wakeLockEnabled) {
|
||||||
await this.requestWakeLock();
|
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() {
|
async requestWakeLock() {
|
||||||
try {
|
try {
|
||||||
this.wakeLock = await navigator.wakeLock.request('screen');
|
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
this.wakeLockEnabled = true;
|
||||||
this.updateWakeLockStatus(true);
|
this.updateWakeLockStatus(true);
|
||||||
|
|
||||||
this.wakeLock.addEventListener('release', () => {
|
this.wakeLock.addEventListener('release', () => {
|
||||||
@@ -289,6 +471,11 @@ class MacroPadApp {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Wake Lock error:', err);
|
console.log('Wake Lock error:', err);
|
||||||
this.updateWakeLockStatus(false);
|
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 +483,9 @@ class MacroPadApp {
|
|||||||
const status = document.getElementById('wake-lock-status');
|
const status = document.getElementById('wake-lock-status');
|
||||||
if (status) {
|
if (status) {
|
||||||
status.classList.toggle('active', active);
|
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