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:
2026-01-05 19:33:07 -08:00
parent 6974947028
commit 8e4c32fea4
8 changed files with 1103 additions and 18 deletions

View File

@@ -5,6 +5,10 @@ import sys
import threading
from typing import Optional
# Windows startup management
if sys.platform == 'win32':
import winreg
def get_resource_path(relative_path):
"""Get the path to a bundled resource file."""
@@ -20,12 +24,13 @@ from PySide6.QtWidgets import (
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QSize
from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
from config import VERSION, THEME, DEFAULT_PORT
from config import VERSION, THEME, DEFAULT_PORT, SETTINGS_FILE
from macro_manager import MacroManager
from web_server import WebServer
from .settings_manager import SettingsManager
class MacroButton(QPushButton):
@@ -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)

344
gui/settings_dialog.py Normal file
View File

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

106
gui/settings_manager.py Normal file
View File

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