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:
+167
-66
@@ -4,6 +4,8 @@ import copy
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
import pyautogui
|
||||
import subprocess
|
||||
@@ -12,37 +14,61 @@ from PIL import Image
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# --- Payload validation for execution ---------------------------------------
|
||||
# Allowlist of key names pyautogui knows how to press. Anything not in this set
|
||||
# is rejected before being handed to pyautogui so arbitrary strings can't be
|
||||
# passed through. Populated from pyautogui when available.
|
||||
VALID_KEYS = frozenset(getattr(pyautogui, "KEYBOARD_KEYS", []) or [])
|
||||
|
||||
# Upper bound (milliseconds) for a single wait step so a macro can't hang the
|
||||
# executor forever.
|
||||
MAX_WAIT_MS = 60000
|
||||
|
||||
# Only persist last_used to disk at most this often (seconds) to avoid rewriting
|
||||
# the whole JSON file on every single execution.
|
||||
LAST_USED_SAVE_INTERVAL = 5.0
|
||||
|
||||
|
||||
class MacroManager:
|
||||
"""Manages macro storage, migration, and execution with command sequences."""
|
||||
"""Manages macro storage, migration, and execution with command sequences.
|
||||
|
||||
Thread-safe: all reads/writes of ``self.macros`` and all disk writes are
|
||||
guarded by ``self._lock``. Public methods acquire the lock and delegate to
|
||||
``*_locked`` internal helpers so we never re-acquire a non-reentrant lock.
|
||||
"""
|
||||
|
||||
def __init__(self, data_file: str, images_dir: str, app_dir: str):
|
||||
self.data_file = data_file
|
||||
self.images_dir = images_dir
|
||||
self.app_dir = app_dir
|
||||
self.macros = {}
|
||||
self._lock = threading.Lock()
|
||||
# Timestamp of the last last_used-triggered save (for debouncing).
|
||||
self._last_used_save_time = 0.0
|
||||
self.load_macros()
|
||||
|
||||
def load_macros(self):
|
||||
"""Load macros from JSON file and migrate if needed."""
|
||||
try:
|
||||
if os.path.exists(self.data_file):
|
||||
with open(self.data_file, "r") as file:
|
||||
self.macros = json.load(file)
|
||||
with self._lock:
|
||||
try:
|
||||
if os.path.exists(self.data_file):
|
||||
with open(self.data_file, "r") as file:
|
||||
self.macros = json.load(file)
|
||||
|
||||
# Migrate old format macros
|
||||
migrated = False
|
||||
for macro_id, macro in list(self.macros.items()):
|
||||
if macro.get("type") != "sequence":
|
||||
self.macros[macro_id] = self._migrate_macro(macro)
|
||||
migrated = True
|
||||
# Migrate old format macros
|
||||
migrated = False
|
||||
for macro_id, macro in list(self.macros.items()):
|
||||
if macro.get("type") != "sequence":
|
||||
self.macros[macro_id] = self._migrate_macro(macro)
|
||||
migrated = True
|
||||
|
||||
if migrated:
|
||||
self.save_macros()
|
||||
print("Migrated macros to new command sequence format")
|
||||
if migrated:
|
||||
self._save_macros_locked()
|
||||
print("Migrated macros to new command sequence format")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading macros: {e}")
|
||||
self.macros = {}
|
||||
except Exception as e:
|
||||
print(f"Error loading macros: {e}")
|
||||
self.macros = {}
|
||||
|
||||
def _migrate_macro(self, old_macro: dict) -> dict:
|
||||
"""Convert old macro format to new command sequence format."""
|
||||
@@ -92,28 +118,56 @@ class MacroManager:
|
||||
}
|
||||
|
||||
def save_macros(self):
|
||||
"""Save macros to JSON file."""
|
||||
"""Save macros to JSON file (thread-safe, atomic)."""
|
||||
with self._lock:
|
||||
self._save_macros_locked()
|
||||
|
||||
def _save_macros_locked(self):
|
||||
"""Atomically write macros to disk. Caller must hold ``self._lock``.
|
||||
|
||||
Writes to a temp file in the same directory then os.replace() over the
|
||||
target so a crash mid-write can't leave a truncated/corrupt JSON file.
|
||||
"""
|
||||
try:
|
||||
with open(self.data_file, "w") as file:
|
||||
json.dump(self.macros, file, indent=4)
|
||||
target_dir = os.path.dirname(self.data_file) or "."
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=".macros-", suffix=".tmp", dir=target_dir
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as file:
|
||||
json.dump(self.macros, file, indent=4)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.replace(tmp_path, self.data_file)
|
||||
except Exception:
|
||||
# Clean up the temp file on any failure
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error saving macros: {e}")
|
||||
|
||||
def get_sorted_macros(self, sort_by: str = "name"):
|
||||
"""Get macros sorted by specified criteria."""
|
||||
macro_list = list(self.macros.items())
|
||||
with self._lock:
|
||||
macro_list = list(self.macros.items())
|
||||
|
||||
if sort_by == "name":
|
||||
macro_list.sort(key=lambda x: x[1]["name"].lower())
|
||||
macro_list.sort(key=lambda x: x[1].get("name", "").lower())
|
||||
elif sort_by == "type":
|
||||
# Sort by first command type in sequence
|
||||
def get_first_type(macro):
|
||||
cmds = macro.get("commands", [])
|
||||
return cmds[0].get("type", "") if cmds else ""
|
||||
macro_list.sort(key=lambda x: (get_first_type(x[1]), x[1]["name"].lower()))
|
||||
macro_list.sort(
|
||||
key=lambda x: (get_first_type(x[1]), x[1].get("name", "").lower())
|
||||
)
|
||||
elif sort_by == "recent":
|
||||
macro_list.sort(
|
||||
key=lambda x: (x[1].get("last_used", 0), x[1]["name"].lower()),
|
||||
key=lambda x: (x[1].get("last_used", 0), x[1].get("name", "").lower()),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
@@ -142,9 +196,10 @@ class MacroManager:
|
||||
tabs = ["All"]
|
||||
categories = set()
|
||||
|
||||
for macro in self.macros.values():
|
||||
if macro.get("category"):
|
||||
categories.add(macro["category"])
|
||||
with self._lock:
|
||||
for macro in self.macros.values():
|
||||
if macro.get("category"):
|
||||
categories.add(macro["category"])
|
||||
|
||||
for category in sorted(categories):
|
||||
if category and category not in tabs:
|
||||
@@ -162,7 +217,7 @@ class MacroManager:
|
||||
"""Add a new macro with command sequence."""
|
||||
macro_id = str(uuid.uuid4())
|
||||
|
||||
# Process image if provided
|
||||
# Process image if provided (file IO; does not touch self.macros)
|
||||
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||
|
||||
# Create macro data (deep copy commands to avoid reference issues)
|
||||
@@ -175,8 +230,9 @@ class MacroManager:
|
||||
"last_used": 0
|
||||
}
|
||||
|
||||
self.macros[macro_id] = macro_data
|
||||
self.save_macros()
|
||||
with self._lock:
|
||||
self.macros[macro_id] = macro_data
|
||||
self._save_macros_locked()
|
||||
return macro_id
|
||||
|
||||
def update_macro(
|
||||
@@ -188,28 +244,32 @@ class MacroManager:
|
||||
image_path: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Update an existing macro."""
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
with self._lock:
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
existing_image = self.macros[macro_id].get("image_path", "")
|
||||
existing_last_used = self.macros[macro_id].get("last_used", 0)
|
||||
|
||||
macro = self.macros[macro_id]
|
||||
|
||||
# Keep old image or update with new one
|
||||
# Keep old image or update with new one (file IO outside the lock)
|
||||
if image_path is not None:
|
||||
image_path_reference = self._process_image(image_path) if image_path else ""
|
||||
else:
|
||||
image_path_reference = macro.get("image_path", "")
|
||||
image_path_reference = existing_image
|
||||
|
||||
# Update macro data (deep copy commands to avoid reference issues)
|
||||
self.macros[macro_id] = {
|
||||
new_data = {
|
||||
"name": name,
|
||||
"type": "sequence",
|
||||
"commands": copy.deepcopy(commands),
|
||||
"category": category,
|
||||
"image_path": image_path_reference,
|
||||
"last_used": macro.get("last_used", 0)
|
||||
"last_used": existing_last_used
|
||||
}
|
||||
|
||||
self.save_macros()
|
||||
with self._lock:
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
self.macros[macro_id] = new_data
|
||||
self._save_macros_locked()
|
||||
return True
|
||||
|
||||
def _process_image(self, image_path: str) -> str:
|
||||
@@ -234,37 +294,50 @@ class MacroManager:
|
||||
|
||||
def delete_macro(self, macro_id: str) -> bool:
|
||||
"""Delete a macro."""
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
with self._lock:
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
|
||||
macro = self.macros[macro_id]
|
||||
macro = self.macros[macro_id]
|
||||
image_path = macro.get("image_path")
|
||||
del self.macros[macro_id]
|
||||
self._save_macros_locked()
|
||||
|
||||
# Delete associated image file
|
||||
if macro.get("image_path"):
|
||||
# Delete associated image file (outside the lock; pure file IO)
|
||||
if image_path:
|
||||
try:
|
||||
img_path = os.path.join(self.app_dir, macro["image_path"])
|
||||
img_path = os.path.join(self.app_dir, image_path)
|
||||
if os.path.exists(img_path):
|
||||
os.remove(img_path)
|
||||
except Exception as e:
|
||||
print(f"Error removing image file: {e}")
|
||||
|
||||
del self.macros[macro_id]
|
||||
self.save_macros()
|
||||
return True
|
||||
|
||||
def execute_macro(self, macro_id: str) -> bool:
|
||||
"""Execute a macro by ID."""
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
"""Execute a macro by ID.
|
||||
|
||||
macro = self.macros[macro_id]
|
||||
The macro's commands are copied under the lock, then executed *outside*
|
||||
the lock so long-running wait/keyboard steps don't block other threads
|
||||
(web server, GUI). last_used is updated in memory and only persisted at
|
||||
most every LAST_USED_SAVE_INTERVAL seconds to avoid rewriting the whole
|
||||
file on every press.
|
||||
"""
|
||||
with self._lock:
|
||||
if macro_id not in self.macros:
|
||||
return False
|
||||
|
||||
# Update last_used timestamp
|
||||
self.macros[macro_id]["last_used"] = time.time()
|
||||
self.save_macros()
|
||||
macro = self.macros[macro_id]
|
||||
commands = copy.deepcopy(macro.get("commands", []))
|
||||
|
||||
# Update last_used in memory; debounce the disk write.
|
||||
now = time.time()
|
||||
macro["last_used"] = now
|
||||
if now - self._last_used_save_time >= LAST_USED_SAVE_INTERVAL:
|
||||
self._last_used_save_time = now
|
||||
self._save_macros_locked()
|
||||
|
||||
try:
|
||||
commands = macro.get("commands", [])
|
||||
for cmd in commands:
|
||||
self._execute_command(cmd)
|
||||
return True
|
||||
@@ -272,6 +345,22 @@ class MacroManager:
|
||||
print(f"Error executing macro: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _valid_keys(keys) -> list:
|
||||
"""Filter a list of key names to those pyautogui recognises."""
|
||||
result = []
|
||||
for k in keys:
|
||||
if not isinstance(k, str):
|
||||
continue
|
||||
name = k.strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
if VALID_KEYS and name not in VALID_KEYS:
|
||||
print(f"Skipping invalid key: {name!r}")
|
||||
continue
|
||||
result.append(name)
|
||||
return result
|
||||
|
||||
def _execute_command(self, cmd: dict):
|
||||
"""Execute a single command from a sequence."""
|
||||
cmd_type = cmd.get("type", "")
|
||||
@@ -294,10 +383,11 @@ class MacroManager:
|
||||
pyautogui.typewrite(value, interval=0.02)
|
||||
|
||||
elif cmd_type == "key":
|
||||
# Press a single key
|
||||
# Press a single key (validated against pyautogui's known keys)
|
||||
key = cmd.get("value", "")
|
||||
if key:
|
||||
pyautogui.press(key)
|
||||
valid = self._valid_keys([key]) if key else []
|
||||
if valid:
|
||||
pyautogui.press(valid[0])
|
||||
|
||||
elif cmd_type == "hotkey":
|
||||
# Press key combination
|
||||
@@ -306,14 +396,22 @@ class MacroManager:
|
||||
# Ensure keys is a list, not a string
|
||||
if isinstance(keys, str):
|
||||
keys = [k.strip().lower() for k in keys.split(",")]
|
||||
# Small delay before hotkey for reliability on Windows
|
||||
time.sleep(0.05)
|
||||
pyautogui.hotkey(*keys, interval=0.05)
|
||||
valid = self._valid_keys(keys)
|
||||
# Only fire if every requested key was valid; a partial hotkey
|
||||
# is worse than nothing.
|
||||
if valid and len(valid) == len([k for k in keys if str(k).strip()]):
|
||||
# Small delay before hotkey for reliability on Windows
|
||||
time.sleep(0.05)
|
||||
pyautogui.hotkey(*valid, interval=0.05)
|
||||
|
||||
elif cmd_type == "wait":
|
||||
# Delay in milliseconds
|
||||
ms = cmd.get("ms", 0)
|
||||
# Delay in milliseconds (bounded, non-negative)
|
||||
try:
|
||||
ms = int(cmd.get("ms", 0))
|
||||
except (TypeError, ValueError):
|
||||
ms = 0
|
||||
if ms > 0:
|
||||
ms = min(ms, MAX_WAIT_MS)
|
||||
time.sleep(ms / 1000.0)
|
||||
|
||||
elif cmd_type == "app":
|
||||
@@ -366,8 +464,11 @@ class MacroManager:
|
||||
|
||||
def get_macro(self, macro_id: str) -> Optional[dict]:
|
||||
"""Get a macro by ID."""
|
||||
return self.macros.get(macro_id)
|
||||
with self._lock:
|
||||
macro = self.macros.get(macro_id)
|
||||
return copy.deepcopy(macro) if macro is not None else None
|
||||
|
||||
def get_all_macros(self) -> dict:
|
||||
"""Get all macros."""
|
||||
return self.macros.copy()
|
||||
with self._lock:
|
||||
return copy.deepcopy(self.macros)
|
||||
|
||||
Reference in New Issue
Block a user