security+robustness(py/gui): thread-safety, upload hardening, keyring
macro_manager: - All macro reads/writes guarded by a lock; macros.json written atomically (temp + fsync + os.replace). Commands are copied under the lock and executed outside it so wait/keyboard steps don't block other threads. - last_used disk writes debounced (>=5s) instead of a full rewrite per press. - Validate key/hotkey names against pyautogui.KEYBOARD_KEYS and clamp wait ms; invalid input is skipped/logged, not blindly executed. web_server: - WebSocket receive loop always disconnects (fixes dead-socket leak); broadcast prunes failed sockets. - POST /api/execute offloaded to a thread executor so a long macro no longer blocks the event loop. - Image upload hardened: 5MB cap (413), real-image validation via Pillow, server-generated name under macro_images/, returns a relative path only, cleans up on failure. gui: - Execute macros off the Qt main thread (signal back to status bar); debounce resizeEvent and only rebuild when the column count changes. - Relay password stored in the OS keyring when available (plaintext JSON blanked; graceful fallback if no backend); delete dialogs name the macro; honor the minimize-to-tray setting; surface save failures. - Add media/system keys (volume, play/pause, track nav) to the macro editor. - THEME gains shared danger/accent-hover colors; drop dead imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-16
@@ -1,19 +1,30 @@
|
||||
# Macro editor dialog with command builder (PySide6)
|
||||
|
||||
import os
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
|
||||
QComboBox, QSpinBox, QMessageBox, QFileDialog, QWidget,
|
||||
QGroupBox, QScrollArea, QCompleter
|
||||
QComboBox, QMessageBox, QFileDialog, QWidget,
|
||||
QGroupBox, QScrollArea
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap, QIcon
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
from config import THEME, IMAGE_EXTENSIONS
|
||||
|
||||
# Selectable single keys offered in the key picker. Includes common media/system
|
||||
# keys pyautogui supports, a near-zero-cost win for a media/stream deck.
|
||||
KEY_CHOICES = [
|
||||
"Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
|
||||
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
|
||||
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
|
||||
# Media / system keys
|
||||
"VolumeUp", "VolumeDown", "VolumeMute",
|
||||
"PlayPause", "NextTrack", "PrevTrack", "Stop",
|
||||
]
|
||||
|
||||
|
||||
class CommandItem(QWidget):
|
||||
"""Widget representing a single command in the list."""
|
||||
@@ -86,7 +97,7 @@ class CommandItem(QWidget):
|
||||
del_btn = QPushButton("X")
|
||||
del_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #dc3545;
|
||||
background-color: {THEME['danger_color']};
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
@@ -94,7 +105,7 @@ class CommandItem(QWidget):
|
||||
min-height: 20px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #c82333;
|
||||
background-color: {THEME['danger_hover']};
|
||||
}}
|
||||
""")
|
||||
del_btn.setFixedSize(30, 28)
|
||||
@@ -212,9 +223,7 @@ class CommandBuilder(QWidget):
|
||||
|
||||
elif cmd_type == "key":
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
|
||||
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
|
||||
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
|
||||
keys = list(KEY_CHOICES)
|
||||
key, ok = QInputDialog.getItem(self, "Key Command", "Select key:", keys, 0, True)
|
||||
if not ok or not key:
|
||||
return
|
||||
@@ -295,9 +304,7 @@ class CommandBuilder(QWidget):
|
||||
cmd["value"] = text
|
||||
|
||||
elif cmd_type == "key":
|
||||
keys = ["Enter", "Tab", "Escape", "Space", "Backspace", "Delete",
|
||||
"Up", "Down", "Left", "Right", "Home", "End", "PageUp", "PageDown",
|
||||
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
|
||||
keys = list(KEY_CHOICES)
|
||||
keys_lower = [k.lower() for k in keys]
|
||||
current = keys_lower.index(cmd.get("value", "enter")) if cmd.get("value") in keys_lower else 0
|
||||
key, ok = QInputDialog.getItem(self, "Edit Key", "Select key:", keys, current, True)
|
||||
@@ -511,7 +518,7 @@ class MacroEditorDialog(QDialog):
|
||||
delete_btn = QPushButton("Delete")
|
||||
delete_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #dc3545;
|
||||
background-color: {THEME['danger_color']};
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
@@ -519,7 +526,7 @@ class MacroEditorDialog(QDialog):
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #c82333;
|
||||
background-color: {THEME['danger_hover']};
|
||||
}}
|
||||
""")
|
||||
delete_btn.clicked.connect(self.delete_macro)
|
||||
@@ -554,7 +561,7 @@ class MacroEditorDialog(QDialog):
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #0096ff;
|
||||
background-color: {THEME['accent_hover']};
|
||||
}}
|
||||
""")
|
||||
save_btn.clicked.connect(self.save_macro)
|
||||
@@ -634,9 +641,15 @@ class MacroEditorDialog(QDialog):
|
||||
|
||||
def delete_macro(self):
|
||||
"""Delete the current macro."""
|
||||
macro_name = self.name_input.text().strip()
|
||||
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",
|
||||
"Are you sure you want to delete this macro?",
|
||||
prompt,
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
|
||||
+60
-12
@@ -21,11 +21,11 @@ def get_resource_path(relative_path):
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QTabWidget, QGridLayout,
|
||||
QScrollArea, QFrame, QMenu, QMenuBar, QStatusBar,
|
||||
QScrollArea, QMenu, QStatusBar,
|
||||
QMessageBox, QApplication, QSystemTrayIcon, QStyle
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QTimer, QSize, QEvent
|
||||
from PySide6.QtGui import QIcon, QPixmap, QAction, QFont
|
||||
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
|
||||
@@ -127,6 +127,8 @@ class MainWindow(QMainWindow):
|
||||
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__()
|
||||
@@ -174,6 +176,14 @@ class MainWindow(QMainWindow):
|
||||
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()
|
||||
@@ -209,7 +219,7 @@ class MainWindow(QMainWindow):
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #0096ff;
|
||||
background-color: {THEME['accent_hover']};
|
||||
}}
|
||||
""")
|
||||
add_btn.clicked.connect(self.add_macro)
|
||||
@@ -555,7 +565,8 @@ class MainWindow(QMainWindow):
|
||||
filtered = self.macro_manager.filter_macros_by_tab(macro_list, tab_name)
|
||||
|
||||
# Add macro buttons
|
||||
cols = max(1, (self.width() - 40) // 130)
|
||||
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))
|
||||
@@ -568,8 +579,25 @@ class MainWindow(QMainWindow):
|
||||
self.refresh_macros()
|
||||
|
||||
def execute_macro(self, macro_id: str):
|
||||
"""Execute a macro."""
|
||||
success = self.macro_manager.execute_macro(macro_id)
|
||||
"""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:
|
||||
@@ -593,9 +621,16 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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",
|
||||
"Are you sure you want to delete this macro?",
|
||||
prompt,
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@@ -738,7 +773,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _handle_relay_session(self, session_id: str):
|
||||
"""Handle relay session on main thread."""
|
||||
self.settings_manager.set_relay_session_id(session_id)
|
||||
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):
|
||||
@@ -825,15 +861,27 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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."""
|
||||
"""Handle window resize (debounced to coalesce rapid resize events)."""
|
||||
super().resizeEvent(event)
|
||||
self.refresh_macros()
|
||||
# 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:
|
||||
if (self.windowState() & Qt.WindowMinimized
|
||||
and self.settings_manager.get_minimize_to_tray()):
|
||||
# Hide instead of minimize (goes to tray)
|
||||
event.ignore()
|
||||
self.hide()
|
||||
|
||||
@@ -123,7 +123,7 @@ class SettingsDialog(QDialog):
|
||||
color: white;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #0096ff;
|
||||
background-color: {THEME['accent_hover']};
|
||||
}}
|
||||
""")
|
||||
save_btn.clicked.connect(self.save_settings)
|
||||
@@ -316,9 +316,15 @@ class SettingsDialog(QDialog):
|
||||
|
||||
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()
|
||||
# Store the password via the keyring-aware setter (also persists the
|
||||
# rest of the settings). Avoid writing the plaintext password to JSON.
|
||||
ok = self.settings_manager.set_relay_password(new_password)
|
||||
if not ok:
|
||||
QMessageBox.warning(
|
||||
self, "Save Warning",
|
||||
"Settings could not be fully saved to disk."
|
||||
)
|
||||
|
||||
if relay_changed:
|
||||
self.relay_settings_changed.emit()
|
||||
|
||||
+45
-4
@@ -5,6 +5,16 @@ import json
|
||||
import secrets
|
||||
from typing import Any, Optional
|
||||
|
||||
# Optional OS keyring for storing the relay password at rest. If the library or
|
||||
# a working backend is unavailable we fall back to the JSON file (now chmod 0600).
|
||||
try:
|
||||
import keyring as _keyring
|
||||
except Exception: # ImportError or any backend import-time failure
|
||||
_keyring = None
|
||||
|
||||
KEYRING_SERVICE = "MacroPad"
|
||||
KEYRING_USER = "relay"
|
||||
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"relay": {
|
||||
@@ -109,13 +119,44 @@ class SettingsManager:
|
||||
return self.get('relay.session_id')
|
||||
|
||||
def get_relay_password(self) -> str:
|
||||
"""Get the relay password."""
|
||||
"""Get the relay password, preferring the OS keyring when available."""
|
||||
if _keyring is not None:
|
||||
try:
|
||||
value = _keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
# Keyring backend missing/broken: fall back to JSON.
|
||||
pass
|
||||
return self.get('relay.password', '')
|
||||
|
||||
def set_relay_session_id(self, session_id: str):
|
||||
"""Store the relay session ID."""
|
||||
def set_relay_password(self, password: str) -> bool:
|
||||
"""Store the relay password, preferring the OS keyring when available.
|
||||
|
||||
When keyring works, the plaintext password is kept out of the JSON file.
|
||||
Never crashes if the keyring backend is missing; falls back to JSON.
|
||||
Returns True on successful persistence.
|
||||
"""
|
||||
password = password or ""
|
||||
stored_in_keyring = False
|
||||
if _keyring is not None:
|
||||
try:
|
||||
_keyring.set_password(KEYRING_SERVICE, KEYRING_USER, password)
|
||||
stored_in_keyring = True
|
||||
except Exception:
|
||||
stored_in_keyring = False
|
||||
|
||||
if stored_in_keyring:
|
||||
# Don't persist plaintext in the JSON when the keyring holds it.
|
||||
self.set('relay.password', "")
|
||||
else:
|
||||
self.set('relay.password', password)
|
||||
return self.save()
|
||||
|
||||
def set_relay_session_id(self, session_id: str) -> bool:
|
||||
"""Store the relay session ID. Returns True on successful save."""
|
||||
self.set('relay.session_id', session_id)
|
||||
self.save()
|
||||
return self.save()
|
||||
|
||||
def get_web_auth_token(self) -> str:
|
||||
"""Get the web API auth token."""
|
||||
|
||||
Reference in New Issue
Block a user