From c51c099c759515c16be5b4bacd5a2a004b150869 Mon Sep 17 00:00:00 2001 From: Kuro Date: Thu, 19 Jun 2025 20:38:55 +0200 Subject: [PATCH] inital commit --- .gitignore | 242 +++++++++++++++++++++++++ controller/controller.py | 136 ++++++++++++++ controller/ui_controller.py | 111 ++++++++++++ main.py | 65 +++++++ model/ahk_process_manager.py | 305 ++++++++++++++++++++++++++++++++ model/app_logger.py | 105 +++++++++++ model/constants.py | 27 +++ model/key_press_model.py | 78 ++++++++ model/window_manager.py | 34 ++++ resources/template.ahk | 76 ++++++++ scripts/create_keypress_exe.cmd | 72 ++++++++ scripts/signing.py | 105 +++++++++++ view/view.py | 157 ++++++++++++++++ 13 files changed, 1513 insertions(+) create mode 100644 .gitignore create mode 100644 controller/controller.py create mode 100644 controller/ui_controller.py create mode 100644 main.py create mode 100644 model/ahk_process_manager.py create mode 100644 model/app_logger.py create mode 100644 model/constants.py create mode 100644 model/key_press_model.py create mode 100644 model/window_manager.py create mode 100644 resources/template.ahk create mode 100644 scripts/create_keypress_exe.cmd create mode 100644 scripts/signing.py create mode 100644 view/view.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd8ff78 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/controller/controller.py b/controller/controller.py new file mode 100644 index 0000000..5df75f4 --- /dev/null +++ b/controller/controller.py @@ -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.") + diff --git a/controller/ui_controller.py b/controller/ui_controller.py new file mode 100644 index 0000000..5605431 --- /dev/null +++ b/controller/ui_controller.py @@ -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 + diff --git a/main.py b/main.py new file mode 100644 index 0000000..3dacefe --- /dev/null +++ b/main.py @@ -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() diff --git a/model/ahk_process_manager.py b/model/ahk_process_manager.py new file mode 100644 index 0000000..2298246 --- /dev/null +++ b/model/ahk_process_manager.py @@ -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.") + diff --git a/model/app_logger.py b/model/app_logger.py new file mode 100644 index 0000000..3e91533 --- /dev/null +++ b/model/app_logger.py @@ -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 + diff --git a/model/constants.py b/model/constants.py new file mode 100644 index 0000000..02946e8 --- /dev/null +++ b/model/constants.py @@ -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' +} diff --git a/model/key_press_model.py b/model/key_press_model.py new file mode 100644 index 0000000..7c2d735 --- /dev/null +++ b/model/key_press_model.py @@ -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() + diff --git a/model/window_manager.py b/model/window_manager.py new file mode 100644 index 0000000..a287339 --- /dev/null +++ b/model/window_manager.py @@ -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 [] + diff --git a/resources/template.ahk b/resources/template.ahk new file mode 100644 index 0000000..b6e3343 --- /dev/null +++ b/resources/template.ahk @@ -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 + }} diff --git a/scripts/create_keypress_exe.cmd b/scripts/create_keypress_exe.cmd new file mode 100644 index 0000000..f8f2fac --- /dev/null +++ b/scripts/create_keypress_exe.cmd @@ -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% \ No newline at end of file diff --git a/scripts/signing.py b/scripts/signing.py new file mode 100644 index 0000000..6d804cd --- /dev/null +++ b/scripts/signing.py @@ -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.") \ No newline at end of file diff --git a/view/view.py b/view/view.py new file mode 100644 index 0000000..66e60bb --- /dev/null +++ b/view/view.py @@ -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("<>", 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') +