inital commit

This commit is contained in:
Kuro 2025-06-19 20:38:55 +02:00
commit c51c099c75
13 changed files with 1513 additions and 0 deletions

242
.gitignore vendored Normal file
View File

@ -0,0 +1,242 @@
# Created by https://www.toptal.com/developers/gitignore/api/python,autohotkey
# Edit at https://www.toptal.com/developers/gitignore?templates=python,autohotkey
### Autohotkey ###
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
# =========================
# Operating System Files
# =========================
# OSX
# =========================
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,autohotkey
/bin/*.ini
/config.ini
*.exe
/.vs/VSWorkspaceState.json
/.vs/keypress/FileContentIndex/52395d4e-3c90-4b4f-aa08-3c78b884c891.vsidx
.wsuo
/.vs/keypress/v17/DocumentLayout.json
/.vs/slnx.sqlite
Analysis-00.toc
EXE-00.toc
JarvisKeyPressUtility.pkg
PKG-00.toc
PYZ-00.pyz
PYZ-00.toc
base_library.zip
/bin/JarvisKeyPressUtility/warn-JarvisKeyPressUtility.txt
/bin/JarvisKeyPressUtility/xref-JarvisKeyPressUtility.html

136
controller/controller.py Normal file
View File

@ -0,0 +1,136 @@
import tkinter as tk
from tkinter import messagebox
import logging
from model.model import KeyPressModel
from view.view import KeyPressView
# NEW: Import UIController
from controller.ui_controller import UIController
logger = logging.getLogger(__name__)
class KeyPressController:
def __init__(self, model, view):
self.model = model
self.view = view # This will be set by main.py after View is instantiated.
self.ui_controller = None # Will be instantiated and linked after view is set.
logger.info("Controller: Initialized (view and UIController might be None for now).")
# Register this controller's callback with the Model for AHK stopping events
self.model.register_ahk_stopped_callback(self._handle_ahk_external_stop)
def initial_setup_after_view_is_ready(self):
"""
Performs controller setup that relies on the view being fully initialized.
Called from main.py after controller.view is set.
"""
if self.view is None:
logger.error("Controller: initial_setup_after_view_is_ready called but view is None!")
return
# Instantiate UIController and pass itself (main controller) and the view
self.ui_controller = UIController(self, self.view)
self.ui_controller.initial_ui_setup_after_view_is_ready() # Trigger UIController's setup
logger.info("Controller: Performing post-view-initialization setup.")
# Initial set for key based on UI default
self.model.set_key_to_press(self.view.get_key_input())
# Initial window refresh and selection handled by ui_controller.refresh_windows_ui()
# --- Methods called by UIController to update Model/Controller state ---
def set_key_input_from_ui(self, key_str):
"""Receives key input from UI, updates model."""
self.model.set_key_to_press(key_str)
def set_window_selection_from_ui(self, selected_title):
"""Receives window selection from UI, updates model based on AHK logic."""
if selected_title == "--- Global Input (No Specific Window) ---":
# As per user's explicit request: "it is always a specific window", no "GLOBAL" AHK branch.
# This selection means AHK script will likely not find a window, thus releasing the key.
self.model.set_target_window("INVALID_GLOBAL_TARGET_FOR_AHK") # Use a distinct string for internal model logic
self.ui_controller.show_ui_error_message("Warning: 'Global Input' selected. The AutoHotkey script is designed for specific windows and will likely NOT press the key automatically with this selection.")
logger.warning("Controller: 'Global Input' selected. AHK logic is non-global; key will not be pressed.")
elif selected_title == "--- Select a Window ---":
self.model.set_target_window("")
logger.info("Controller: No specific window selected. Target is empty.")
else:
self.model.set_target_window(selected_title)
logger.info(f"Controller: Target window set to: '{selected_title}'")
# --- Methods for UIController to query Model ---
def get_available_window_titles(self):
"""Provides available window titles (from model) to UIController."""
return self.model.get_window_titles()
# --- Core Application Logic / Event Handlers (Called by UIController/View directly, or internally) ---
def _check_start_button_state(self):
"""
Internal method to check application state and update UI button state.
This is called by UIController after relevant inputs change.
"""
if self.ui_controller: # Ensure UIController is instantiated before calling its methods
self.ui_controller._check_start_button_state_ui() # Delegate to UIController for UI update
def start_key_press(self):
"""Handles the 'Start Holding Key' button click event (from View)."""
logger.info("Controller: Start key press requested.")
# Perform validation using UIController's methods to get current UI state
key_value = self.view.get_key_input() # Get current key from view
selected_window = self.view.get_selected_window_title() # Get current window from view
if not key_value:
self.ui_controller.show_ui_error_message("The 'Key to Press' field cannot be empty. What exactly do you expect me to press?")
self.view.set_key_validation_visibility(True)
logger.error("Controller: Aborting: Key to press field is empty.")
return
if selected_window == "--- Select a Window ---":
self.ui_controller.show_ui_error_message("Please select a specific target window or 'Global Input'.")
self.view.set_window_validation_visibility(True)
logger.error("Controller: Aborting: No target window selected.")
return
if selected_window == "--- Global Input (No Specific Window) ---":
# Show the warning again if the user is attempting to start with 'Global Input'
self.ui_controller.show_ui_error_message("Warning: 'Global Input' selected. The current AutoHotkey script is designed to activate a specific window, not provide global key presses. This option will prevent the key from being pressed automatically.")
logger.warning("Controller: Attempting to start with 'Global Input'. AHK logic is non-global. Key will not be pressed.")
# Tell Model to start AHK script. Model handles pynput listener internally.
if self.model.start_autohotkey_script():
self.ui_controller.set_ui_running_state() # Update UI via UIController
logger.info("Controller: Key press operation successfully initiated.")
else:
self.ui_controller.show_ui_error_message("Failed to start AutoHotkey script. Check application logs for details (e.g., AutoHotkey.exe not found).")
self.ui_controller.set_ui_idle_state() # Reset UI via UIController
logger.error("Controller: Failed to start AHK script. Aborting.")
def stop_key_press(self):
"""Handles the 'Stop Key Press' button click event (from View)."""
logger.info("Controller: Stop key press requested.")
self.model.stop_autohotkey_script() # Tell Model to stop AHK
self.ui_controller.set_ui_idle_state() # Set UI to idle state via UIController
logger.info("Controller: Key press operation stopped.")
def _handle_ahk_external_stop(self):
"""
Callback method invoked by the Model when the AHK script stops due to
an external event (e.g., AHK error, F6 press, window not found).
Resets the UI state via UIController.
"""
logger.info("Controller: AHK script stopped externally. Requesting UI reset.")
# Ensure this is scheduled on the main Tkinter thread if called from a background thread
if self.view and self.view.master:
self.view.master.after_idle(self.ui_controller.set_ui_idle_state)
else:
logger.warning("Controller: Cannot schedule UI reset, view or master is unavailable.")
def on_app_close(self):
"""Handles application window close event, ensures AHK cleanup."""
logger.info("Controller: Application close requested. Initiating cleanup.")
self.model.stop_autohotkey_script()
if self.view and self.view.master: # Check if master still exists before destroying
self.view.master.destroy()
logger.info("Controller: Application closed.")

111
controller/ui_controller.py Normal file
View File

@ -0,0 +1,111 @@
import tkinter as tk
from tkinter import messagebox
import logging
logger = logging.getLogger(__name__)
class UIController:
def __init__(self, controller_main, view):
# controller_main is a reference to the main KeyPressController instance
self.controller_main = controller_main
self.view = view
logger.info("UIController: Initialized.")
def initial_ui_setup_after_view_is_ready(self):
"""
Performs UI-specific setup that relies on the view being fully initialized.
Called from controller.py after view reference is available.
"""
if self.view is None:
logger.error("UIController: initial_ui_setup_after_view_is_ready called but view is None!")
return
logger.info("UIController: Performing post-view-initialization UI setup.")
# Any view-dependent setup that was previously in KeyPressController's init_setup
# that directly interacts with self.view now lives here.
# Link View's StringVar traces for key input change
self.view.key_to_press_var.trace_add("write", lambda name, index, mode: self._on_key_input_change())
# Initial refresh of windows list for the UI
self.refresh_windows_ui()
# Initial check of start button state based on UI inputs
self._check_start_button_state_ui()
def _on_key_input_change(self):
"""Handles changes in the key input field, informs main controller."""
self.controller_main.set_key_input_from_ui(self.view.get_key_input())
self._check_start_button_state_ui()
def refresh_windows_ui(self):
"""
Refreshes the list of available windows in the View and updates Controller.
Calls model via controller_main to get window titles.
"""
current_selection = self.view.get_selected_window_title()
window_titles = self.controller_main.get_available_window_titles() # Get from model via main controller
display_window_titles = ["--- Select a Window ---"] + window_titles
if "--- Global Input (No Specific Window) ---" not in display_window_titles:
display_window_titles.insert(1, "--- Global Input (No Specific Window) ---")
new_selection = current_selection
if new_selection not in display_window_titles:
new_selection = "--- Select a Window ---"
elif current_selection == "--- Global Input (No Specific Window) ---":
new_selection = "--- Global Input (No Specific Window) ---"
self.view.update_window_list(display_window_titles, new_selection)
# Inform the main controller about the updated window selection
self.controller_main.set_window_selection_from_ui(new_selection)
logger.info(f"UIController: Refreshed window list. Found {len(window_titles)} active windows.")
self._check_start_button_state_ui()
def on_window_selected_ui(self):
"""Handles combobox selection event from View, informs main controller."""
selected_title = self.view.get_selected_window_title()
self.controller_main.set_window_selection_from_ui(selected_title)
self._check_start_button_state_ui()
def _check_start_button_state_ui(self):
"""
Determines if the start button should be enabled based on UI input validation.
Updates validation labels in the View directly.
"""
key_input_present = bool(self.view.get_key_input())
self.view.set_key_validation_visibility(not key_input_present)
selected_option = self.view.get_selected_window_title()
is_real_window_selected = (selected_option != "--- Select a Window ---" and selected_option in self.controller_main.get_available_window_titles())
is_global_input_selected = (selected_option == "--- Global Input (No Specific Window) ---")
valid_window_selected_for_start = is_real_window_selected or is_global_input_selected
self.view.set_window_validation_visibility(not valid_window_selected_for_start)
if key_input_present and valid_window_selected_for_start:
# Only enable the button if both are valid
self.view.start_button.config(state=tk.NORMAL)
logger.debug("UIController: Start button enabled.")
else:
self.view.start_button.config(state=tk.DISABLED)
logger.debug("UIController: Start button disabled (validation error).")
def show_ui_error_message(self, message):
"""Displays a messagebox error to the user via View."""
self.view.show_input_error(message)
def set_ui_running_state(self):
"""Updates UI elements to reflect running state."""
self.view.set_ui_state_running()
def set_ui_idle_state(self):
"""Updates UI elements to reflect idle state."""
self.view.set_ui_state_idle()
self._check_start_button_state_ui() # Re-check after returning to idle to ensure proper button state

65
main.py Normal file
View File

@ -0,0 +1,65 @@
import tkinter as tk
import logging
import sys
import os
# --- Setup Python Path for MVC Imports ---
# Get the absolute path of the directory containing main.py
current_dir = os.path.dirname(os.path.abspath(__file__))
# Add the current directory to sys.path to allow importing from model/, view/, controller/
sys.path.insert(0, current_dir)
# Import the main Model class and the message queue from app_logger
from model.key_press_model import KeyPressModel # Updated import path
from model.app_logger import message_queue, setup_logging_from_config # Updated import for logging setup
from view.view import KeyPressView
from controller.controller import KeyPressController
# --- Global Logging Setup will now be handled by setup_logging_from_config from model.app_logger ---
# No direct logging setup here, just calling the setup function.
logger = logging.getLogger(__name__) # Logger for the main.py module itself
if __name__ == "__main__":
# Set up logging early using the config file provided by app_logger
current_log_file_path = setup_logging_from_config()
# --- Python Environment Diagnostics (Included for completeness) ---
print(f"--- Python Environment Diagnostics ---")
print(f"Python Executable: {sys.executable}")
print(f"Python Version: {sys.version}")
print(f"Python Path (sys.path):")
for p in sys.path:
print(f" {p}")
print(f"--- End Diagnostics ---")
logger.info("Main: Application starting up.")
logger.info(f"Main: Logging all output to file: {current_log_file_path}")
main_error_occurred = False
try:
root = tk.Tk()
# Instantiate Model, View, and Controller
model = KeyPressModel()
controller = KeyPressController(model, None)
view = KeyPressView(root, controller)
controller.view = view
controller.initial_setup_after_view_is_ready()
root.protocol("WM_DELETE_WINDOW", controller.on_app_close)
logger.info("Main: GUI initialized. Starting main loop.")
root.mainloop()
except Exception as e:
logger.critical(f"Main: An unhandled exception occurred in the main application loop: {e}", exc_info=True)
main_error_occurred = True
finally:
logger.info("Main: Application loop ended. Ensuring all background processes are stopped.")
print("Application closed. Jarvis signing off.")
if main_error_occurred and not getattr(sys, 'frozen', False):
print("\nAn unexpected error occurred. Press Enter to exit the console...")
input()

View File

@ -0,0 +1,305 @@
import sys
import os
import tempfile
import subprocess
import threading
import queue
import logging
import time
from pynput.keyboard import Key, Listener # Directly imported as it's used here
from model.constants import STOP_KEY, AHK_LOG_MONITOR_INTERVAL, AHK_TEMPLATE_REL_PATH_FROM_MODEL_DIR, AHK_EXECUTABLE_FALLBACK_PATHS, AUTOHOTKEY_KEY_MAP
logger = logging.getLogger(__name__)
class AHKProcessManager:
"""
Manages the AutoHotkey script lifecycle: generation, launching,
monitoring, stopping, and cleanup of temporary files.
Also handles the pynput keyboard listener for global stop key detection.
"""
def __init__(self, ahk_template_rel_path, ahk_executable_fallback_paths, autohotkey_key_map, stop_key, message_queue):
self._ahk_template_rel_path = ahk_template_rel_path
self._ahk_executable_fallback_paths = ahk_executable_fallback_paths
self._autohotkey_key_map = autohotkey_key_map
self._stop_key = stop_key
self._message_queue = message_queue # Central message queue for GUI communication
self._ahk_process = None
self._ahk_script_temp_file = None
self._ahk_log_temp_file = None
self._ahk_log_file_last_read_pos = 0
self._keep_running = False # Flag to control monitoring and listener threads
self._current_target_window_title = ""
self._current_key_to_press_str = ''
self._ahk_stopped_callback = None # Callback to notify KeyPressModel (and then Controller)
logger.info("AHKProcessManager: Initialized.")
def register_ahk_stopped_callback(self, callback_func):
"""Registers a callback function to be called when the AHK script stops externally."""
self._ahk_stopped_callback = callback_func
logger.info("AHKProcessManager: AHK stopped callback registered.")
def _trigger_ahk_stopped_callback(self):
"""Internal method to trigger the registered callback, if any."""
if self._ahk_stopped_callback:
# The callback is expected to be a method of KeyPressModel,
# which then might schedule a call to the Controller on the main thread.
self._ahk_stopped_callback()
logger.info("AHKProcessManager: AHK stopped callback triggered.")
else:
logger.warning("AHKProcessManager: AHK stopped, but no callback was registered.")
def set_target_window(self, title):
"""Sets the target window title for AHK operations."""
self._current_target_window_title = title
def set_key_to_press(self, key_str):
"""Sets the key string to be pressed by AHK."""
self._current_key_to_press_str = key_str
def _get_autohotkey_key_name(self, user_input_key):
"""Converts a user-friendly key input to its AutoHotkey v2 equivalent."""
normalized_input = user_input_key.lower().strip()
mapped_key = self._autohotkey_key_map.get(normalized_input)
if mapped_key:
return mapped_key
if len(user_input_key) == 1 and user_input_key.isalnum():
return user_input_key
return user_input_key
def start_script(self):
"""
Generates and launches the AutoHotkey script as a subprocess.
Monitors its log file and starts pynput listener in separate threads.
"""
logger.info("AHKProcessManager: start_script: Function entry.")
if self._ahk_process and self._ahk_process.poll() is None:
logger.warning("AHKProcessManager: AutoHotkey script is already running. Exiting function.")
return True
resolved_autohotkey_exe_path = None
try:
# --- Determine the correct path to template.ahk ---
if getattr(sys, 'frozen', False): # Running as a PyInstaller executable
template_path = os.path.join(sys._MEIPASS, "resources", "template.ahk")
logger.info(f"AHKProcessManager: Running as EXE. Template path: {template_path}")
else: # Running as a Python script (from project_root/model/)
script_dir = os.path.dirname(os.path.abspath(__file__))
# Construct path relative to ahk_process_manager.py to reach project_root/resources/template.ahk
template_path = os.path.join(script_dir, self._ahk_template_rel_path)
logger.info(f"AHKProcessManager: Running as script. Template path: {template_path}")
logger.info(f"AHKProcessManager: Attempting to read AHK template from: {template_path}")
if not os.path.exists(template_path):
logger.error(f"AHKProcessManager: AHK template file not found at '{template_path}'.")
return False
with open(template_path, 'r') as f:
ahk_template_content = f.read()
logger.info(f"AHKProcessManager: AHK template loaded successfully from {template_path}.")
logger.debug("AHKProcessManager: Creating temporary files for AHK script and log.")
fd_script, self._ahk_script_temp_file = tempfile.mkstemp(suffix=".ahk", prefix="jarvis_ahk_")
fd_log, self._ahk_log_temp_file = tempfile.mkstemp(suffix=".log", prefix="jarvis_ahk_log_")
os.close(fd_script)
os.close(fd_log)
self._ahk_log_file_last_read_pos = 0
target_win_ahk = self._current_target_window_title
key_to_press_ahk = self._get_autohotkey_key_name(self._current_key_to_press_str)
stop_key_ahk_name = self._get_autohotkey_key_name(str(self._stop_key.name))
logger.info(f"AHKProcessManager: Target window for AHK: '{target_win_ahk}'")
logger.info(f"AHKProcessManager: Key to press for AHK: '{key_to_press_ahk}'")
logger.info(f"AHKProcessManager: Stop key for AHK: '{stop_key_ahk_name}'")
ahk_script_content = ahk_template_content.format(
TARGET_WINDOW_TITLE_PLACEHOLDER=target_win_ahk,
KEY_TO_PRESS_AHK_PLACEHOLDER=key_to_press_ahk,
STOP_KEY_AHK_PLACEHOLDER=stop_key_ahk_name,
AHK_LOG_FILE_PLACEHOLDER=self._ahk_log_temp_file.replace('\\', '/')
)
logger.debug(f"AHKProcessManager: Writing AHK script content to: {self._ahk_script_temp_file}")
with open(self._ahk_script_temp_file, 'w') as f:
f.write(ahk_script_content)
logger.debug(f"AHKProcessManager: AutoHotkey script successfully written.")
logger.debug(f"AHKProcessManager: AutoHotkey logging will occur in: {self._ahk_log_temp_file}")
# --- Determine the correct path to AutoHotkey.exe ---
if getattr(sys, 'frozen', False):
bundle_dir = sys._MEIPASS
ahk_bundled_locations = [
os.path.join(bundle_dir, "AutoHotkey.exe"),
os.path.join(bundle_dir, "AutoHotkey", "AutoHotkey.exe"),
os.path.join(bundle_dir, "v2", "AutoHotkey.exe"),
]
for loc in ahk_bundled_locations:
if os.path.exists(loc):
resolved_autohotkey_exe_path = loc
logger.debug(f"AHKProcessManager: Found bundled AutoHotkey.exe at: {resolved_autohotkey_exe_path}")
break
if resolved_autohotkey_exe_path is None:
logger.warning("AHKProcessManager: Could not find AutoHotkey.exe within the PyInstaller bundle. Falling back to common install paths.")
if resolved_autohotkey_exe_path is None:
for path in self._ahk_executable_fallback_paths:
if os.path.exists(path):
resolved_autohotkey_exe_path = path
logger.debug(f"AHKProcessManager: Found AutoHotkey.exe at configured path: {resolved_autohotkey_exe_path}")
break
if resolved_autohotkey_exe_path is None:
logger.error("AHKProcessManager: AutoHotkey.exe not found in any known location or system PATH. Cannot launch AHK script.")
raise FileNotFoundError("AutoHotkey.exe not found. Please ensure it's installed or specify its path.")
command = [resolved_autohotkey_exe_path, self._ahk_script_temp_file]
logger.info(f"AHKProcessManager: Launching AutoHotkey command: {' '.join(command)}")
creationflags = subprocess.DETACHED_PROCESS if os.name == 'nt' else 0
self._ahk_process = subprocess.Popen(command, creationflags=creationflags)
logger.info(f"AHKProcessManager: AutoHotkey script launched successfully with PID: {self._ahk_process.pid}")
self._keep_running = True
logger.debug("AHKProcessManager: Waiting 0.5 seconds for AHK to initialize.")
time.sleep(0.5)
logger.debug("AHKProcessManager: Launching AHK log monitoring thread.")
threading.Thread(target=self._monitor_ahk_log, daemon=True).start()
logger.debug("AHKProcessManager: AHK log monitoring thread launched.")
return True
except FileNotFoundError as e:
logger.error(f"AHKProcessManager: FileNotFoundError: {e}", exc_info=True)
self._trigger_ahk_stopped_callback()
return False
except Exception as e:
logger.error(f"AHKProcessManager: An unexpected error occurred while launching AutoHotkey script: {e}", exc_info=True)
self._trigger_ahk_stopped_callback()
return False
finally:
logger.info("AHKProcessManager: start_script: Function exit.")
def stop_script(self):
"""
Terminates the running AutoHotkey script process and cleans up temporary files.
Always attempts to stop the process and clean up, regardless of previous state.
"""
logger.info("AHKProcessManager: stop_script: Function entry.")
self._keep_running = False
if self._ahk_process and self._ahk_process.poll() is None:
logger.info("AHKProcessManager: Terminating AutoHotkey script process.")
try:
self._ahk_process.terminate()
self._ahk_process.wait(timeout=2)
if self._ahk_process.poll() is None:
self._ahk_process.kill()
logger.info("AHKProcessManager: AutoHotkey process forcefully killed.")
else:
logger.info("AHKProcessManager: AutoHotkey process terminated gracefully.")
except Exception as e:
logger.error(f"AHKProcessManager: Error terminating AutoHotkey process: {e}", exc_info=True)
finally:
self._ahk_process = None
else:
logger.info("AHKProcessManager: AutoHotkey script was not running or already terminated.")
self._clean_ahk_temp_files()
logger.info("AHKProcessManager: stop_script: Function exit.")
self._trigger_ahk_stopped_callback()
def _monitor_ahk_log(self):
"""Continuously reads the AutoHotkey script's log file and puts new lines into the message queue."""
logger.info("AHKProcessManager: _monitor_ahk_log: Started monitoring AHK log file.")
time.sleep(1)
while self._keep_running and self._ahk_log_temp_file and os.path.exists(self._ahk_log_temp_file):
try:
if self._ahk_process and self._ahk_process.poll() is not None:
logger.warning("AHKProcessManager: AHK process unexpectedly terminated. Signaling stop.")
self._keep_running = False
self._trigger_ahk_stopped_callback()
break
with open(self._ahk_log_temp_file, 'r') as f:
f.seek(self._ahk_log_file_last_read_pos)
new_lines = f.readlines()
for line in new_lines:
self._message_queue.put(f"[AHK] {line.strip()}")
self._ahk_log_file_last_read_pos = f.tell()
except FileNotFoundError:
logger.warning("AHKProcessManager: _monitor_ahk_log: AHK log file not found, it might have been deleted or not created yet. Retrying...")
except Exception as e:
logger.error(f"AHKProcessManager: _monitor_ahk_log: Error reading AHK log file: {e}", exc_info=True)
time.sleep(AHK_LOG_MONITOR_INTERVAL)
logger.info("AHKProcessManager: _monitor_ahk_log: Stopped monitoring AHK log file.")
if not self._keep_running:
self.stop_script()
def _clean_ahk_temp_files(self):
"""Removes temporary AutoHotkey script and log files."""
logger.info("AHKProcessManager: _clean_ahk_temp_files: Attempting to clean up temporary AHK files.")
if self._ahk_script_temp_file and os.path.exists(self._ahk_script_temp_file):
try:
os.remove(self._ahk_script_temp_file)
logger.info(f"AHKProcessManager: Removed temporary AutoHotkey script: {self._ahk_script_temp_file}")
except Exception as e:
logger.error(f"AHKProcessManager: Failed to remove temporary AHK script file: {e}", exc_info=True)
self._ahk_script_temp_file = None
if self._ahk_log_temp_file and os.path.exists(self._ahk_log_temp_file):
try:
os.remove(self._ahk_log_temp_file)
logger.info(f"AHKProcessManager: Removed temporary AutoHotkey log: {self._ahk_log_temp_file}")
except Exception as e:
logger.error(f"AHKProcessManager: Failed to remove temporary AHK log file: {e}", exc_info=True)
self._ahk_log_temp_file = None
def _on_pynput_key_listener(self, key):
"""Callback for pynput keyboard listener to detect STOP_KEY."""
try:
if key == self._stop_key:
logger.info(f"AHKProcessManager: Stop key pressed. Signaling script to stop.")
self._keep_running = False
self._trigger_ahk_stopped_callback()
return False
except AttributeError: # Handle special keys which don't have a .char attribute (e.g., F-keys)
if key == self._stop_key:
logger.info(f"AHKProcessManager: '{self._stop_key}' pressed. Signaling script to stop.")
self._keep_running = False
self._trigger_ahk_stopped_callback()
return False
def start_pynput_listener(self):
"""
Starts the pynput keyboard listener thread.
This method is a public interface to initiate the internal listener thread.
"""
logger.info("AHKProcessManager: Public start_pynput_listener called. Initiating internal listener thread.")
threading.Thread(target=self._start_pynput_listener_thread_internal, daemon=True).start()
def _start_pynput_listener_thread_internal(self):
"""
Internal method to start the pynput keyboard listener.
This method is meant to be run in its own daemon thread.
"""
logger.info("AHKProcessManager: pynput listener thread started. Waiting for stop key press.")
try:
with Listener(on_press=self._on_pynput_key_listener) as listener:
listener.join()
except Exception as e:
logger.error(f"AHKProcessManager: Error in pynput listener thread: {e}", exc_info=True)
logger.info("AHKProcessManager: pynput listener thread stopped.")

105
model/app_logger.py Normal file
View File

@ -0,0 +1,105 @@
import logging
import queue
import sys
import os
import datetime
import configparser
# --- Global Message Queue for GUI Communication ---
# All log messages and AHK output pass through this queue to the View.
message_queue = queue.Queue()
# --- Custom QueueHandler for UI messages ---
class QueueHandler(logging.Handler):
"""
Custom logging handler to push *only the raw message* to a queue,
allowing the GUI to display it directly without additional formatting.
"""
def __init__(self, message_queue):
super().__init__()
self.message_queue = message_queue
def emit(self, record):
# Use record.getMessage() to get the log message without formatters applied
self.message_queue.put(record.getMessage())
# --- Configuration Constants for Logging ---
LOG_FILENAME_BASE = "JarvisKeyPressUtility"
CONFIG_FILE_NAME = "config.ini"
def setup_logging_from_config():
"""
Sets up logging handlers (console and file) based on settings in config.ini.
Creates a default config.ini if it doesn't exist.
This function should be called once at application startup.
"""
root_logger = logging.getLogger()
# Clear existing handlers to prevent duplicates if function is called multiple times
if root_logger.handlers:
for handler in list(root_logger.handlers):
root_logger.removeHandler(handler)
config = configparser.ConfigParser()
# Determine the execution directory for the config and log files
if getattr(sys, 'frozen', False): # Running as a PyInstaller executable
execution_dir = os.path.dirname(sys.executable)
else: # Running as a Python script
# When called from main.py, os.path.abspath(__file__) points to model/app_logger.py.
# Need to go up two levels to reach project root where config.ini is expected.
execution_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
config_file_path = os.path.join(execution_dir, CONFIG_FILE_NAME)
# Create default config.ini if it doesn't exist
if not os.path.exists(config_file_path):
config['Logging'] = {
'file_log_level': 'DEBUG', # Default to DEBUG for file
'console_log_level': 'INFO' # Default to INFO for console
}
try:
with open(config_file_path, 'w') as f:
config.write(f)
print(f"Created default '{CONFIG_FILE_NAME}' at '{config_file_path}'")
except IOError as e:
print(f"Error creating default config.ini at {config_file_path}: {e}")
# Fallback to default levels if config file cannot be created
file_log_level = logging.DEBUG
console_log_level = logging.INFO
else:
config.read(config_file_path)
# Get log levels from config, default to INFO if not found or invalid
file_log_level_str = config.get('Logging', 'file_log_level', fallback='DEBUG').upper()
console_log_level_str = config.get('Logging', 'console_log_level', fallback='INFO').upper()
file_log_level = getattr(logging, file_log_level_str, logging.INFO)
console_log_level = getattr(logging, console_log_level_str, logging.INFO)
# Set the root logger level to the lowest (most verbose) level needed by any handler
root_logger.setLevel(min(file_log_level, console_log_level, logging.WARNING))
# 1. Console Handler: Outputs to stdout
console_handler = logging.StreamHandler(sys.stdout)
console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
console_handler.setFormatter(console_formatter)
console_handler.setLevel(console_log_level) # Set level for console output
root_logger.addHandler(console_handler)
# 2. File Handler: Outputs to a log file
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_file_path = os.path.join(execution_dir, f"{LOG_FILENAME_BASE}_{timestamp}.log")
file_handler = logging.FileHandler(log_file_path, encoding='utf-8')
file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(funcName)s - %(message)s')
file_handler.setFormatter(file_formatter)
file_handler.setLevel(file_log_level) # Set level for file output (e.g., DEBUG for all)
root_logger.addHandler(file_handler)
# 3. UI Queue Handler (for messages sent to Tkinter GUI)
ui_queue_handler = QueueHandler(message_queue)
ui_queue_handler.setLevel(logging.WARNING) # UI usually wants WARNING or higher messages by default
root_logger.addHandler(ui_queue_handler)
return log_file_path # Return path for initial log message in main

27
model/constants.py Normal file
View File

@ -0,0 +1,27 @@
from pynput.keyboard import Key
# --- Configuration Constants ---
STOP_KEY = Key.f6 # The global key to stop the AutoHotkey script listener
RETRY_WINDOW_SECONDS = 2 # Not directly used in current AHK template, but kept for context
AHK_LOG_MONITOR_INTERVAL = 0.5 # Interval to check AHK log file for new messages
# Path to the external AutoHotkey template file, now relative from model/ to resources/
AHK_TEMPLATE_REL_PATH_FROM_MODEL_DIR = os.path.join(os.pardir, "resources", "template.ahk")
# List of common AutoHotkey.exe installation paths and fallbacks.
AHK_EXECUTABLE_FALLBACK_PATHS = [
"C:\\Program Files\\AutoHotkey\\v2\\AutoHotkey.exe", # Common AHK v2 install path
"C:\\Program Files\\AutoHotkey\\AutoHotkey.exe", # Common AHK v1 install path
"AutoHotkey.exe" # Fallback: relies on system PATH or current working directory
]
# Mapping for common key names to AutoHotkey v2 equivalents.
AUTOHOTKEY_KEY_MAP = {
'+': 'NumpadAdd', 'num+': 'NumpadAdd', 'numpad+': 'NumpadAdd',
'enter': 'Enter', 'return': 'Enter', 'space': 'Space',
'esc': 'Escape', 'del': 'Delete', 'Backspace': 'Backspace',
'tab': 'Tab', 'shift': 'Shift', 'ctrl': 'Control', 'control': 'Control',
'alt': 'Alt', 'up': 'Up', 'down': 'Down', 'left': 'Left', 'right': 'Right',
'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6',
'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12'
}

78
model/key_press_model.py Normal file
View File

@ -0,0 +1,78 @@
import logging
import os
import sys
# Import components from other model files
from model.constants import STOP_KEY, AHK_TEMPLATE_REL_PATH_FROM_MODEL_DIR, AHK_EXECUTABLE_FALLBACK_PATHS, AUTOHOTKEY_KEY_MAP
from model.app_logger import message_queue # Assuming app_logger provides the message_queue and handles initial logging setup
from model.ahk_process_manager import AHKProcessManager
from model.window_manager import WindowManager
from pynput.keyboard import Key, Listener # Still needed here for STOP_KEY in on_press_listener callback directly
logger = logging.getLogger(__name__)
class KeyPressModel:
def __init__(self):
self.current_target_window_title = ""
self.current_key_to_press_str = '+'
self._ahk_stopped_callback = None # Callback to Controller when AHK script stops externally
# Instantiate sub-components that manage specific areas of the model's responsibility
self.ahk_manager = AHKProcessManager(
ahk_template_rel_path=AHK_TEMPLATE_REL_PATH_FROM_MODEL_DIR,
ahk_executable_fallback_paths=AHK_EXECUTABLE_FALLBACK_PATHS,
autohotkey_key_map=AUTOHOTKEY_KEY_MAP,
stop_key=STOP_KEY,
message_queue=message_queue # Pass the central message queue
)
self.window_manager = WindowManager()
# Register callback for AHK process stopping
self.ahk_manager.register_ahk_stopped_callback(self._ahk_process_stopped_internally)
logger.info("Model: KeyPressModel Initialized. Sub-components loaded.")
def _ahk_process_stopped_internally(self):
"""Internal callback from AHKProcessManager when AHK script stops."""
logger.info("Model: AHK process reported termination.")
if self._ahk_stopped_callback:
self._ahk_stopped_callback()
def register_ahk_stopped_callback(self, callback_func):
"""Registers a callback function to be called when the AHK script stops externally."""
self._ahk_stopped_callback = callback_func
logger.info("Model: AHK stopped callback registered by Controller.")
def get_window_titles(self):
"""Retrieves a sorted list of active window titles from WindowManager."""
return self.window_manager.get_window_titles()
def set_target_window(self, title):
"""Sets the target window title for AHK operations in AHKProcessManager."""
self.current_target_window_title = title
self.ahk_manager.set_target_window(title)
logger.info(f"Model: Target window set to: '{self.current_target_window_title}' in AHK manager.")
def set_key_to_press(self, key_str):
"""Sets the key string to be pressed by AHK in AHKProcessManager."""
self.current_key_to_press_str = key_str
self.ahk_manager.set_key_to_press(key_str)
logger.info(f"Model: Key to press set to: '{self.current_key_to_press_str}' in AHK manager.")
def get_message_queue(self):
"""Returns the central message queue."""
return message_queue
def start_autohotkey_script(self):
"""Initiates the AHK script launch via AHKProcessManager."""
return self.ahk_manager.start_script()
def stop_autohotkey_script(self):
"""Terminates the AHK script via AHKProcessManager."""
self.ahk_manager.stop_script()
def start_pynput_listener_thread(self):
"""Starts the pynput keyboard listener thread via AHKProcessManager."""
self.ahk_manager.start_pynput_listener()

34
model/window_manager.py Normal file
View File

@ -0,0 +1,34 @@
import pygetwindow as gw
import logging
logger = logging.getLogger(__name__)
class WindowManager:
"""
Manages interactions related to system windows,
primarily retrieving active window titles.
"""
def __init__(self):
logger.info("WindowManager: Initialized.")
def get_window_titles(self):
"""
Retrieves a sorted list of active window titles.
Excludes the utility's own window by its title.
"""
logger.info("WindowManager: Attempting to retrieve active window list.")
try:
all_windows = gw.getAllWindows()
# Filter out empty titles and the utility's own window by its default title
window_titles = sorted(
list(set([
win.title for win in all_windows
if win.title and win.title != "Jarvis Key Press Utility (AHK Integrated)"
]))
)
logger.info(f"WindowManager: Found {len(window_titles)} active windows.")
return window_titles
except Exception as e:
logger.error(f"WindowManager: Failed to retrieve window list: {e}", exc_info=True)
return []

76
resources/template.ahk Normal file
View File

@ -0,0 +1,76 @@
; === CONFIGURATION (Filled by Python) ===
TargetWindowTitle := "{TARGET_WINDOW_TITLE_PLACEHOLDER}"
KeyToPressAHK := "{KEY_TO_PRESS_AHK_PLACEHOLDER}"
StopKeyAHK := "{STOP_KEY_AHK_PLACEHOLDER}"
AHKLogFile := "{AHK_LOG_FILE_PLACEHOLDER}"
; === AHK LOGGING FUNCTION ===
Log(message) {{
FileAppend(message "`n", AHKLogFile)
}}
; === SCRIPT LOGIC ===
Log("AHK Script started.")
; Global state variable to track if the key is currently pressed down
isKeyDown := false
{STOP_KEY_AHK_PLACEHOLDER}::
{{
Log("Stop key pressed. Releasing key and exiting.")
Send("{{KeyToPressAHK Up}}") ; Ensure key is released on manual stop
ExitApp
}}
SetTimer(() => SendKeyLoop(), 100)
SendKeyLoop() {{
global isKeyDown
; Check if window exists and is NOT active, then attempt activation
If WinExist(TargetWindowTitle) && not WinActive(TargetWindowTitle) {{
Log("Target window '" TargetWindowTitle "' exists but not active. Attempting activation.")
WinActivate(TargetWindowTitle)
Sleep 50 ; Give a moment for the window to become active
if WinActive(TargetWindowTitle) {{
Log("Target window '" TargetWindowTitle "' is now active.")
}}
}}
; If window does NOT exist
Else {{
Log("Target window '" TargetWindowTitle "' not found. Stopping script.")
If (isKeyDown) {{
Send("{{KeyToPressAHK Up}}")
isKeyDown := false
Log("Key released (Target window not found).")
}}
ExitApp ; <<< NEW: Exit AHK script if window is not found
}}
; After potential activation attempt, or if already active, check active state to send/release key
if WinActive(TargetWindowTitle) {{
If (not isKeyDown) {{ ; Only press down if not already down
Send("{{KeyToPressAHK Down}}")
isKeyDown := true
Log("Key pressed down (Target window active).")
}}
}} Else {{
; Window exists but is not active (either initially not active, or activation failed)
Log("Target window '" TargetWindowTitle "' exists but failed to activate.")
If (isKeyDown) {{
Send("{{KeyToPressAHK Up}}")
isKeyDown := false
Log("Key released (Failed to activate target window).")
}}
}}
}}
OnScriptExit(*) {{ ; Defined as a function that accepts any number of parameters
global isKeyDown
If (isKeyDown) {{ ; Ensure key is released if script exits while it's down
Send("{{KeyToPressAHK Up}}")
Log("Key released (Script exiting).")
}}
Log("AHK Script exiting.")
ExitApp
}}

View File

@ -0,0 +1,72 @@
@echo off
setlocal
REM Navigate to the project root from the scripts folder
pushd ..
REM Now current directory is project_root/
REM --- Ensure bin directory exists at project root ---
echo.
echo Ensuring 'bin' directory exists at project root...
IF NOT EXIST "bin" (
mkdir "bin"
echo Created 'bin' directory.
)
echo.
REM --- Cleanup Previous Build Artifacts within bin folder ---
echo.
echo Cleaning up previous build artifacts in 'bin' folder...
IF EXIST "bin\build" (
rmdir /s /q "bin\build"
echo Removed 'bin\build' directory.
)
IF EXIST "bin\dist" (
rmdir /s /q "bin\dist"
echo Removed 'bin\dist' directory.
)
IF EXIST "bin\JarvisKeyPressUtility.spec" (
del /q "bin\JarvisKeyPressUtility.spec"
echo Removed 'bin\JarvisKeyPressUtility.spec' file.
)
echo Cleanup in 'bin' complete.
echo.
REM Navigate back to the scripts folder where exe_compile.cmd is
popd
REM Now current directory is project_root/scripts/
REM This is the PyInstaller command to build your executable.
REM Paths are now relative to the current directory (scripts/).
pyinstaller --onefile --windowed --name "JarvisKeyPressUtility" ^
--add-binary "C:\Program Files\AutoHotkey\v2\AutoHotkey.exe;." ^
--add-data "..\resources;resources" ^
--uac-admin ..\main.py ^
--distpath ..\bin ^
--workpath ..\bin ^
--specpath ..\bin
REM Capture the exit code of the PyInstaller command.
set "EXIT_CODE=%ERRORLEVEL%"
REM Check the exit code. If it's not 0 (indicating a compilation error), pause.
IF %EXIT_CODE% NEQ 0 (
echo.
echo PyInstaller compilation failed!
echo PyInstaller exited with code: %EXIT_CODE%
echo.
pause
) ELSE (
REM If compilation was successful, print a message and allow the window to close.
echo PyInstaller compilation successful.
echo The executable "JarvisKeyPressUtility.exe" should be in the 'bin\dist' folder.
REM --- Copy template.ahk to bin\dist folder (already handled by --add-data) ---
REM This line is now effectively redundant as the entire 'resources' folder
REM including 'template.ahk' is bundled by '--add-data "..\resources;resources"'
REM PyInstaller places it within the extracted temp folder, accessible via sys._MEIPASS
)
endlocal
REM Exit the batch script with the same exit code as PyInstaller.
exit /b %EXIT_CODE%

105
scripts/signing.py Normal file
View File

@ -0,0 +1,105 @@
import subprocess
import os
import sys
import logging
# --- Configuration for Code Signing ---
# !!! IMPORTANT: Replace these placeholders with your actual details !!!
# Path to your .pfx or .p12 certificate file
CERTIFICATE_PATH = r"C:\Path\To\Your\my_certificate.pfx"
# Password for your certificate file
CERTIFICATE_PASSWORD = "YourSecureCertificatePassword"
# Path to signtool.exe (usually in Windows SDK bin folder)
# You might need to adjust the version number (e.g., 10.0.19041.0) and architecture (x64/x86)
SIGNTOOL_PATH = r"C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe"
# Name of your PyInstaller-generated executable (found in the 'dist' folder)
EXE_NAME = "JarvisKeyPressUtility.exe"
# Timestamp server URL (use one provided by your CA, e.g., DigiCert, Sectigo)
TIMESTAMP_SERVER = "http://timestamp.digicert.com"
# --- Logging Setup ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def run_command(command, cwd=None):
"""Executes a shell command and captures its output."""
try:
logging.info(f"Executing command: {' '.join(command)}")
process = subprocess.run(command, capture_output=True, text=True, check=True, cwd=cwd)
logging.info("Command completed successfully.")
logging.info(f"STDOUT:\n{process.stdout}")
if process.stderr:
logging.warning(f"STDERR:\n{process.stderr}")
return True
except subprocess.CalledProcessError as e:
logging.error(f"Command failed with exit code {e.returncode}.")
logging.error(f"STDOUT:\n{e.stdout}")
logging.error(f"STDERR:\n{e.stderr}")
return False
except FileNotFoundError:
logging.error(f"Error: Executable not found. Check the path for: {command[0]}")
return False
except Exception as e:
logging.error(f"An unexpected error occurred: {e}")
return False
def sign_executable(exe_path, cert_path, cert_password, signtool_path, timestamp_server):
"""
Signs the given executable using signtool.exe.
"""
if not os.path.exists(signtool_path):
logging.error(f"SignTool not found at: {signtool_path}. Please ensure Windows SDK is installed and path is correct.")
return False
if not os.path.exists(cert_path):
logging.error(f"Certificate file not found at: {cert_path}. Please check the path.")
return False
logging.info(f"Attempting to sign executable: {exe_path}")
# Signtool command components
command = [
signtool_path,
"sign",
"/f", cert_path, # Certificate file
"/p", cert_password, # Certificate password
"/fd", "sha256", # File digest algorithm
"/tr", timestamp_server, # Timestamp server URL
"/td", "sha256", # Timestamp digest algorithm
exe_path # Executable to sign
]
return run_command(command)
if __name__ == "__main__":
# Ensure this script is run from the same directory where 'dist' folder is or adjust paths.
# Typically, you'd run this script after PyInstaller has finished its build.
current_script_dir = os.path.dirname(os.path.abspath(__file__))
dist_folder = os.path.join(current_script_dir, "dist")
exe_path_in_dist = os.path.join(dist_folder, EXE_NAME)
if not os.path.exists(dist_folder):
logging.error(f"Error: 'dist' folder not found at '{dist_folder}'. Please run PyInstaller first.")
sys.exit(1)
if not os.path.exists(exe_path_in_dist):
# Handle --onefile vs. no --onefile
if not os.path.exists(os.path.join(dist_folder, EXE_NAME.replace(".exe", ""), EXE_NAME)):
logging.error(f"Error: Executable '{EXE_NAME}' not found in '{dist_folder}' or its subfolder (for non-onefile builds).")
logging.info("If you used PyInstaller without --onefile, the EXE might be in a subfolder like 'dist/JarvisKeyPressUtility/JarvisKeyPressUtility.exe'.")
sys.exit(1)
else:
exe_path_to_sign = os.path.join(dist_folder, EXE_NAME.replace(".exe", ""), EXE_NAME)
logging.info(f"Detected non-onefile build. Signing: {exe_path_to_sign}")
else:
exe_path_to_sign = exe_path_in_dist
logging.info(f"Detected onefile build. Signing: {exe_path_to_sign}")
logging.info("Starting code signing process.")
success = sign_executable(exe_path_to_sign, CERTIFICATE_PATH, CERTIFICATE_PASSWORD, SIGNTOOL_PATH, TIMESTAMP_SERVER)
if success:
logging.info(f"Successfully signed '{EXE_NAME}'. Your AVG should now be less annoyed, theoretically.")
else:
logging.error(f"Failed to sign '{EXE_NAME}'. Check the log for details on the signtool error.")

157
view/view.py Normal file
View File

@ -0,0 +1,157 @@
import tkinter as tk
from tkinter import scrolledtext, Label, Button, Entry, StringVar, ttk, messagebox
import datetime
import queue
class KeyPressView(tk.Frame):
def __init__(self, master, controller):
super().__init__(master)
self.master = master
self.controller = controller # Store reference to controller
self.master.title("Jarvis Key Press Utility (AHK Integrated)")
self.master.geometry("600x600")
self.pack(fill=tk.BOTH, expand=True)
self.grid_columnconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=0)
self.grid_rowconfigure(1, weight=0)
self.grid_rowconfigure(2, weight=1)
# --- Configuration Section ---
self.config_frame = tk.LabelFrame(self, text="Configuration", padx=10, pady=10, font=('Inter', 10, 'bold'))
self.config_frame.grid(row=0, column=0, columnspan=2, padx=10, pady=10, sticky="ew")
self.config_frame.grid_columnconfigure(1, weight=1)
Label(self.config_frame, text="Key to Press:", font=('Inter', 10)).grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.key_to_press_var = StringVar(value="+")
self.key_input_entry = Entry(self.config_frame, textvariable=self.key_to_press_var, width=20, font=('Inter', 10))
self.key_input_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
Label(self.config_frame, text="(e.g., '+', 'a', 'enter', 'space', 'f5')", font=('Inter', 8), fg="#586e75").grid(row=1, column=1, padx=5, pady=0, sticky="nw")
self.key_validation_label = Label(self.config_frame, text="Please enter a key to press.", font=('Inter', 8, 'bold'), fg="red")
self.key_validation_label.grid(row=0, column=2, padx=5, pady=5, sticky="w")
self.key_validation_label.grid_remove()
Label(self.config_frame, text="Target Window:", font=('Inter', 10)).grid(row=2, column=0, padx=5, pady=5, sticky="w")
self.target_window_title_var = StringVar(value="--- Select a Window ---")
self.window_combobox = ttk.Combobox(self.config_frame, textvariable=self.target_window_title_var,
state="readonly", font=('Inter', 10))
self.window_combobox.grid(row=2, column=1, padx=5, pady=5, sticky="ew")
self.window_combobox.bind("<<ComboboxSelected>>", self.controller.on_window_selected) # Bind to controller directly
self.refresh_button = Button(self.config_frame, text="Refresh Windows", command=self.controller.refresh_windows, font=('Inter', 9))
self.refresh_button.grid(row=2, column=2, padx=5, pady=5, sticky="ew")
self.target_window_validation_label = Label(self.config_frame, text="Please select a target window.", font=('Inter', 8, 'bold'), fg="red")
self.target_window_validation_label.grid(row=3, column=1, padx=5, pady=0, sticky="nw")
self.target_window_validation_label.grid_remove()
# --- Control Buttons Section ---
self.button_frame = tk.Frame(self)
self.button_frame.grid(row=1, column=0, columnspan=2, padx=10, pady=10, sticky="ew")
self.button_frame.grid_columnconfigure(0, weight=1)
self.button_frame.grid_columnconfigure(1, weight=1)
self.button_frame.grid_columnconfigure(2, weight=1) # For Clear Log Button
self.start_button = Button(self.button_frame, text="Start Holding Key", command=self.controller.start_key_press, bg="#2aa198", fg="white", font=('Inter', 12, 'bold'))
self.start_button.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
self.stop_button = Button(self.button_frame, text="Stop Key Press (or F6)", command=self.controller.stop_key_press, bg="#dc322f", fg="white", font=('Inter', 12, 'bold'))
self.stop_button.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.stop_button.config(state=tk.DISABLED)
self.clear_log_button = Button(self.button_frame, text="Clear Log", command=self._clear_log, font=('Inter', 9))
self.clear_log_button.grid(row=0, column=2, padx=5, pady=5, sticky="ew")
# --- Log Display Area ---
self.log_frame = tk.LabelFrame(self, text="Activity Log (Jarvis's Observations)", padx=10, pady=10, font=('Consolas', 10, 'bold'))
self.log_frame.grid(row=2, column=0, columnspan=2, padx=10, pady=10, sticky="nsew")
self.log_frame.grid_rowconfigure(0, weight=1)
self.log_frame.grid_columnconfigure(0, weight=1)
self.log_text_area = scrolledtext.ScrolledText(self.log_frame, state='disabled', wrap='word', width=60, height=15, bg="#eee8d5", fg="#586e75", font=('Consolas', 10))
self.log_text_area.grid(row=0, column=0, sticky="nsew")
# Initial population of window list is now triggered by controller's initial_setup_after_view_is_ready()
self.master.after(100, self._poll_message_queue)
# --- View Update Methods (Called by Controller) ---
def update_window_list(self, window_titles, current_selected_title):
"""Updates the combobox with new window titles."""
self.window_combobox['values'] = window_titles
self.target_window_title_var.set(current_selected_title)
def get_key_input(self):
"""Returns the current value of the key input entry."""
return self.key_to_press_var.get().strip()
def get_selected_window_title(self):
"""Returns the currently selected window title from the combobox."""
return self.target_window_title_var.get()
def set_ui_state_running(self):
"""Sets UI elements to a 'running' state."""
self.start_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.NORMAL)
self.key_input_entry.config(state=tk.DISABLED)
self.window_combobox.config(state=tk.DISABLED)
self.refresh_button.config(state=tk.DISABLED)
# Validation labels are hidden by UIController when changing state
def set_ui_state_idle(self):
"""Sets UI elements to an 'idle' state."""
self.start_button.config(state=tk.NORMAL)
self.stop_button.config(state=tk.DISABLED)
self.key_input_entry.config(state=tk.NORMAL)
self.window_combobox.config(state="readonly")
self.refresh_button.config(state=tk.NORMAL)
# Validation labels visibility will be managed by UIController's _check_start_button_state_ui()
def show_input_error(self, message):
"""Displays a messagebox error for input validation."""
messagebox.showerror("Input Error", message)
def set_key_validation_visibility(self, visible):
"""Controls visibility of the key validation label."""
if visible:
self.key_validation_label.grid()
else:
self.key_validation_label.grid_remove()
def set_window_validation_visibility(self, visible):
"""Controls visibility of the window validation label."""
if visible:
self.target_window_validation_label.grid()
else:
self.target_window_validation_label.grid_remove()
def _on_window_selected_event(self, event):
"""Internal handler for combobox selection event, calls controller."""
# This now directly calls the main controller, which will then use the UIController
self.controller.on_window_selected()
def _poll_message_queue(self):
"""Periodically checks the message queue for log updates."""
message_queue = self.controller.get_message_queue()
while True:
try:
record = message_queue.get(block=False)
self.log_text_area.configure(state='normal')
# Add current timestamp to log entries for better tracking
timestamp_str = datetime.datetime.now().strftime('%H:%M:%S')
self.log_text_area.insert(tk.END, f"{timestamp_str} - {record}\n")
self.log_text_area.see(tk.END)
self.log_text_area.configure(state='disabled')
except queue.Empty:
break
self.master.after(100, self._poll_message_queue)
def _clear_log(self):
"""Clears the content of the log text area."""
self.log_text_area.configure(state='normal')
self.log_text_area.delete(1.0, tk.END)
self.log_text_area.configure(state='disabled')