diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c4e4fe9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include README.md +include LICENSE +recursive-include assets *.icns *.png +recursive-include src *.py +recursive-exclude tests * +recursive-exclude logs * +recursive-exclude dist * +recursive-exclude build * +recursive-exclude __pycache__ * +recursive-exclude *.egg-info * diff --git a/README.md b/README.md index 39ec93a..f23ed02 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,16 @@ DEBUG = False # Enable debug logging (default: False) ``` ### Logging Configuration -- **Log Location**: `logs/hereiam.log` +- **Log Location**: `~/.hereiam/logs/hereiam.log` (same directory as config file) +- **Log Persistence**: Logs are persistent across reboots - **Log Rotation**: Automatic (when DEBUG mode is enabled) -- **Log Levels**: INFO (default) or DEBUG (when DEBUG=True) +- **Log Levels**: INFO (default) or DEBUG (when DEBUG=True in options) + +### Startup Configuration +- **Launch at Startup**: Automatically launch HereIAm when you log in to macOS +- **Implementation**: Uses macOS Launch Agents (`~/Library/LaunchAgents/`) +- **Requirements**: Only available when using the built .app bundle +- **Configuration**: Can be enabled/disabled through the Options dialog ## Dependencies @@ -115,19 +122,30 @@ DEBUG = False # Enable debug logging (default: False) ## Project Structure ``` HereIAm/ -├── src/ +├── src/ # Source code │ ├── main.py # Application entry point and main GUI logic │ ├── mouse_mover.py # Mouse monitoring and movement logic -│ ├── config.py # Configuration and logging setup +│ ├── config_manager.py # Configuration management +│ ├── logging_config.py # Logging configuration +│ ├── options_dialog.py # Options dialog UI │ └── utils.py # Utility functions and safety features -├── assets/ +├── assets/ # Application assets │ ├── Enabled.icns # Icon for enabled state │ ├── Disabled-Light.icns # Icon for disabled state (light theme) │ └── Disabled-Dark.icns # Icon for disabled state (dark theme) -├── logs/ # Application logs (created at runtime) +├── tests/ # Test scripts and test documentation +│ ├── test_config.py # Configuration tests +│ ├── test_dialog.py # Dialog functionality tests +│ ├── test_logging.py # Logging tests +│ ├── test_restart.py # Restart functionality tests +│ ├── test_app.sh # App bundle integration tests +│ └── README.md # Test documentation +├── build_app.sh # Build script for macOS app bundle +├── create_dmg.sh # DMG creation script ├── requirements.txt # Python dependencies -├── setup.py # Package installation configuration -└── README.md # This file +├── requirements-build.txt # Build dependencies +├── setup.py # Package installation configuration +└── README.md # This file ``` ## Technical Details @@ -150,6 +168,36 @@ HereIAm/ - **Graceful Shutdown**: Clean thread termination and resource cleanup - **Fail-Safe Integration**: PyAutoGUI safety features configured appropriately +## Testing + +The project includes comprehensive test scripts located in the `tests/` directory. + +### Running Tests + +**Python Unit Tests:** +```bash +cd tests +python3 test_config.py # Test configuration management +python3 test_dialog.py # Test options dialog +python3 test_logging.py # Test logging functionality +python3 test_restart.py # Test restart functionality +``` + +**Application Bundle Test:** +```bash +cd tests +./test_app.sh # Test built macOS app bundle +``` + +**Note:** Make sure to build the application first with `./build_app.sh` before running app bundle tests. + +4. **Testing Startup Functionality**: + ```bash + ./test_startup.py + ``` + +For detailed testing information, see [tests/README.md](tests/README.md). + ## Troubleshooting ### Common Issues @@ -162,7 +210,7 @@ HereIAm/ **Mouse movement not working:** - Check that accessibility permissions are granted to Terminal/Python - Verify pyautogui installation: `python -c "import pyautogui; print('OK')"` -- Review logs in `logs/hereiam.log` for error details +- Review logs in `~/.hereiam/logs/hereiam.log` for error details **Icons not displaying:** - Verify all `.icns` files are present in the `assets/` directory @@ -170,9 +218,9 @@ HereIAm/ - Enable DEBUG mode in config.py for detailed icon loading logs ### Debugging -1. Enable debug mode in `src/config.py`: `DEBUG = True` +1. Enable debug mode in the Options dialog: set Log Level to "DEBUG" 2. Run from terminal to see console output -3. Check `logs/hereiam.log` for detailed operation logs +3. Check `~/.hereiam/logs/hereiam.log` for detailed operation logs 4. Use Activity Monitor to verify the application is running ## Development diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..acfbca7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,98 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "HereIAm" +dynamic = ["version"] +description = "A macOS application that moves the mouse cursor to prevent inactivity." +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "Jerico Thomas", email = "jerico@tekop.net"} +] +maintainers = [ + {name = "Jerico Thomas", email = "jerico@tekop.net"} +] +keywords = ["macos", "mouse", "activity", "prevention", "system-tray"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Utilities", + "Environment :: MacOS X", + "Environment :: MacOS X :: Cocoa", +] +requires-python = ">=3.8" +dependencies = [ + "pyautogui>=0.9.54", + "PyQt5>=5.15.0", + "pyobjc-framework-Cocoa>=9.0", + "pyobjc-framework-Quartz>=9.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=22.0.0", + "flake8>=4.0.0", +] +build = [ + "pyinstaller>=5.0.0", + "dmgbuild>=1.6.0", +] + +[project.urls] +Homepage = "https://github.com/your-username/hereiam" +Repository = "https://github.com/your-username/hereiam.git" +Issues = "https://github.com/your-username/hereiam/issues" + +[project.scripts] +hereiam = "src.main:main" + +[project.gui-scripts] +hereiam-gui = "src.main:main" + +[tool.setuptools] +package-dir = {"" = "."} +packages = ["src"] + +[tool.setuptools.package-data] +"*" = ["assets/*.icns", "assets/*.png"] + +[tool.setuptools.dynamic] +version = {attr = "src.__init__.__version__"} + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | venv + | _build + | buck-out + | build + | dist + | tests +)/ +''' + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" diff --git a/setup.py b/setup.py index 4dee998..f7f8382 100644 --- a/setup.py +++ b/setup.py @@ -1,23 +1,40 @@ from setuptools import setup, find_packages import os +import re + +# Read version and other metadata from __init__.py +def get_metadata(): + """Extract metadata from src/__init__.py""" + init_file = os.path.join(os.path.dirname(__file__), 'src', '__init__.py') + with open(init_file, 'r', encoding='utf-8') as f: + content = f.read() + + version = re.search(r'__version__ = ["\']([^"\']+)["\']', content).group(1) + author = re.search(r'__author__ = ["\']([^"\']+)["\']', content).group(1) + email = re.search(r'__email__ = ["\']([^"\']+)["\']', content).group(1) + + return version, author, email # Read README for long description def read_readme(): with open("README.md", "r", encoding="utf-8") as fh: return fh.read() +version, author, email = get_metadata() + setup( name='HereIAm', - version='1.0.0', + version=version, description='A macOS application that moves the mouse cursor to prevent inactivity.', long_description=read_readme(), long_description_content_type="text/markdown", - author='Jerico Thomas', - author_email='jerico@tekop.net', + author=author, + author_email=email, url='https://github.com/your-username/hereiam', # Update with your repo URL - packages=find_packages(), + packages=['src'], # Only include src package + package_dir={'src': 'src'}, package_data={ - 'assets': ['*.icns', '*.png'], + '': ['assets/*.icns', 'assets/*.png'], # Include assets at root level }, include_package_data=True, install_requires=[ @@ -38,13 +55,16 @@ setup( classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Utilities", + "Environment :: MacOS X", + "Environment :: MacOS X :: Cocoa", ], + license="MIT", ) \ No newline at end of file diff --git a/src/config.py b/src/config.py deleted file mode 100644 index e1206fa..0000000 --- a/src/config.py +++ /dev/null @@ -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. diff --git a/src/config_manager.py b/src/config_manager.py new file mode 100644 index 0000000..cce0e99 --- /dev/null +++ b/src/config_manager.py @@ -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) diff --git a/src/launch_agent.py b/src/launch_agent.py new file mode 100644 index 0000000..95b0375 --- /dev/null +++ b/src/launch_agent.py @@ -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() diff --git a/src/logging_config.py b/src/logging_config.py new file mode 100644 index 0000000..7e90ec1 --- /dev/null +++ b/src/logging_config.py @@ -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 diff --git a/src/main.py b/src/main.py index 8e40166..a19c99f 100644 --- a/src/main.py +++ b/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""" -
Version {__version__}
-A macOS application that prevents system sleep by moving the mouse cursor periodically.
-Author: {__author__}
-Email: {__email__}
-Built with PyQt5 and Python
- """ - 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""" +Version {__version__}
+A macOS application that prevents system sleep by moving the mouse cursor periodically.
+Author: {__author__}
+Email: {__email__}
+Launch at Startup: {startup_status}
+Log File: {log_path}
+Logs are stored in ~/.hereiam/logs/ directory
+Built with PyQt5 and Python
+ """ + 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}") diff --git a/src/menu_bar.py b/src/menu_bar.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/mouse_mover.py b/src/mouse_mover.py index 27972a8..02d7894 100644 --- a/src/mouse_mover.py +++ b/src/mouse_mover.py @@ -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 } \ No newline at end of file diff --git a/src/options_dialog.py b/src/options_dialog.py new file mode 100644 index 0000000..b96cec6 --- /dev/null +++ b/src/options_dialog.py @@ -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 diff --git a/test_startup.py b/test_startup.py new file mode 100755 index 0000000..aa6aeeb --- /dev/null +++ b/test_startup.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Test script for Launch Agent functionality. +""" + +import sys +import os + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from launch_agent import get_launch_agent_manager +from logging_config import setup_logging, get_logger + +def test_launch_agent(): + """Test the launch agent functionality""" + setup_logging(debug=True) + logger = get_logger(__name__) + + print("🧪 Testing Launch Agent Functionality") + print("=" * 40) + + # Get launch agent manager + manager = get_launch_agent_manager() + + # Check current status + is_enabled = manager.is_startup_enabled() + print(f"📋 Current startup status: {'Enabled' if is_enabled else 'Disabled'}") + + if is_enabled: + info = manager.get_launch_agent_info() + if info: + print(f"📂 Plist path: {info['path']}") + print(f"🚀 Program: {' '.join(info['program_arguments'])}") + + # Test app path detection + app_path = manager.get_app_path() + print(f"📱 Detected app path: {app_path}") + + # Test plist creation (don't install it) + plist_data = manager.create_launch_agent_plist() + print(f"📄 Generated plist data:") + for key, value in plist_data.items(): + print(f" {key}: {value}") + + print("\n✅ Launch Agent test completed!") + print("\nTo test startup functionality:") + print("1. Build the app: ./build_app.sh") + print("2. Run the app and go to Options") + print("3. Enable 'Launch at Startup'") + print("4. Log out and back in to test") + +if __name__ == "__main__": + test_launch_agent() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1911a3c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,42 @@ +# HereIAm Tests + +This directory contains all test scripts for the HereIAm application. + +## Test Scripts + +### Python Unit Tests +- `test_config.py` - Tests configuration management +- `test_dialog.py` - Tests options dialog functionality +- `test_logging.py` - Tests logging configuration +- `test_restart.py` - Tests application restart functionality + +### Shell Scripts +- `test_app.sh` - Tests the built macOS application bundle + +## Running Tests + +### Python Tests +Run individual test files: +```bash +cd tests +python3 test_config.py +python3 test_dialog.py +python3 test_logging.py +python3 test_restart.py +``` + +### App Bundle Test +Test the built application: +```bash +cd tests +./test_app.sh +``` + +**Note:** Make sure to build the application first by running `../build_app.sh` from the tests directory. + +## Test Requirements + +- Python 3.x +- PyQt5 +- Built HereIAm.app (for app bundle tests) +- macOS (for app bundle tests) diff --git a/test_app.sh b/tests/test_app.sh similarity index 97% rename from test_app.sh rename to tests/test_app.sh index c7ed649..36882ee 100755 --- a/test_app.sh +++ b/tests/test_app.sh @@ -15,12 +15,12 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' -APP_PATH="dist/HereIAm.app" +APP_PATH="../dist/HereIAm.app" # Check if app exists if [ ! -d "$APP_PATH" ]; then echo -e "${RED}❌ Error: $APP_PATH not found!${NC}" - echo "Please run './build_app.sh' first." + echo "Please run '../build_app.sh' first." exit 1 fi @@ -133,6 +133,6 @@ echo -e "${GREEN}📱 HereIAm.app is ready for distribution${NC}" echo "" echo -e "${YELLOW}📝 Next steps:${NC}" echo "1. Test manually: open $APP_PATH" -echo "2. Create DMG: ./create_dmg.sh" +echo "2. Create DMG: ../create_dmg.sh" echo "3. For distribution: code sign and notarize" echo "" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..20ea66b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Test script to verify the configuration system works correctly +""" +import sys +import os +sys.path.insert(0, '/Users/JeThomas/scripts/HereIAm/src') + +from config_manager import ConfigManager + +def test_config_manager(): + print("Testing ConfigManager...") + + # Create config manager + cm = ConfigManager() + + # Test loading default config + config = cm.load_config() + print(f"Default config: {config}") + + # Test updating config with new move_px + test_config = { + 'wait_time': 300, + 'start_enabled': False, + 'log_level': 'DEBUG', + 'move_px': 15 + } + + success = cm.save_config(test_config) + print(f"Save config success: {success}") + + # Test loading updated config + loaded_config = cm.load_config() + print(f"Loaded config: {loaded_config}") + + # Test individual value operations + cm.set_config_value('wait_time', 180) + cm.set_config_value('move_px', 20) + wait_time = cm.get_config_value('wait_time') + move_px = cm.get_config_value('move_px') + print(f"Individual wait_time: {wait_time}") + print(f"Individual move_px: {move_px}") + + print("ConfigManager tests completed!") + +if __name__ == "__main__": + test_config_manager() diff --git a/tests/test_dialog.py b/tests/test_dialog.py new file mode 100644 index 0000000..caad977 --- /dev/null +++ b/tests/test_dialog.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Test script to verify options dialog works correctly +""" +import sys +import os +sys.path.insert(0, '/Users/JeThomas/scripts/HereIAm/src') + +from PyQt5 import QtWidgets +from options_dialog import OptionsDialog + +def test_dialog_multiple_opens(): + print("Testing multiple dialog opens...") + + app = QtWidgets.QApplication(sys.argv) + + # Test opening dialog multiple times + for i in range(3): + print(f"Opening dialog #{i+1}") + + dialog = OptionsDialog( + current_wait_time=240 + i*10, + current_start_enabled=True, + current_log_level="INFO", + current_move_px=10 + i + ) + + # Show dialog briefly (simulate quick open/close) + dialog.show() + app.processEvents() # Process pending events + + # Get values to ensure dialog is functional + values = dialog.get_values() + print(f"Dialog #{i+1} values: {values}") + + # Close dialog + dialog.close() + dialog.deleteLater() + app.processEvents() # Process cleanup events + + print(f"Dialog #{i+1} closed successfully") + + print("Multiple dialog test completed successfully!") + app.quit() + +if __name__ == "__main__": + test_dialog_multiple_opens() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..7fd1d81 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Test script to verify logging is working correctly +""" +import sys +import os +import time +sys.path.insert(0, '/Users/JeThomas/scripts/HereIAm/src') + +from logging_config import setup_logging, get_logger +from config_manager import ConfigManager + +def test_logging(): + print("Testing logging system...") + + # Test 1: Default logging setup + setup_logging() + logger = get_logger("test_logger") + logger.info("Test message 1 - Default setup") + logger.debug("This debug message should NOT appear in default setup") + + # Test 2: Debug mode + print("\nSwitching to DEBUG mode...") + setup_logging(debug=True, force_reconfigure=True) + logger.info("Test message 2 - Debug mode") + logger.debug("This debug message SHOULD appear in debug mode") + + # Test 3: Load config and apply logging + print("\nTesting with config manager...") + cm = ConfigManager() + config = cm.load_config() + debug_mode = config.get('log_level', 'INFO').upper() == 'DEBUG' + setup_logging(debug=debug_mode, force_reconfigure=True) + + logger.info(f"Test message 3 - Config mode (debug={debug_mode})") + logger.debug(f"Debug message based on config: log_level={config.get('log_level', 'INFO')}") + + print(f"\nCheck the log file at: ~/logs/hereiam.log") + print("Also check console output above for debug messages") + +if __name__ == "__main__": + test_logging() diff --git a/tests/test_restart.py b/tests/test_restart.py new file mode 100644 index 0000000..bdc1ddc --- /dev/null +++ b/tests/test_restart.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +Test script to verify restart logic +""" +import sys +import os +import subprocess + +def test_restart_logic(): + print("Testing restart logic...") + + # Simulate the restart logic + script_path = os.path.abspath(__file__) + print(f"Current script path: {script_path}") + print(f"sys.argv: {sys.argv}") + print(f"__name__: {__name__}") + print(f"sys.executable: {sys.executable}") + print(f"Current working directory: {os.getcwd()}") + + # Show what command would be used for restart + if __name__ != "__main__": + args = [sys.executable] + sys.argv + else: + args = [sys.executable, script_path] + + print(f"Restart command would be: {' '.join(args)}") + print("Test completed - no actual restart performed") + +if __name__ == "__main__": + test_restart_logic()