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
+45 -4
View File
@@ -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."""