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:
2026-07-17 10:26:22 -07:00
parent 435cc1aca9
commit beba425868
7 changed files with 402 additions and 117 deletions
+29 -16
View File
@@ -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: