1.0.0 Release
This commit is contained in:
@@ -1,43 +0,0 @@
|
||||
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.
|
||||
71
src/config_manager.py
Normal file
71
src/config_manager.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from logging_config import get_logger
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manage application configuration settings"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = get_logger(__name__)
|
||||
self.config_dir = os.path.join(os.path.expanduser("~"), ".hereiam")
|
||||
self.config_file = os.path.join(self.config_dir, "config.json")
|
||||
|
||||
# Default settings
|
||||
self.default_config = {
|
||||
'wait_time': 240,
|
||||
'start_enabled': True,
|
||||
'log_level': 'INFO',
|
||||
'move_px': 10
|
||||
}
|
||||
|
||||
# Ensure config directory exists
|
||||
os.makedirs(self.config_dir, exist_ok=True)
|
||||
|
||||
def load_config(self):
|
||||
"""Load configuration from file"""
|
||||
try:
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
# Ensure all default keys exist
|
||||
for key, value in self.default_config.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
self.logger.debug(f"Configuration loaded: {config}")
|
||||
return config
|
||||
else:
|
||||
self.logger.info("No config file found, using defaults")
|
||||
return self.default_config.copy()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading config: {e}")
|
||||
return self.default_config.copy()
|
||||
|
||||
def save_config(self, config):
|
||||
"""Save configuration to file"""
|
||||
try:
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
self.logger.info(f"Configuration saved: {config}")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving config: {e}")
|
||||
return False
|
||||
|
||||
def get_config_value(self, key, default=None):
|
||||
"""Get a specific configuration value"""
|
||||
config = self.load_config()
|
||||
return config.get(key, default)
|
||||
|
||||
def set_config_value(self, key, value):
|
||||
"""Set a specific configuration value"""
|
||||
config = self.load_config()
|
||||
config[key] = value
|
||||
return self.save_config(config)
|
||||
|
||||
def update_config(self, updates):
|
||||
"""Update multiple configuration values"""
|
||||
config = self.load_config()
|
||||
config.update(updates)
|
||||
return self.save_config(config)
|
||||
164
src/launch_agent.py
Normal file
164
src/launch_agent.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
macOS Launch Agent management for HereIAm application.
|
||||
Handles creating, installing, and removing Launch Agents for startup functionality.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
from logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LaunchAgentManager:
|
||||
"""Manages macOS Launch Agents for application startup"""
|
||||
|
||||
def __init__(self, app_name="HereIAm"):
|
||||
self.app_name = app_name
|
||||
self.bundle_id = f"com.hereiam.{app_name.lower()}"
|
||||
self.launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
|
||||
self.plist_filename = f"{self.bundle_id}.plist"
|
||||
self.plist_path = self.launch_agents_dir / self.plist_filename
|
||||
|
||||
# Ensure LaunchAgents directory exists
|
||||
self.launch_agents_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_app_path(self):
|
||||
"""Get the path to the current application executable"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running as PyInstaller bundle
|
||||
# Get the .app bundle path from the executable path
|
||||
executable_path = sys.executable
|
||||
# Navigate up from Contents/MacOS/HereIAm to get HereIAm.app
|
||||
app_bundle = Path(executable_path).parent.parent.parent
|
||||
return str(app_bundle)
|
||||
else:
|
||||
# Running in development mode - not suitable for launch agent
|
||||
# Return the Python script path for development testing
|
||||
return sys.argv[0]
|
||||
|
||||
def create_launch_agent_plist(self):
|
||||
"""Create the Launch Agent plist configuration"""
|
||||
app_path = self.get_app_path()
|
||||
|
||||
# For .app bundles, use 'open' command to launch properly
|
||||
if app_path.endswith('.app'):
|
||||
program_arguments = ['/usr/bin/open', '-a', app_path]
|
||||
else:
|
||||
# For development mode
|
||||
program_arguments = [sys.executable, app_path]
|
||||
|
||||
plist_data = {
|
||||
'Label': self.bundle_id,
|
||||
'ProgramArguments': program_arguments,
|
||||
'RunAtLoad': True,
|
||||
'KeepAlive': False,
|
||||
'StandardOutPath': str(Path.home() / '.hereiam' / 'logs' / 'launchd.log'),
|
||||
'StandardErrorPath': str(Path.home() / '.hereiam' / 'logs' / 'launchd-error.log'),
|
||||
}
|
||||
|
||||
return plist_data
|
||||
|
||||
def is_startup_enabled(self):
|
||||
"""Check if startup is currently enabled"""
|
||||
return self.plist_path.exists()
|
||||
|
||||
def enable_startup(self):
|
||||
"""Enable application startup at login"""
|
||||
try:
|
||||
logger.info("Enabling startup at login...")
|
||||
|
||||
# Create the plist data
|
||||
plist_data = self.create_launch_agent_plist()
|
||||
|
||||
# Write the plist file
|
||||
with open(self.plist_path, 'wb') as f:
|
||||
plistlib.dump(plist_data, f)
|
||||
|
||||
logger.info(f"Launch Agent created at: {self.plist_path}")
|
||||
|
||||
# Load the launch agent immediately
|
||||
self._load_launch_agent()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enable startup: {e}")
|
||||
return False
|
||||
|
||||
def disable_startup(self):
|
||||
"""Disable application startup at login"""
|
||||
try:
|
||||
logger.info("Disabling startup at login...")
|
||||
|
||||
# Unload the launch agent if it's loaded
|
||||
self._unload_launch_agent()
|
||||
|
||||
# Remove the plist file
|
||||
if self.plist_path.exists():
|
||||
self.plist_path.unlink()
|
||||
logger.info(f"Launch Agent removed: {self.plist_path}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to disable startup: {e}")
|
||||
return False
|
||||
|
||||
def _load_launch_agent(self):
|
||||
"""Load the launch agent using launchctl"""
|
||||
try:
|
||||
import subprocess
|
||||
cmd = ['launchctl', 'load', str(self.plist_path)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.debug("Launch Agent loaded successfully")
|
||||
else:
|
||||
logger.warning(f"Failed to load Launch Agent: {result.stderr}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading Launch Agent: {e}")
|
||||
|
||||
def _unload_launch_agent(self):
|
||||
"""Unload the launch agent using launchctl"""
|
||||
try:
|
||||
import subprocess
|
||||
cmd = ['launchctl', 'unload', str(self.plist_path)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.debug("Launch Agent unloaded successfully")
|
||||
else:
|
||||
# It's okay if unload fails (agent might not be loaded)
|
||||
logger.debug(f"Launch Agent unload result: {result.stderr}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error unloading Launch Agent: {e}")
|
||||
|
||||
def get_launch_agent_info(self):
|
||||
"""Get information about the current launch agent configuration"""
|
||||
if not self.is_startup_enabled():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(self.plist_path, 'rb') as f:
|
||||
plist_data = plistlib.load(f)
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'path': str(self.plist_path),
|
||||
'program_arguments': plist_data.get('ProgramArguments', []),
|
||||
'label': plist_data.get('Label', ''),
|
||||
'run_at_load': plist_data.get('RunAtLoad', False)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading launch agent info: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_launch_agent_manager():
|
||||
"""Factory function to get a LaunchAgentManager instance"""
|
||||
return LaunchAgentManager()
|
||||
71
src/logging_config.py
Normal file
71
src/logging_config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Logging configuration - use same directory as config file
|
||||
def get_log_directory():
|
||||
"""Get the appropriate log directory based on runtime environment"""
|
||||
# Use the same directory structure as config manager
|
||||
config_dir = os.path.join(os.path.expanduser("~"), ".hereiam")
|
||||
log_dir = os.path.join(config_dir, "logs")
|
||||
|
||||
return log_dir
|
||||
|
||||
LOG_DIR = get_log_directory()
|
||||
LOG_FILE = os.path.join(LOG_DIR, 'hereiam.log')
|
||||
|
||||
def setup_logging(debug=False, force_reconfigure=False):
|
||||
"""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'
|
||||
|
||||
# Get the root logger
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
# If we need to reconfigure, remove existing handlers
|
||||
if force_reconfigure and root_logger.handlers:
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
# Only configure if no handlers exist or we're forcing reconfiguration
|
||||
if not root_logger.handlers or force_reconfigure:
|
||||
# Create formatter
|
||||
formatter = logging.Formatter(log_format)
|
||||
|
||||
# Create file handler
|
||||
file_handler = logging.FileHandler(LOG_FILE)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Add console handler if debug mode
|
||||
if debug:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Set the root logger level
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Log startup with config directory info
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"HereIAm logging configured - Level: {'DEBUG' if debug else 'INFO'}")
|
||||
logger.info(f"Log directory: {LOG_DIR}")
|
||||
logger.debug(f"Log file: {LOG_FILE}")
|
||||
|
||||
def get_logger(name):
|
||||
"""Get a logger instance"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
def get_log_file_path():
|
||||
"""Get the current log file path"""
|
||||
return LOG_FILE
|
||||
|
||||
def get_log_directory_path():
|
||||
"""Get the current log directory path"""
|
||||
return LOG_DIR
|
||||
399
src/main.py
399
src/main.py
@@ -1,29 +1,61 @@
|
||||
from PyQt5 import QtWidgets, QtGui, QtCore
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
from mouse_mover import MouseMover
|
||||
from config import StartEnabled, setup_logging, get_logger
|
||||
from logging_config import setup_logging, get_logger
|
||||
from config_manager import ConfigManager
|
||||
from options_dialog import OptionsDialog
|
||||
from launch_agent import get_launch_agent_manager
|
||||
from __init__ import __version__, __author__, __email__, __app_name__
|
||||
|
||||
# Setup logging before anything else
|
||||
# We'll setup with default first, then update after loading config
|
||||
setup_logging()
|
||||
|
||||
class HereIAmApp:
|
||||
def __init__(self):
|
||||
# Initialize configuration manager first
|
||||
self.config_manager = ConfigManager()
|
||||
self.config = self.config_manager.load_config()
|
||||
|
||||
# Update logging based on config with force reconfigure
|
||||
debug_mode = self.config.get('log_level', 'INFO').upper() == 'DEBUG'
|
||||
setup_logging(debug=debug_mode, force_reconfigure=True)
|
||||
|
||||
# Now get logger after proper configuration
|
||||
self.logger = get_logger(__name__)
|
||||
self.logger.info("Initializing HereIAm application")
|
||||
self.logger.info(f"Config loaded: {self.config}")
|
||||
|
||||
self.app = QtWidgets.QApplication(sys.argv)
|
||||
self.app.setQuitOnLastWindowClosed(False) # Keep running when windows close
|
||||
|
||||
# Initialize MouseMover instance
|
||||
self.mouse_mover = MouseMover()
|
||||
# Initialize MouseMover instance with configured wait time and move distance
|
||||
self.mouse_mover = MouseMover(
|
||||
wait_time=self.config.get('wait_time', 240),
|
||||
move_px=self.config.get('move_px', 10)
|
||||
)
|
||||
|
||||
# Initialize state from config
|
||||
self.mouse_mover_enabled = StartEnabled
|
||||
# Initialize state from config (prioritize saved config over default)
|
||||
self.mouse_mover_enabled = self.config.get('start_enabled', True)
|
||||
|
||||
# Initialize launch agent manager
|
||||
self.launch_agent_manager = get_launch_agent_manager()
|
||||
|
||||
# Dialog management
|
||||
self.options_dialog = None
|
||||
|
||||
# Initialize icon paths (use absolute paths)
|
||||
self.base_path = os.path.dirname(os.path.dirname(__file__))
|
||||
# Handle PyInstaller bundle paths
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running in a PyInstaller bundle
|
||||
self.base_path = sys._MEIPASS
|
||||
else:
|
||||
# Running in development mode
|
||||
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")
|
||||
@@ -50,17 +82,20 @@ class HereIAmApp:
|
||||
self.tray_icon.show()
|
||||
|
||||
# Start mouse movement if enabled in config
|
||||
if StartEnabled:
|
||||
if self.mouse_mover_enabled:
|
||||
self.mouse_mover.start()
|
||||
|
||||
self.logger.info("HereIAm application initialized successfully")
|
||||
|
||||
def _verify_icons(self):
|
||||
"""Verify that all required icon files exist"""
|
||||
self.logger.debug(f"Base path for icons: {self.base_path}")
|
||||
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}")
|
||||
else:
|
||||
self.logger.debug(f"Icon file found: {icon_path}")
|
||||
|
||||
def _create_menu(self):
|
||||
"""Create the context menu"""
|
||||
@@ -77,8 +112,8 @@ class HereIAmApp:
|
||||
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:
|
||||
# Set initial menu state based on current config
|
||||
if self.mouse_mover_enabled:
|
||||
self.enable_action.setEnabled(False)
|
||||
self.disable_action.setEnabled(True)
|
||||
else:
|
||||
@@ -88,6 +123,7 @@ class HereIAmApp:
|
||||
self.menu.addSeparator()
|
||||
|
||||
# Additional options
|
||||
self.options_action = self.menu.addAction("Options...")
|
||||
self.about_action = self.menu.addAction("About HereIAm")
|
||||
self.quit_action = self.menu.addAction("Quit")
|
||||
|
||||
@@ -101,7 +137,8 @@ class HereIAmApp:
|
||||
# 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.options_action.triggered.connect(self.show_options)
|
||||
self.about_action.triggered.connect(lambda: self._handle_action(self.show_about))
|
||||
self.quit_action.triggered.connect(self.quit)
|
||||
|
||||
# Listen for system theme changes
|
||||
@@ -126,10 +163,34 @@ class HereIAmApp:
|
||||
else:
|
||||
icon_path = self.disabled_dark_icon if self.is_dark_mode() else self.disabled_light_icon
|
||||
|
||||
self.logger.debug(f"Attempting to set icon: {icon_path}")
|
||||
|
||||
if os.path.exists(icon_path):
|
||||
self.tray_icon.setIcon(QtGui.QIcon(icon_path))
|
||||
try:
|
||||
icon = QtGui.QIcon(icon_path)
|
||||
if not icon.isNull():
|
||||
self.tray_icon.setIcon(icon)
|
||||
self.logger.debug(f"Successfully set icon: {icon_path}")
|
||||
else:
|
||||
self.logger.warning(f"Icon file exists but is invalid: {icon_path}")
|
||||
self._set_fallback_icon()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting icon {icon_path}: {e}")
|
||||
self._set_fallback_icon()
|
||||
else:
|
||||
self.logger.warning(f"Icon file not found: {icon_path}")
|
||||
self._set_fallback_icon()
|
||||
|
||||
def _set_fallback_icon(self):
|
||||
"""Set a fallback system icon if custom icons fail"""
|
||||
try:
|
||||
# Use a standard system icon as fallback
|
||||
style = self.app.style()
|
||||
icon = style.standardIcon(QtWidgets.QStyle.SP_ComputerIcon)
|
||||
self.tray_icon.setIcon(icon)
|
||||
self.logger.info("Using fallback system icon")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to set fallback icon: {e}")
|
||||
|
||||
def _update_status_display(self):
|
||||
"""Update the status display in the menu"""
|
||||
@@ -174,22 +235,318 @@ class HereIAmApp:
|
||||
self.mouse_mover.stop()
|
||||
self.logger.debug("Mouse movement disabled successfully")
|
||||
|
||||
def show_options(self):
|
||||
"""Show options dialog"""
|
||||
self.logger.debug("Opening options dialog")
|
||||
|
||||
# Prevent multiple dialog instances
|
||||
if self.options_dialog is not None:
|
||||
try:
|
||||
# Check if dialog is still valid and visible
|
||||
if hasattr(self.options_dialog, 'isVisible') and self.options_dialog.isVisible():
|
||||
self.logger.debug("Options dialog already open, bringing to front")
|
||||
self.options_dialog.raise_()
|
||||
self.options_dialog.activateWindow()
|
||||
return
|
||||
else:
|
||||
# Dialog exists but not visible, clean it up
|
||||
self._cleanup_options_dialog()
|
||||
except RuntimeError:
|
||||
# Dialog object is invalid, clean up reference
|
||||
self.options_dialog = None
|
||||
|
||||
# Get current values
|
||||
current_wait_time = self.config.get('wait_time', 240)
|
||||
current_start_enabled = self.config.get('start_enabled', True)
|
||||
current_log_level = self.config.get('log_level', 'INFO')
|
||||
current_move_px = self.config.get('move_px', 10)
|
||||
current_launch_at_startup = self.launch_agent_manager.is_startup_enabled()
|
||||
|
||||
# Create a new dialog instance
|
||||
try:
|
||||
self.options_dialog = OptionsDialog(
|
||||
current_wait_time=current_wait_time,
|
||||
current_start_enabled=current_start_enabled,
|
||||
current_log_level=current_log_level,
|
||||
current_move_px=current_move_px,
|
||||
current_launch_at_startup=current_launch_at_startup,
|
||||
parent=None # Use None as parent for system tray apps
|
||||
)
|
||||
|
||||
# Connect finished signal to cleanup (but handle cleanup manually)
|
||||
self.options_dialog.finished.connect(self._on_options_dialog_finished)
|
||||
|
||||
# Show dialog and get result
|
||||
result = self.options_dialog.exec_()
|
||||
|
||||
# Get values immediately after exec_() while dialog is still valid
|
||||
new_values = None
|
||||
if result == QtWidgets.QDialog.Accepted:
|
||||
try:
|
||||
new_values = self.options_dialog.get_values()
|
||||
if new_values is not None:
|
||||
self.logger.info(f"Options updated: {new_values}")
|
||||
else:
|
||||
self.logger.error("Failed to get values from options dialog")
|
||||
except (RuntimeError, AttributeError) as e:
|
||||
self.logger.error(f"Error getting dialog values: {e}")
|
||||
new_values = None
|
||||
|
||||
# Process the saved values if dialog was accepted and values are valid
|
||||
if result == QtWidgets.QDialog.Accepted and new_values is not None:
|
||||
# Check if restart is needed (start_enabled changed)
|
||||
restart_needed = new_values['start_enabled'] != current_start_enabled
|
||||
if restart_needed:
|
||||
self.logger.info(f"Start enabled changed from {current_start_enabled} to {new_values['start_enabled']}, restart required")
|
||||
|
||||
# Handle launch at startup setting
|
||||
if 'launch_at_startup' in new_values:
|
||||
self._handle_startup_setting(new_values['launch_at_startup'])
|
||||
|
||||
# Save new configuration (excluding launch_at_startup as it's not a config setting)
|
||||
config_values = {k: v for k, v in new_values.items() if k != 'launch_at_startup'}
|
||||
self.config.update(config_values)
|
||||
self.config_manager.save_config(self.config)
|
||||
|
||||
# Apply changes immediately where possible
|
||||
if new_values['wait_time'] != current_wait_time:
|
||||
self.mouse_mover.update_wait_time(new_values['wait_time'])
|
||||
self.logger.debug(f"Wait time updated to {new_values['wait_time']} seconds")
|
||||
|
||||
if new_values['move_px'] != current_move_px:
|
||||
self.mouse_mover.update_move_px(new_values['move_px'])
|
||||
self.logger.debug(f"Move distance updated to {new_values['move_px']} pixels")
|
||||
|
||||
# Update log level
|
||||
if new_values['log_level'] != current_log_level:
|
||||
self._update_log_level(new_values['log_level'])
|
||||
|
||||
# Restart application if needed
|
||||
if restart_needed:
|
||||
self.logger.info("Start enabled setting changed, restarting application...")
|
||||
self.restart_application()
|
||||
else:
|
||||
self.logger.debug("Settings applied immediately, no restart needed")
|
||||
else:
|
||||
self.logger.debug("Options dialog cancelled")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error showing options dialog: {e}")
|
||||
QtWidgets.QMessageBox.critical(
|
||||
None,
|
||||
"Error",
|
||||
f"Failed to open options dialog: {str(e)}"
|
||||
)
|
||||
finally:
|
||||
# Always ensure cleanup happens
|
||||
self._cleanup_options_dialog()
|
||||
|
||||
def _on_options_dialog_finished(self, result):
|
||||
"""Called when options dialog is finished"""
|
||||
self.logger.debug(f"Options dialog finished with result: {result}")
|
||||
# Note: Cleanup is handled in show_options() method to avoid timing issues
|
||||
|
||||
def _cleanup_options_dialog(self):
|
||||
"""Clean up options dialog reference"""
|
||||
if self.options_dialog is not None:
|
||||
try:
|
||||
# Disconnect signals to prevent issues
|
||||
self.options_dialog.finished.disconnect()
|
||||
except (TypeError, RuntimeError, AttributeError):
|
||||
# Signal already disconnected, object deleted, or doesn't exist
|
||||
pass
|
||||
|
||||
try:
|
||||
# Only call deleteLater if the object is still valid
|
||||
self.options_dialog.deleteLater()
|
||||
except RuntimeError:
|
||||
# Object already deleted by Qt
|
||||
pass
|
||||
|
||||
# Always clear the reference regardless of errors
|
||||
self.options_dialog = None
|
||||
|
||||
def _update_log_level(self, log_level_str):
|
||||
"""Update the current log level"""
|
||||
try:
|
||||
log_level = getattr(logging, log_level_str.upper())
|
||||
|
||||
# Update all loggers
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# If switching to DEBUG, add console handler if not present
|
||||
# If switching away from DEBUG, remove console handler
|
||||
debug_mode = log_level_str.upper() == 'DEBUG'
|
||||
|
||||
# Force reconfigure logging to handle console output properly
|
||||
setup_logging(debug=debug_mode, force_reconfigure=True)
|
||||
|
||||
self.logger.info(f"Log level updated to {log_level_str}")
|
||||
except AttributeError:
|
||||
self.logger.error(f"Invalid log level: {log_level_str}")
|
||||
|
||||
def _handle_startup_setting(self, enable_startup):
|
||||
"""Handle enabling or disabling startup at login"""
|
||||
try:
|
||||
current_startup_enabled = self.launch_agent_manager.is_startup_enabled()
|
||||
|
||||
if enable_startup and not current_startup_enabled:
|
||||
# Enable startup
|
||||
success = self.launch_agent_manager.enable_startup()
|
||||
if success:
|
||||
self.logger.info("Successfully enabled startup at login")
|
||||
else:
|
||||
self.logger.error("Failed to enable startup at login")
|
||||
# Show error message to user
|
||||
QtWidgets.QMessageBox.warning(
|
||||
None,
|
||||
"Startup Configuration",
|
||||
"Failed to enable startup at login. Please check the logs for details."
|
||||
)
|
||||
|
||||
elif not enable_startup and current_startup_enabled:
|
||||
# Disable startup
|
||||
success = self.launch_agent_manager.disable_startup()
|
||||
if success:
|
||||
self.logger.info("Successfully disabled startup at login")
|
||||
else:
|
||||
self.logger.error("Failed to disable startup at login")
|
||||
# Show error message to user
|
||||
QtWidgets.QMessageBox.warning(
|
||||
None,
|
||||
"Startup Configuration",
|
||||
"Failed to disable startup at login. Please check the logs for details."
|
||||
)
|
||||
else:
|
||||
self.logger.debug(f"Startup setting unchanged: {enable_startup}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling startup setting: {e}")
|
||||
QtWidgets.QMessageBox.critical(
|
||||
None,
|
||||
"Startup Configuration Error",
|
||||
f"An error occurred while configuring startup settings:\n{str(e)}"
|
||||
)
|
||||
|
||||
def restart_application(self):
|
||||
"""Restart the application"""
|
||||
self.logger.info("Restarting application...")
|
||||
|
||||
try:
|
||||
# Stop the mouse mover
|
||||
self.mouse_mover.stop()
|
||||
|
||||
# Hide the tray icon
|
||||
self.tray_icon.hide()
|
||||
|
||||
# Get the current script path
|
||||
script_path = os.path.abspath(__file__)
|
||||
|
||||
# If running as a module, try to restart via module
|
||||
if __name__ != "__main__":
|
||||
# Try to restart via python -m or the original command
|
||||
args = [sys.executable] + sys.argv
|
||||
else:
|
||||
# Direct script execution
|
||||
args = [sys.executable, script_path]
|
||||
|
||||
self.logger.debug(f"Restarting with command: {' '.join(args)}")
|
||||
|
||||
# Start a new instance of the application
|
||||
subprocess.Popen(args, cwd=os.getcwd())
|
||||
|
||||
# Exit current instance
|
||||
self.logger.info("New instance started, exiting current instance")
|
||||
QtWidgets.QApplication.instance().quit()
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error restarting application: {e}")
|
||||
# Fallback to regular quit if restart fails
|
||||
self.quit()
|
||||
|
||||
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)
|
||||
self.logger.debug("Opening about dialog")
|
||||
try:
|
||||
from logging_config import get_log_file_path
|
||||
log_path = get_log_file_path()
|
||||
|
||||
# Get startup status
|
||||
startup_status = "Enabled" if self.launch_agent_manager.is_startup_enabled() else "Disabled"
|
||||
|
||||
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><b>Launch at Startup:</b> {startup_status}</p>
|
||||
<p><b>Log File:</b> {log_path}</p>
|
||||
<p><i>Logs are stored in ~/.hereiam/logs/ directory</i></p>
|
||||
<br>
|
||||
<p><i>Built with PyQt5 and Python</i></p>
|
||||
"""
|
||||
self.logger.debug(f"About dialog text prepared: {about_text[:100]}...")
|
||||
|
||||
# Create and show the about dialog with explicit parent handling
|
||||
msg_box = QtWidgets.QMessageBox()
|
||||
msg_box.setWindowTitle(f"About {__app_name__}")
|
||||
msg_box.setText(about_text)
|
||||
msg_box.setTextFormat(QtCore.Qt.RichText)
|
||||
msg_box.setIcon(QtWidgets.QMessageBox.Information)
|
||||
|
||||
# Ensure dialog appears on top and is properly managed
|
||||
msg_box.setWindowFlags(
|
||||
QtCore.Qt.Dialog |
|
||||
QtCore.Qt.WindowStaysOnTopHint |
|
||||
QtCore.Qt.WindowSystemMenuHint |
|
||||
QtCore.Qt.WindowTitleHint
|
||||
)
|
||||
|
||||
# Center the dialog on screen
|
||||
screen = QtWidgets.QApplication.primaryScreen()
|
||||
if screen:
|
||||
screen_geometry = screen.geometry()
|
||||
msg_box.resize(450, 350) # Slightly larger to accommodate log path
|
||||
dialog_geometry = msg_box.geometry()
|
||||
x = (screen_geometry.width() - dialog_geometry.width()) // 2
|
||||
y = (screen_geometry.height() - dialog_geometry.height()) // 2
|
||||
msg_box.move(x, y)
|
||||
|
||||
self.logger.debug("Showing about dialog")
|
||||
msg_box.exec_()
|
||||
self.logger.debug("About dialog closed")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error showing about dialog: {e}")
|
||||
# Fallback to simple message
|
||||
try:
|
||||
QtWidgets.QMessageBox.information(
|
||||
None,
|
||||
f"About {__app_name__}",
|
||||
f"{__app_name__} - Version {__version__}\nBy {__author__}"
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
self.logger.error(f"Fallback about dialog also failed: {fallback_error}")
|
||||
# Last resort - show a notification
|
||||
self.tray_icon.showMessage(
|
||||
f"About {__app_name__}",
|
||||
f"Version {__version__} by {__author__}",
|
||||
QtWidgets.QSystemTrayIcon.Information,
|
||||
5000
|
||||
)
|
||||
|
||||
def quit(self):
|
||||
self.logger.info("Application quit requested")
|
||||
try:
|
||||
# Clean up options dialog if open
|
||||
if self.options_dialog is not None:
|
||||
self.options_dialog.close()
|
||||
self._cleanup_options_dialog()
|
||||
|
||||
self.mouse_mover.stop()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping mouse mover: {e}")
|
||||
|
||||
@@ -3,25 +3,27 @@
|
||||
import time
|
||||
import threading
|
||||
import pyautogui
|
||||
from config import WAITTIME, MovePx, get_logger
|
||||
from logging_config import get_logger
|
||||
|
||||
class MouseMover:
|
||||
def __init__(self):
|
||||
def __init__(self, wait_time=240, move_px=10):
|
||||
self.logger = get_logger(__name__)
|
||||
self.countdown = WAITTIME
|
||||
self.wait_time = wait_time
|
||||
self.move_px = move_px
|
||||
self.countdown = self.wait_time
|
||||
self.mouse_movement_enabled = False
|
||||
self.xold = 0
|
||||
self.yold = 0
|
||||
self.thread = None
|
||||
self.running = False
|
||||
self.logger.debug("MouseMover initialized")
|
||||
self.logger.debug(f"MouseMover initialized with wait_time={self.wait_time}, move_px={self.move_px}")
|
||||
|
||||
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")
|
||||
pyautogui.moveRel(0, self.move_px, duration=0.5) # Faster movement
|
||||
pyautogui.moveRel(0, -self.move_px, duration=0.5)
|
||||
self.logger.debug(f"Mouse moved {self.move_px}px vertically and back")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to move mouse: {e}")
|
||||
|
||||
@@ -32,7 +34,7 @@ class MouseMover:
|
||||
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")
|
||||
self.logger.info(f"Mouse movement monitoring started (wait_time={self.wait_time}s, move_px={self.move_px}px)")
|
||||
else:
|
||||
self.logger.warning("Mouse movement monitoring already running")
|
||||
|
||||
@@ -61,8 +63,8 @@ class MouseMover:
|
||||
# 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
|
||||
self.logger.info(f"Timer expired ({self.wait_time}s), mouse moved automatically")
|
||||
self.countdown = self.wait_time
|
||||
continue
|
||||
|
||||
# Check if mouse has moved
|
||||
@@ -74,7 +76,7 @@ class MouseMover:
|
||||
else:
|
||||
# Mouse moved, reset timer
|
||||
self.logger.debug(f"Mouse moved to ({x}, {y}), timer reset")
|
||||
self.countdown = WAITTIME
|
||||
self.countdown = self.wait_time
|
||||
self.xold = x
|
||||
self.yold = y
|
||||
|
||||
@@ -84,11 +86,26 @@ class MouseMover:
|
||||
|
||||
self.logger.debug("Mouse monitoring thread ended")
|
||||
|
||||
def update_wait_time(self, new_wait_time):
|
||||
"""Update the wait time setting"""
|
||||
self.wait_time = new_wait_time
|
||||
# Reset countdown to new wait time if currently counting down
|
||||
if self.running and self.countdown > self.wait_time:
|
||||
self.countdown = self.wait_time
|
||||
self.logger.info(f"Wait time updated to {self.wait_time} seconds")
|
||||
|
||||
def update_move_px(self, new_move_px):
|
||||
"""Update the mouse movement distance setting"""
|
||||
self.move_px = new_move_px
|
||||
self.logger.info(f"Mouse movement distance updated to {self.move_px} pixels")
|
||||
|
||||
def get_status(self):
|
||||
"""Get current status information"""
|
||||
return {
|
||||
'running': self.running,
|
||||
'enabled': self.mouse_movement_enabled,
|
||||
'countdown': self.countdown,
|
||||
'wait_time': self.wait_time,
|
||||
'move_px': self.move_px,
|
||||
'thread_alive': self.thread.is_alive() if self.thread else False
|
||||
}
|
||||
153
src/options_dialog.py
Normal file
153
src/options_dialog.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from PyQt5 import QtWidgets, QtCore, QtGui
|
||||
import logging
|
||||
from logging_config import get_logger
|
||||
from launch_agent import get_launch_agent_manager
|
||||
|
||||
|
||||
class OptionsDialog(QtWidgets.QDialog):
|
||||
"""Options dialog for configuring HereIAm settings"""
|
||||
|
||||
def __init__(self, current_wait_time=240, current_start_enabled=True, current_log_level="INFO", current_move_px=10, current_launch_at_startup=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Store current values
|
||||
self.wait_time = current_wait_time
|
||||
self.start_enabled = current_start_enabled
|
||||
self.log_level = current_log_level
|
||||
self.move_px = current_move_px
|
||||
self.launch_at_startup = current_launch_at_startup
|
||||
|
||||
# Initialize launch agent manager
|
||||
self.launch_agent_manager = get_launch_agent_manager()
|
||||
|
||||
self.setupUI()
|
||||
self.load_current_values()
|
||||
|
||||
def setupUI(self):
|
||||
"""Setup the dialog UI"""
|
||||
self.setWindowTitle("HereIAm Options")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(400, 220) # Increased height to accommodate new option
|
||||
|
||||
# Ensure dialog appears on top and is properly managed
|
||||
self.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowStaysOnTopHint)
|
||||
# Remove WA_DeleteOnClose to prevent automatic deletion conflicts
|
||||
|
||||
# Main layout
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setSpacing(10) # Reduce spacing between elements
|
||||
|
||||
# Create form layout
|
||||
form_layout = QtWidgets.QFormLayout()
|
||||
form_layout.setVerticalSpacing(8) # Reduce vertical spacing between form rows
|
||||
form_layout.setHorizontalSpacing(10) # Set horizontal spacing
|
||||
|
||||
# Wait Time setting
|
||||
self.wait_time_spinbox = QtWidgets.QSpinBox()
|
||||
self.wait_time_spinbox.setRange(10, 3600) # 10 seconds to 1 hour
|
||||
self.wait_time_spinbox.setSuffix(" seconds")
|
||||
self.wait_time_spinbox.setToolTip("Time to wait before moving mouse when idle (Range: 10-3600 seconds)")
|
||||
self.wait_time_spinbox.setKeyboardTracking(True) # Enable keyboard input
|
||||
self.wait_time_spinbox.setButtonSymbols(QtWidgets.QAbstractSpinBox.UpDownArrows) # Ensure arrows are visible
|
||||
self.wait_time_spinbox.lineEdit().setReadOnly(False) # Allow typing in the field
|
||||
form_layout.addRow("Wait Time (10-3600s):", self.wait_time_spinbox)
|
||||
|
||||
# Mouse Movement Distance setting
|
||||
self.move_px_spinbox = QtWidgets.QSpinBox()
|
||||
self.move_px_spinbox.setRange(1, 100) # 1 to 100 pixels
|
||||
self.move_px_spinbox.setSuffix(" pixels")
|
||||
self.move_px_spinbox.setToolTip("Distance to move mouse in pixels (Range: 1-100 pixels)")
|
||||
self.move_px_spinbox.setKeyboardTracking(True) # Enable keyboard input
|
||||
self.move_px_spinbox.setButtonSymbols(QtWidgets.QAbstractSpinBox.UpDownArrows) # Ensure arrows are visible
|
||||
self.move_px_spinbox.lineEdit().setReadOnly(False) # Allow typing in the field
|
||||
form_layout.addRow("Movement Distance (1-100px):", self.move_px_spinbox)
|
||||
|
||||
# Start Enabled setting
|
||||
self.start_enabled_checkbox = QtWidgets.QCheckBox()
|
||||
self.start_enabled_checkbox.setToolTip("Start HereIAm enabled when application launches")
|
||||
form_layout.addRow("Start Enabled:", self.start_enabled_checkbox)
|
||||
|
||||
# Launch at Startup setting
|
||||
self.launch_at_startup_checkbox = QtWidgets.QCheckBox()
|
||||
self.launch_at_startup_checkbox.setToolTip("Automatically launch HereIAm when you log in to macOS")
|
||||
form_layout.addRow("Launch at Startup:", self.launch_at_startup_checkbox)
|
||||
|
||||
# Log Level setting
|
||||
self.log_level_combo = QtWidgets.QComboBox()
|
||||
self.log_level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
|
||||
self.log_level_combo.setToolTip("Set the logging level for the application")
|
||||
form_layout.addRow("Log Level:", self.log_level_combo)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
# Button box
|
||||
button_box = QtWidgets.QDialogButtonBox(
|
||||
QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Center the dialog on screen
|
||||
self.center_on_screen()
|
||||
|
||||
def center_on_screen(self):
|
||||
"""Center the dialog on the screen"""
|
||||
screen = QtWidgets.QApplication.primaryScreen()
|
||||
if screen:
|
||||
screen_geometry = screen.geometry()
|
||||
dialog_geometry = self.geometry()
|
||||
x = (screen_geometry.width() - dialog_geometry.width()) // 2
|
||||
y = (screen_geometry.height() - dialog_geometry.height()) // 2
|
||||
self.move(x, y)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Called when dialog is shown"""
|
||||
super().showEvent(event)
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Called when dialog is closed"""
|
||||
self.logger.debug("Options dialog closing")
|
||||
super().closeEvent(event)
|
||||
|
||||
def load_current_values(self):
|
||||
"""Load current values into the form"""
|
||||
self.wait_time_spinbox.setValue(self.wait_time)
|
||||
self.move_px_spinbox.setValue(self.move_px)
|
||||
self.start_enabled_checkbox.setChecked(self.start_enabled)
|
||||
|
||||
# Check current startup status from the system
|
||||
current_startup_enabled = self.launch_agent_manager.is_startup_enabled()
|
||||
self.launch_at_startup_checkbox.setChecked(current_startup_enabled)
|
||||
|
||||
# Set log level
|
||||
index = self.log_level_combo.findText(self.log_level)
|
||||
if index >= 0:
|
||||
self.log_level_combo.setCurrentIndex(index)
|
||||
|
||||
def get_values(self):
|
||||
"""Get the values from the form"""
|
||||
try:
|
||||
return {
|
||||
'wait_time': self.wait_time_spinbox.value(),
|
||||
'move_px': self.move_px_spinbox.value(),
|
||||
'start_enabled': self.start_enabled_checkbox.isChecked(),
|
||||
'launch_at_startup': self.launch_at_startup_checkbox.isChecked(),
|
||||
'log_level': self.log_level_combo.currentText()
|
||||
}
|
||||
except AttributeError as e:
|
||||
# If widgets are already destroyed, return None
|
||||
self.logger.error(f"Error getting dialog values: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_options(current_wait_time=240, current_start_enabled=True, current_log_level="INFO", current_move_px=10, current_launch_at_startup=False, parent=None):
|
||||
"""Static method to show the dialog and return values"""
|
||||
dialog = OptionsDialog(current_wait_time, current_start_enabled, current_log_level, current_move_px, current_launch_at_startup, parent)
|
||||
if dialog.exec_() == QtWidgets.QDialog.Accepted:
|
||||
return dialog.get_values(), True
|
||||
return None, False
|
||||
Reference in New Issue
Block a user