Initial commit: HereIAm mouse movement monitor
- Complete macOS application with PyQt5 GUI - Smart mouse monitoring with configurable timing - System menu bar integration with adaptive theming - Comprehensive build system with PyInstaller - Professional DMG creation for distribution - Full documentation and testing scripts
This commit is contained in:
10
src/__init__.py
Normal file
10
src/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
HereIAm - Mouse Movement Monitor for macOS
|
||||
A sophisticated application that prevents system sleep by intelligently monitoring
|
||||
and managing mouse activity.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Jerico Thomas"
|
||||
__email__ = "jerico@tekop.net"
|
||||
__app_name__ = "HereIAm"
|
||||
43
src/config.py
Normal file
43
src/config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Configuration
|
||||
WAITTIME = 240
|
||||
MovePx = 10
|
||||
StartEnabled = True
|
||||
DEBUG = False
|
||||
|
||||
# Logging configuration
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
|
||||
LOG_FILE = os.path.join(LOG_DIR, 'hereiam.log')
|
||||
|
||||
def setup_logging():
|
||||
"""Setup logging configuration"""
|
||||
# Create logs directory if it doesn't exist
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
# Configure logging
|
||||
log_level = logging.DEBUG if DEBUG else logging.INFO
|
||||
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
|
||||
handlers = [logging.FileHandler(LOG_FILE)]
|
||||
if DEBUG:
|
||||
handlers.append(logging.StreamHandler()) # Also log to console in debug mode
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format=log_format,
|
||||
handlers=handlers
|
||||
)
|
||||
|
||||
# Log startup
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("HereIAm application starting...")
|
||||
logger.info(f"Config: WAITTIME={WAITTIME}, MovePx={MovePx}, StartEnabled={StartEnabled}")
|
||||
|
||||
def get_logger(name):
|
||||
"""Get a logger instance"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
# Add any additional configuration settings or constants here as needed.
|
||||
216
src/main.py
Normal file
216
src/main.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from PyQt5 import QtWidgets, QtGui, QtCore
|
||||
import sys
|
||||
import os
|
||||
from mouse_mover import MouseMover
|
||||
from config import StartEnabled, setup_logging, get_logger
|
||||
from __init__ import __version__, __author__, __email__, __app_name__
|
||||
|
||||
# Setup logging before anything else
|
||||
setup_logging()
|
||||
|
||||
class HereIAmApp:
|
||||
def __init__(self):
|
||||
self.logger = get_logger(__name__)
|
||||
self.logger.info("Initializing HereIAm application")
|
||||
|
||||
self.app = QtWidgets.QApplication(sys.argv)
|
||||
self.app.setQuitOnLastWindowClosed(False) # Keep running when windows close
|
||||
|
||||
# Initialize MouseMover instance
|
||||
self.mouse_mover = MouseMover()
|
||||
|
||||
# Initialize state from config
|
||||
self.mouse_mover_enabled = StartEnabled
|
||||
|
||||
# Initialize icon paths (use absolute paths)
|
||||
self.base_path = os.path.dirname(os.path.dirname(__file__))
|
||||
self.enabled_icon = os.path.join(self.base_path, "assets", "Enabled.icns")
|
||||
self.disabled_light_icon = os.path.join(self.base_path, "assets", "Disabled-Light.icns")
|
||||
self.disabled_dark_icon = os.path.join(self.base_path, "assets", "Disabled-Dark.icns")
|
||||
|
||||
# Verify icon files exist
|
||||
self._verify_icons()
|
||||
|
||||
# Create tray icon
|
||||
self.tray_icon = QtWidgets.QSystemTrayIcon(self.app)
|
||||
self.tray_icon.setToolTip("HereIAm - Mouse Movement Monitor")
|
||||
|
||||
# Check if system tray is available
|
||||
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
|
||||
self.logger.critical("System tray is not available on this system")
|
||||
QtWidgets.QMessageBox.critical(None, "HereIAm",
|
||||
"System tray is not available on this system.")
|
||||
sys.exit(1)
|
||||
|
||||
self.update_icon() # Set initial icon based on theme
|
||||
|
||||
self._create_menu()
|
||||
self._setup_connections()
|
||||
|
||||
self.tray_icon.show()
|
||||
|
||||
# Start mouse movement if enabled in config
|
||||
if StartEnabled:
|
||||
self.mouse_mover.start()
|
||||
|
||||
self.logger.info("HereIAm application initialized successfully")
|
||||
|
||||
def _verify_icons(self):
|
||||
"""Verify that all required icon files exist"""
|
||||
icons = [self.enabled_icon, self.disabled_light_icon, self.disabled_dark_icon]
|
||||
for icon_path in icons:
|
||||
if not os.path.exists(icon_path):
|
||||
self.logger.warning(f"Icon file not found: {icon_path}")
|
||||
|
||||
def _create_menu(self):
|
||||
"""Create the context menu"""
|
||||
self.menu = QtWidgets.QMenu()
|
||||
|
||||
# Add status item (non-clickable)
|
||||
status_text = "Enabled" if self.mouse_mover_enabled else "Disabled"
|
||||
self.status_action = self.menu.addAction(f"Status: {status_text}")
|
||||
self.status_action.setEnabled(False)
|
||||
|
||||
self.menu.addSeparator()
|
||||
|
||||
# Main actions
|
||||
self.enable_action = self.menu.addAction("Enable HereIAm")
|
||||
self.disable_action = self.menu.addAction("Disable HereIAm")
|
||||
|
||||
# Set initial menu state based on StartEnabled config
|
||||
if StartEnabled:
|
||||
self.enable_action.setEnabled(False)
|
||||
self.disable_action.setEnabled(True)
|
||||
else:
|
||||
self.enable_action.setEnabled(True)
|
||||
self.disable_action.setEnabled(False)
|
||||
|
||||
self.menu.addSeparator()
|
||||
|
||||
# Additional options
|
||||
self.about_action = self.menu.addAction("About HereIAm")
|
||||
self.quit_action = self.menu.addAction("Quit")
|
||||
|
||||
# Set the context menu - this provides the most reliable behavior on macOS
|
||||
self.tray_icon.setContextMenu(self.menu)
|
||||
|
||||
def _setup_connections(self):
|
||||
"""Setup all signal connections"""
|
||||
self.tray_icon.activated.connect(self.on_tray_icon_activated)
|
||||
|
||||
# Use lambda to add immediate feedback
|
||||
self.enable_action.triggered.connect(lambda: self._handle_action(self.enable_mouse_movement))
|
||||
self.disable_action.triggered.connect(lambda: self._handle_action(self.disable_mouse_movement))
|
||||
self.about_action.triggered.connect(self.show_about)
|
||||
self.quit_action.triggered.connect(self.quit)
|
||||
|
||||
# Listen for system theme changes
|
||||
self.app.paletteChanged.connect(self.on_theme_changed)
|
||||
|
||||
def _handle_action(self, action_func):
|
||||
"""Handle menu actions with immediate feedback"""
|
||||
self.logger.debug(f"Menu action triggered: {action_func.__name__}")
|
||||
action_func()
|
||||
|
||||
def is_dark_mode(self):
|
||||
"""Detect if the system is in dark mode"""
|
||||
palette = self.app.palette()
|
||||
window_color = palette.color(QtGui.QPalette.Window)
|
||||
# If the window background is dark, we're in dark mode
|
||||
return window_color.lightness() < 128
|
||||
|
||||
def update_icon(self):
|
||||
"""Update the tray icon based on current state and theme"""
|
||||
if self.mouse_mover_enabled:
|
||||
icon_path = self.enabled_icon
|
||||
else:
|
||||
icon_path = self.disabled_dark_icon if self.is_dark_mode() else self.disabled_light_icon
|
||||
|
||||
if os.path.exists(icon_path):
|
||||
self.tray_icon.setIcon(QtGui.QIcon(icon_path))
|
||||
else:
|
||||
self.logger.warning(f"Icon file not found: {icon_path}")
|
||||
|
||||
def _update_status_display(self):
|
||||
"""Update the status display in the menu"""
|
||||
status_text = "Enabled" if self.mouse_mover_enabled else "Disabled"
|
||||
self.status_action.setText(f"Status: {status_text}")
|
||||
|
||||
def on_theme_changed(self):
|
||||
"""Called when system theme changes"""
|
||||
self.logger.debug("System theme changed")
|
||||
self.update_icon()
|
||||
|
||||
def on_tray_icon_activated(self, reason):
|
||||
"""Handle tray icon activation"""
|
||||
self.logger.debug(f"Tray icon activated with reason: {reason}")
|
||||
|
||||
# On macOS, single click usually shows the context menu automatically
|
||||
# We only need to handle double-click for quick toggle
|
||||
if reason == QtWidgets.QSystemTrayIcon.DoubleClick:
|
||||
self.logger.debug("Double click detected - toggling state")
|
||||
if self.mouse_mover_enabled:
|
||||
self.disable_mouse_movement()
|
||||
else:
|
||||
self.enable_mouse_movement()
|
||||
|
||||
def enable_mouse_movement(self):
|
||||
self.logger.info("Enabling mouse movement via user request")
|
||||
self.mouse_mover_enabled = True
|
||||
self.enable_action.setEnabled(False)
|
||||
self.disable_action.setEnabled(True)
|
||||
self._update_status_display()
|
||||
self.update_icon()
|
||||
self.mouse_mover.start()
|
||||
self.logger.debug("Mouse movement enabled successfully")
|
||||
|
||||
def disable_mouse_movement(self):
|
||||
self.logger.info("Disabling mouse movement via user request")
|
||||
self.mouse_mover_enabled = False
|
||||
self.disable_action.setEnabled(False)
|
||||
self.enable_action.setEnabled(True)
|
||||
self._update_status_display()
|
||||
self.update_icon()
|
||||
self.mouse_mover.stop()
|
||||
self.logger.debug("Mouse movement disabled successfully")
|
||||
|
||||
def show_about(self):
|
||||
"""Show about dialog"""
|
||||
about_text = f"""
|
||||
<h3>{__app_name__}</h3>
|
||||
<p>Version {__version__}</p>
|
||||
<p>A macOS application that prevents system sleep by moving the mouse cursor periodically.</p>
|
||||
<p><b>Author:</b> {__author__}</p>
|
||||
<p><b>Email:</b> {__email__}</p>
|
||||
<br>
|
||||
<p><i>Built with PyQt5 and Python</i></p>
|
||||
"""
|
||||
QtWidgets.QMessageBox.about(None, f"About {__app_name__}", about_text)
|
||||
|
||||
def quit(self):
|
||||
self.logger.info("Application quit requested")
|
||||
try:
|
||||
self.mouse_mover.stop()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping mouse mover: {e}")
|
||||
|
||||
self.tray_icon.hide()
|
||||
QtWidgets.QApplication.instance().quit()
|
||||
self.logger.info("Application exited")
|
||||
sys.exit(0)
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
try:
|
||||
app_instance = HereIAmApp()
|
||||
sys.exit(app_instance.app.exec_())
|
||||
except KeyboardInterrupt:
|
||||
print("Application interrupted")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger = get_logger(__name__)
|
||||
logger.critical(f"Fatal application error: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/menu_bar.py
Normal file
0
src/menu_bar.py
Normal file
94
src/mouse_mover.py
Normal file
94
src/mouse_mover.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# filepath: src/mouse_mover.py
|
||||
|
||||
import time
|
||||
import threading
|
||||
import pyautogui
|
||||
from config import WAITTIME, MovePx, get_logger
|
||||
|
||||
class MouseMover:
|
||||
def __init__(self):
|
||||
self.logger = get_logger(__name__)
|
||||
self.countdown = WAITTIME
|
||||
self.mouse_movement_enabled = False
|
||||
self.xold = 0
|
||||
self.yold = 0
|
||||
self.thread = None
|
||||
self.running = False
|
||||
self.logger.debug("MouseMover initialized")
|
||||
|
||||
def _move_mouse(self):
|
||||
"""Move mouse slightly and return to original position"""
|
||||
try:
|
||||
pyautogui.moveRel(0, MovePx, duration=0.5) # Faster movement
|
||||
pyautogui.moveRel(0, -MovePx, duration=0.5)
|
||||
self.logger.debug(f"Mouse moved {MovePx}px vertically and back")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to move mouse: {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the mouse movement monitoring in a separate thread"""
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.mouse_movement_enabled = True
|
||||
self.thread = threading.Thread(target=self._monitor_mouse, daemon=True)
|
||||
self.thread.start()
|
||||
self.logger.info("Mouse movement monitoring started")
|
||||
else:
|
||||
self.logger.warning("Mouse movement monitoring already running")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the mouse movement monitoring"""
|
||||
if self.running:
|
||||
self.logger.info("Stopping mouse movement monitoring...")
|
||||
self.running = False
|
||||
self.mouse_movement_enabled = False
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2) # Increased timeout
|
||||
if self.thread.is_alive():
|
||||
self.logger.warning("Mouse monitoring thread did not stop gracefully")
|
||||
else:
|
||||
self.logger.debug("Mouse monitoring thread stopped successfully")
|
||||
self.logger.info("Mouse movement monitoring stopped")
|
||||
|
||||
def _monitor_mouse(self):
|
||||
"""Main monitoring loop that runs in separate thread"""
|
||||
self.logger.debug("Mouse monitoring thread started")
|
||||
|
||||
while self.running and self.mouse_movement_enabled:
|
||||
try:
|
||||
x, y = pyautogui.position()
|
||||
|
||||
# Check if timer has expired
|
||||
if self.countdown <= 0:
|
||||
self._move_mouse()
|
||||
self.logger.info(f"Timer expired ({WAITTIME}s), mouse moved automatically")
|
||||
self.countdown = WAITTIME
|
||||
continue
|
||||
|
||||
# Check if mouse has moved
|
||||
if x == self.xold and y == self.yold:
|
||||
# Mouse hasn't moved, count down
|
||||
self.logger.debug(f"Mouse idle, countdown: {self.countdown}s")
|
||||
time.sleep(1)
|
||||
self.countdown -= 1
|
||||
else:
|
||||
# Mouse moved, reset timer
|
||||
self.logger.debug(f"Mouse moved to ({x}, {y}), timer reset")
|
||||
self.countdown = WAITTIME
|
||||
self.xold = x
|
||||
self.yold = y
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in mouse monitoring: {e}")
|
||||
break
|
||||
|
||||
self.logger.debug("Mouse monitoring thread ended")
|
||||
|
||||
def get_status(self):
|
||||
"""Get current status information"""
|
||||
return {
|
||||
'running': self.running,
|
||||
'enabled': self.mouse_movement_enabled,
|
||||
'countdown': self.countdown,
|
||||
'thread_alive': self.thread.is_alive() if self.thread else False
|
||||
}
|
||||
40
src/utils.py
Normal file
40
src/utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Security and error handling utilities
|
||||
import pyautogui
|
||||
import logging
|
||||
|
||||
def setup_pyautogui_safety():
|
||||
"""Setup PyAutoGUI safety features"""
|
||||
# Prevent pyautogui from crashing when mouse is moved to corner
|
||||
pyautogui.FAILSAFE = False
|
||||
|
||||
# Set reasonable pause between actions
|
||||
pyautogui.PAUSE = 0.1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug("PyAutoGUI safety features configured")
|
||||
|
||||
def safe_move_mouse(x_offset, y_offset, duration=0.5):
|
||||
"""Safely move mouse with error handling"""
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
# Get current position first
|
||||
current_x, current_y = pyautogui.position()
|
||||
|
||||
# Calculate new position
|
||||
new_x = current_x + x_offset
|
||||
new_y = current_y + y_offset
|
||||
|
||||
# Get screen size for bounds checking
|
||||
screen_width, screen_height = pyautogui.size()
|
||||
|
||||
# Ensure we stay within screen bounds
|
||||
new_x = max(0, min(new_x, screen_width - 1))
|
||||
new_y = max(0, min(new_y, screen_height - 1))
|
||||
|
||||
# Move mouse
|
||||
pyautogui.moveTo(new_x, new_y, duration=duration)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to move mouse: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user