Files
MP-Server/gui/main_window.py
T
shadowdao 2f855bf720 fix: drop netifaces (unbuildable on runner), harden build install
The packaged exe crashed with ModuleNotFoundError: No module named 'PySide6'.
Root cause: `pip install -e .` aborted because netifaces has no wheel for the
build's Python and needs MSVC to compile from source, so NO dependencies were
installed — and the workflow didn't catch it (native-command failures don't
trip $ErrorActionPreference, and the next pip command succeeded).

- Replace netifaces with a dependency-free socket-based LAN IP detection in the
  GUI; remove netifaces from pyproject and all PyInstaller specs.
- Make pip failures fatal (check $LASTEXITCODE) and add a "Verify runtime
  imports" step that fails the build before bundling if any dep is missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:40:34 -07:00

916 lines
32 KiB
Python

# 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, QMenu, QStatusBar,
QMessageBox, QApplication, QSystemTrayIcon, QStyle
)
from PySide6.QtCore import Qt, Signal, QTimer, QEvent
from PySide6.QtGui import QIcon, QPixmap, QAction
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, app_dir: str, 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"):
# Resolve relative image path against app directory
image_path = os.path.join(app_dir, macro["image_path"])
pixmap = QPixmap(image_path)
if not pixmap.isNull():
pixmap = pixmap.scaled(48, 48, Qt.KeepAspectRatio, Qt.SmoothTransformation)
image_label.setPixmap(pixmap)
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()
# Signals for thread-safe relay status updates
relay_session_received = Signal(str)
relay_connected_signal = Signal()
relay_disconnected_signal = Signal()
# Report macro execution result back to the GUI thread (success flag).
macro_execution_finished = Signal(bool)
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
token = self.settings_manager.get_web_auth_token()
host = "0.0.0.0" if self.settings_manager.get_web_allow_lan() else "127.0.0.1"
self.web_server = WebServer(
self.macro_manager, app_dir, DEFAULT_PORT,
auth_token=token, host=host
)
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)
self.relay_session_received.connect(self._handle_relay_session)
self.relay_connected_signal.connect(lambda: self._update_relay_status(True))
self.relay_disconnected_signal.connect(lambda: self._update_relay_status(False))
self.macro_execution_finished.connect(self._on_macro_execution_finished)
# Debounce timer for resize-driven grid rebuilds.
self._resize_timer = QTimer(self)
self._resize_timer.setSingleShot(True)
self._resize_timer.setInterval(150)
self._resize_timer.timeout.connect(self._apply_resize)
self._last_col_count = None
# Load initial data
self.refresh_tabs()
self.refresh_macros()
def setup_ui(self):
"""Setup the main UI components."""
self.setWindowTitle("MacroPad Server")
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: {THEME['accent_hover']};
}}
""")
add_btn.clicked.connect(self.add_macro)
toolbar_layout.addWidget(add_btn)
toolbar_layout.addStretch()
# Clickable IP address label (click to copy)
self.ip_label = QPushButton()
self.ip_label.setCursor(Qt.PointingHandCursor)
self.ip_label.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
}}
""")
self.ip_label.clicked.connect(self.copy_url_to_clipboard)
self.update_ip_label()
toolbar_layout.addWidget(self.ip_label)
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.restore_window)
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.restore_window()
def restore_window(self):
"""Restore and bring window to front."""
# Clear minimized state and show
self.setWindowState(Qt.WindowNoState)
self.show()
self.raise_()
self.activateWindow()
def start_server(self):
"""Start the web server in a background thread."""
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
relay_connected = self.relay_client and self.relay_client.is_connected()
session_id = self.settings_manager.get_relay_session_id()
if relay_connected and session_id:
relay_url = self.settings_manager.get_relay_url()
# Convert wss:// to https:// for display
base_url = relay_url.replace('wss://', 'https://').replace('ws://', 'http://')
base_url = base_url.replace('/desktop', '').rstrip('/')
full_url = f"{base_url}/{session_id}"
self.ip_label.setText(full_url)
return
# Fall back to local IP. Append the auth token as a query param so the
# QR/copied URL lets a LAN device authenticate against the API.
token = self.settings_manager.get_web_auth_token()
token_qs = f"?token={token}" if token else ""
ip = self._detect_lan_ip()
if ip:
self.ip_label.setText(f"http://{ip}:{DEFAULT_PORT}{token_qs}")
return
self.ip_label.setText(f"http://localhost:{DEFAULT_PORT}{token_qs}")
@staticmethod
def _detect_lan_ip():
"""Best-effort primary LAN IPv4, with no third-party dependency.
Uses a UDP socket to discover which local interface would be used to
reach the internet (no packets are actually sent), then falls back to
resolving the hostname. Returns None if only loopback is available.
"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
except Exception:
pass
try:
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
if not ip.startswith("127."):
return ip
except Exception:
pass
return None
def copy_url_to_clipboard(self):
"""Copy the web interface URL to clipboard."""
url = self.ip_label.text()
if url == "Copied!":
return # Already showing copied feedback
clipboard = QApplication.clipboard()
clipboard.setText(url)
# Show "Copied!" feedback with dimmed style
self.ip_label.setText("Copied!")
self.ip_label.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #888888;
border: none;
padding: 4px 8px;
}
""")
# Restore original URL after delay
QTimer.singleShot(1500, self._restore_url_label)
def _restore_url_label(self):
"""Restore URL label after copy feedback."""
self.ip_label.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {THEME['fg_color']};
border: none;
padding: 4px 8px;
}}
""")
self.update_ip_label()
def refresh_tabs(self):
"""Refresh the tab widget."""
self.tab_widget.blockSignals(True)
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 = self._compute_columns()
self._last_col_count = cols
for i, (macro_id, macro) in enumerate(filtered):
btn = MacroButton(macro_id, macro, self.app_dir)
btn.clicked.connect(lambda checked, mid=macro_id: self.execute_macro(mid))
btn.edit_requested.connect(self.edit_macro)
btn.delete_requested.connect(self.delete_macro)
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 off the GUI thread so the UI doesn't freeze.
pyautogui presses and wait steps are blocking; running them on the Qt
main thread would freeze the window. Run in a worker thread and report
the result back via a signal (thread-safe).
"""
self.status_bar.showMessage("Executing macro...", 2000)
def run():
try:
success = self.macro_manager.execute_macro(macro_id)
except Exception:
success = False
self.macro_execution_finished.emit(success)
threading.Thread(target=run, daemon=True).start()
def _on_macro_execution_finished(self, success: bool):
"""Report macro execution result on the GUI thread."""
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."""
macro = self.macro_manager.get_macro(macro_id)
macro_name = macro.get("name", "") if macro else ""
prompt = (
f"Are you sure you want to delete \"{macro_name}\"?"
if macro_name else
"Are you sure you want to delete this macro?"
)
reply = QMessageBox.question(
self, "Delete Macro",
prompt,
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"<h2>MacroPad Server v{VERSION}</h2>"
"<p>A cross-platform macro management application<br>"
"with desktop and web interfaces.</p>"
"<p><b>Author:</b> <a href='https://shadowdao.com'>ShadowDao</a></p>"
"<p><b>Updates:</b> <a href='https://shadowdao.com'>shadowdao.com</a></p>"
"<p><b>Donate:</b> <a href='https://liberapay.com/GoTakeAKnapp/'>Liberapay</a></p>"
)
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,
local_auth_token=self.settings_manager.get_web_auth_token()
)
self.relay_client.start()
self.status_bar.showMessage("Connecting to relay server...")
def stop_relay_client(self):
"""Stop the relay client."""
if self.relay_client:
self.relay_client.stop()
self.relay_client = None
self.status_bar.showMessage("Relay disconnected", 2000)
def on_relay_connected(self):
"""Handle relay connection established (called from background thread)."""
self.relay_connected_signal.emit()
def on_relay_disconnected(self):
"""Handle relay disconnection (called from background thread)."""
self.relay_disconnected_signal.emit()
def on_relay_session_id(self, session_id: str):
"""Handle receiving session ID from relay (called from background thread)."""
self.relay_session_received.emit(session_id)
def _handle_relay_session(self, session_id: str):
"""Handle relay session on main thread."""
if not self.settings_manager.set_relay_session_id(session_id):
self.status_bar.showMessage("Warning: failed to save relay session", 3000)
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 _compute_columns(self) -> int:
"""Compute how many macro columns fit at the current width."""
return max(1, (self.width() - 40) // 130)
def _apply_resize(self):
"""Rebuild the grid only if the column count actually changed."""
if self._compute_columns() != self._last_col_count:
self.refresh_macros()
def resizeEvent(self, event):
"""Handle window resize (debounced to coalesce rapid resize events)."""
super().resizeEvent(event)
# Coalesce a burst of resize events; only rebuild after things settle
# and only when the computed column count changed.
self._resize_timer.start()
def changeEvent(self, event):
"""Handle window state changes - minimize to tray."""
if event.type() == QEvent.Type.WindowStateChange:
if (self.windowState() & Qt.WindowMinimized
and self.settings_manager.get_minimize_to_tray()):
# 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)