1.0.0 Release
This commit is contained in:
10
MANIFEST.in
Normal file
10
MANIFEST.in
Normal file
@@ -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 *
|
||||
70
README.md
70
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
|
||||
|
||||
98
pyproject.toml
Normal file
98
pyproject.toml
Normal file
@@ -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"
|
||||
32
setup.py
32
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",
|
||||
)
|
||||
@@ -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
|
||||
54
test_startup.py
Executable file
54
test_startup.py
Executable file
@@ -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()
|
||||
42
tests/README.md
Normal file
42
tests/README.md
Normal file
@@ -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)
|
||||
@@ -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 ""
|
||||
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal file
@@ -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()
|
||||
47
tests/test_dialog.py
Normal file
47
tests/test_dialog.py
Normal file
@@ -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()
|
||||
42
tests/test_logging.py
Normal file
42
tests/test_logging.py
Normal file
@@ -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()
|
||||
30
tests/test_restart.py
Normal file
30
tests/test_restart.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user