From 8e4c32fea46681becba2a4e1eb17b1f7a017aa21 Mon Sep 17 00:00:00 2001 From: jknapp Date: Mon, 5 Jan 2026 19:33:07 -0800 Subject: [PATCH] Add v0.9.5 features: minimize to tray, settings, relay support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- config.py | 3 +- gui/main_window.py | 206 +++++++++++++++++++++++- gui/settings_dialog.py | 344 ++++++++++++++++++++++++++++++++++++++++ gui/settings_manager.py | 106 +++++++++++++ pyproject.toml | 4 +- relay_client.py | 232 +++++++++++++++++++++++++++ web/css/styles.css | 9 ++ web/js/app.js | 217 +++++++++++++++++++++++-- 8 files changed, 1103 insertions(+), 18 deletions(-) create mode 100644 gui/settings_dialog.py create mode 100644 gui/settings_manager.py create mode 100644 relay_client.py diff --git a/config.py b/config.py index 89e154d..e9b851a 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,8 @@ # Configuration and constants for MacroPad Server -VERSION = "0.9.3" +VERSION = "0.9.5" DEFAULT_PORT = 40000 +SETTINGS_FILE = "settings.json" # UI Theme colors THEME = { diff --git a/gui/main_window.py b/gui/main_window.py index 4cb1ce7..5a55795 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -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): @@ -123,6 +128,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 +143,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,6 +154,10 @@ 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) @@ -275,11 +291,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") @@ -379,6 +412,18 @@ class MainWindow(QMainWindow): def update_ip_label(self): """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: import netifaces for iface in netifaces.interfaces(): @@ -555,8 +600,149 @@ 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.""" + 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): """Handle window close.""" + # Stop the relay client + self.stop_relay_client() + # Stop the web server self.stop_server() @@ -569,3 +755,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) diff --git a/gui/settings_dialog.py b/gui/settings_dialog.py new file mode 100644 index 0000000..68d1de2 --- /dev/null +++ b/gui/settings_dialog.py @@ -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." + ) diff --git a/gui/settings_manager.py b/gui/settings_manager.py new file mode 100644 index 0000000..613bc13 --- /dev/null +++ b/gui/settings_manager.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index ace7108..620222d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/relay_client.py b/relay_client.py new file mode 100644 index 0000000..4ce4677 --- /dev/null +++ b/relay_client.py @@ -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 + }) diff --git a/web/css/styles.css b/web/css/styles.css index d5c5c10..a878f76 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -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; } diff --git a/web/js/app.js b/web/js/app.js index d22d4ee..c7fbbd5 100644 --- a/web/js/app.js +++ b/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'; + } } } @@ -261,26 +412,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 +471,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 +483,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)'; + } } } }