# Main window for MacroPad Server (PySide6) import os 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.""" if getattr(sys, 'frozen', False): base_path = sys._MEIPASS else: base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path) from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTabWidget, QGridLayout, QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar, QMessageBox, QApplication, QSystemTrayIcon, QStyle ) from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent from PySide6.QtGui import QIcon, QPixmap, QAction, QFont 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): """Custom button widget for displaying a macro.""" # Signals for context menu actions edit_requested = Signal(str) delete_requested = Signal(str) def __init__(self, macro_id: str, macro: dict, parent=None): super().__init__(parent) self.macro_id = macro_id self.macro = macro self.setFixedSize(120, 100) self.setCursor(Qt.PointingHandCursor) self.setStyleSheet(f""" QPushButton {{ background-color: {THEME['button_bg']}; color: {THEME['fg_color']}; border: none; border-radius: 8px; padding: 8px; text-align: center; }} QPushButton:hover {{ background-color: {THEME['highlight_color']}; }} QPushButton:pressed {{ background-color: {THEME['accent_color']}; }} """) # Layout layout = QVBoxLayout(self) layout.setSpacing(4) layout.setContentsMargins(4, 4, 4, 4) # Image or placeholder image_label = QLabel() image_label.setFixedSize(48, 48) image_label.setAlignment(Qt.AlignCenter) if macro.get("image_path"): pixmap = QPixmap(macro["image_path"]) if not pixmap.isNull(): pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation) image_label.setPixmap(pixmap) else: self._set_placeholder(image_label, macro["name"]) else: self._set_placeholder(image_label, macro["name"]) layout.addWidget(image_label, alignment=Qt.AlignCenter) # Name label name_label = QLabel(macro["name"]) name_label.setAlignment(Qt.AlignCenter) name_label.setWordWrap(True) name_label.setStyleSheet(f"color: {THEME['fg_color']}; font-size: 11px;") layout.addWidget(name_label) def _set_placeholder(self, label: QLabel, name: str): """Set a placeholder with the first letter of the name.""" label.setStyleSheet(f""" background-color: {THEME['highlight_color']}; border-radius: 8px; font-size: 20px; font-weight: bold; color: {THEME['fg_color']}; """) label.setText(name[0].upper() if name else "?") def contextMenuEvent(self, event): """Show context menu on right-click.""" menu = QMenu(self) edit_action = menu.addAction("Edit") delete_action = menu.addAction("Delete") action = menu.exec_(event.globalPos()) if action == edit_action: self.edit_requested.emit(self.macro_id) elif action == delete_action: self.delete_requested.emit(self.macro_id) class MainWindow(QMainWindow): """Main application window.""" macros_changed = Signal() def __init__(self, app_dir: str): super().__init__() self.app_dir = app_dir 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") os.makedirs(images_dir, exist_ok=True) self.macro_manager = MacroManager(data_file, images_dir, app_dir) # Initialize web server 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() self.setup_tray() # 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) # Load initial data self.refresh_tabs() self.refresh_macros() def setup_ui(self): """Setup the main UI components.""" self.setWindowTitle(f"MacroPad Server v{VERSION}") self.setMinimumSize(600, 400) self.setStyleSheet(f"background-color: {THEME['bg_color']};") # Central widget central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) layout.setSpacing(0) layout.setContentsMargins(0, 0, 0, 0) # Toolbar toolbar = QWidget() toolbar.setStyleSheet(f"background-color: {THEME['highlight_color']};") toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(10, 10, 10, 10) add_btn = QPushButton("+ Add Macro") add_btn.setStyleSheet(f""" QPushButton {{ background-color: {THEME['accent_color']}; color: white; border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold; }} QPushButton:hover {{ background-color: #0096ff; }} """) add_btn.clicked.connect(self.add_macro) toolbar_layout.addWidget(add_btn) toolbar_layout.addStretch() # IP address label self.ip_label = QLabel() self.ip_label.setStyleSheet(f"color: {THEME['fg_color']};") self.update_ip_label() toolbar_layout.addWidget(self.ip_label) qr_btn = QPushButton("QR Code") qr_btn.setStyleSheet(f""" QPushButton {{ background-color: {THEME['button_bg']}; color: white; border: none; padding: 8px 16px; border-radius: 4px; }} QPushButton:hover {{ background-color: {THEME['highlight_color']}; }} """) qr_btn.clicked.connect(self.show_qr_code) toolbar_layout.addWidget(qr_btn) layout.addWidget(toolbar) # Tab widget self.tab_widget = QTabWidget() self.tab_widget.setStyleSheet(f""" QTabWidget::pane {{ border: none; background: {THEME['bg_color']}; }} QTabBar::tab {{ background: {THEME['tab_bg']}; color: {THEME['fg_color']}; padding: 8px 16px; margin-right: 2px; border-top-left-radius: 4px; border-top-right-radius: 4px; }} QTabBar::tab:selected {{ background: {THEME['tab_selected']}; }} QTabBar::tab:hover {{ background: {THEME['highlight_color']}; }} """) self.tab_widget.currentChanged.connect(self.on_tab_changed) layout.addWidget(self.tab_widget) # Status bar self.status_bar = QStatusBar() self.status_bar.setStyleSheet(f""" background-color: {THEME['highlight_color']}; color: {THEME['fg_color']}; """) self.setStatusBar(self.status_bar) self.status_bar.showMessage("Ready") def setup_menu(self): """Setup the menu bar.""" menubar = self.menuBar() menubar.setStyleSheet(f""" QMenuBar {{ background-color: {THEME['highlight_color']}; color: {THEME['fg_color']}; }} QMenuBar::item:selected {{ background-color: {THEME['accent_color']}; }} QMenu {{ background-color: {THEME['highlight_color']}; color: {THEME['fg_color']}; }} QMenu::item:selected {{ background-color: {THEME['accent_color']}; }} """) # File menu file_menu = menubar.addMenu("File") add_action = QAction("Add Macro", self) add_action.setShortcut("Ctrl+N") add_action.triggered.connect(self.add_macro) file_menu.addAction(add_action) 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") refresh_action = QAction("Refresh", self) refresh_action.setShortcut("F5") refresh_action.triggered.connect(self.refresh_all) view_menu.addAction(refresh_action) # Sort submenu sort_menu = view_menu.addMenu("Sort By") for sort_option in [("Name", "name"), ("Type", "type"), ("Recent", "recent")]: action = QAction(sort_option[0], self) action.triggered.connect(lambda checked, s=sort_option[1]: self.set_sort(s)) sort_menu.addAction(action) # Help menu help_menu = menubar.addMenu("Help") about_action = QAction("About", self) about_action.triggered.connect(self.show_about) help_menu.addAction(about_action) def setup_tray(self): """Setup system tray icon.""" self.tray_icon = QSystemTrayIcon(self) # Load icon from bundled resources icon_path = get_resource_path("Macro Pad.png") if os.path.exists(icon_path): icon = QIcon(icon_path) self.tray_icon.setIcon(icon) self.setWindowIcon(icon) else: self.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) # Tray menu tray_menu = QMenu() show_action = tray_menu.addAction("Show") show_action.triggered.connect(self.show) quit_action = tray_menu.addAction("Quit") quit_action.triggered.connect(self.close) self.tray_icon.setContextMenu(tray_menu) self.tray_icon.activated.connect(self.on_tray_activated) self.tray_icon.show() def on_tray_activated(self, reason): """Handle tray icon activation.""" if reason == QSystemTrayIcon.ActivationReason.DoubleClick: self.show() self.activateWindow() def start_server(self): """Start the web server in a background thread.""" self.server_error = None def run(): try: self.web_server.create_app() self.web_server.run() except Exception as e: self.server_error = str(e) # Emit signal to show error on main thread QTimer.singleShot(0, self.show_server_error) self.server_thread = threading.Thread(target=run, daemon=True) self.server_thread.start() # Give the server a moment to start, then check for errors QTimer.singleShot(1000, self.check_server_started) def check_server_started(self): """Check if server started successfully.""" if self.server_error: self.show_server_error() else: self.status_bar.showMessage(f"Server running on port {DEFAULT_PORT}") def show_server_error(self): """Show server error dialog.""" error_msg = self.server_error or "Unknown error" self.status_bar.showMessage(f"Server failed to start") QMessageBox.warning( self, "Web Server Error", f"Failed to start web server on port {DEFAULT_PORT}.\n\n" f"Error: {error_msg}\n\n" "The web interface will not be available.\n" "Check if another application is using the port." ) def stop_server(self): """Stop the web server.""" if self.web_server: self.web_server.stop() self.status_bar.showMessage("Server stopped") 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(): addrs = netifaces.ifaddresses(iface) if netifaces.AF_INET in addrs: for addr in addrs[netifaces.AF_INET]: ip = addr.get('addr', '') if ip and not ip.startswith('127.'): self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}") return except Exception: pass self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}") def refresh_tabs(self): """Refresh the tab widget.""" self.tab_widget.blockSignals(True) self.tab_widget.clear() tabs = self.macro_manager.get_unique_tabs() for tab_name in tabs: scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet(f"background-color: {THEME['bg_color']}; border: none;") container = QWidget() container.setStyleSheet(f"background-color: {THEME['bg_color']};") scroll.setWidget(container) self.tab_widget.addTab(scroll, tab_name) self.tab_widget.blockSignals(False) def refresh_macros(self): """Refresh the macro grid.""" current_index = self.tab_widget.currentIndex() if current_index < 0: return scroll = self.tab_widget.widget(current_index) container = scroll.widget() # Clear existing layout if container.layout(): while container.layout().count(): item = container.layout().takeAt(0) if item.widget(): item.widget().deleteLater() else: container.setLayout(QGridLayout()) layout = container.layout() layout.setSpacing(10) layout.setContentsMargins(10, 10, 10, 10) layout.setAlignment(Qt.AlignTop | Qt.AlignLeft) # Get macros for current tab tab_name = self.tab_widget.tabText(current_index) macro_list = self.macro_manager.get_sorted_macros(self.sort_by) filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name) # Add macro buttons cols = max(1, (self.width() - 40) // 130) for i, (macro_id, macro) in enumerate(filtered): btn = MacroButton(macro_id, macro) btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid)) btn.edit_requested.connect(self.edit_macro) btn.delete_requested.connect(self.delete_macro) layout.addWidget(btn, i // cols, i % cols) def on_tab_changed(self, index): """Handle tab change.""" self.refresh_macros() def execute_macro(self, macro_id: str): """Execute a macro.""" success = self.macro_manager.execute_macro(macro_id) if success: self.status_bar.showMessage("Macro executed", 2000) else: self.status_bar.showMessage("Macro execution failed", 2000) def add_macro(self): """Open dialog to add a new macro.""" from .macro_editor import MacroEditorDialog dialog = MacroEditorDialog(self.macro_manager, parent=self) if dialog.exec_(): self.refresh_tabs() self.refresh_macros() def edit_macro(self, macro_id: str): """Open dialog to edit a macro.""" from .macro_editor import MacroEditorDialog dialog = MacroEditorDialog(self.macro_manager, macro_id=macro_id, parent=self) if dialog.exec_(): self.refresh_tabs() self.refresh_macros() def delete_macro(self, macro_id: str): """Delete a macro with confirmation.""" reply = QMessageBox.question( self, "Delete Macro", "Are you sure you want to delete this macro?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self.macro_manager.delete_macro(macro_id) self.refresh_tabs() self.refresh_macros() def set_sort(self, sort_by: str): """Set the sort order.""" self.sort_by = sort_by self.refresh_macros() def refresh_all(self): """Refresh tabs and macros.""" self.refresh_tabs() self.refresh_macros() def show_qr_code(self): """Show QR code dialog.""" try: import qrcode from PySide6.QtWidgets import QDialog, QVBoxLayout from io import BytesIO url = self.ip_label.text() qr = qrcode.QRCode(version=1, box_size=10, border=4) qr.add_data(url) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") # Convert to QPixmap buffer = BytesIO() img.save(buffer, format="PNG") buffer.seek(0) pixmap = QPixmap() pixmap.loadFromData(buffer.read()) # Show dialog dialog = QDialog(self) dialog.setWindowTitle("QR Code") layout = QVBoxLayout(dialog) label = QLabel() label.setPixmap(pixmap.scaled(300, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation)) layout.addWidget(label) url_label = QLabel(url) url_label.setStyleSheet(f"color: {THEME['fg_color']};") url_label.setAlignment(Qt.AlignCenter) layout.addWidget(url_label) dialog.exec_() except ImportError: QMessageBox.warning(self, "Error", "QR code library not available") def show_about(self): """Show about dialog.""" about_box = QMessageBox(self) about_box.setWindowTitle("About MacroPad Server") about_box.setTextFormat(Qt.RichText) about_box.setTextInteractionFlags(Qt.TextBrowserInteraction) about_box.setText( f"

MacroPad Server v{VERSION}

" "

A cross-platform macro management application
" "with desktop and web interfaces.

" "

Author: ShadowDao

" "

Updates: shadowdao.com

" "

Donate: Liberapay

" ) 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() # Hide tray icon self.tray_icon.hide() event.accept() def resizeEvent(self, event): """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)