├── .github └── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── .gitignore ├── LICENSE ├── build_portable.py ├── docs ├── README.md ├── configuration_file.md ├── icons │ ├── logo.png │ └── readme.png ├── images │ ├── headers │ │ └── github-banner-readme.png │ └── screenshots │ │ ├── other_actions.png │ │ ├── process_list.png │ │ ├── process_list_actions.png │ │ ├── process_list_faded.png │ │ ├── process_list_menu.png │ │ ├── process_list_only.png │ │ ├── process_rules.png │ │ ├── process_rules_actions.png │ │ ├── process_rules_add_rule.gif │ │ ├── process_rules_error.png │ │ ├── process_rules_menu.png │ │ ├── process_rules_only.png │ │ ├── process_rules_unsaved.png │ │ ├── save_action.png │ │ ├── service_rules_only.png │ │ ├── tooltips.png │ │ └── tray_menu.png ├── rule_behavior_and_tips.md ├── run_and_build.md └── ui_process_governor.md ├── mypy.ini ├── process-governor.py ├── requirements.txt ├── resources ├── UI │ ├── add-process-rule.png │ ├── add-service-rule.png │ ├── add.png │ ├── config.png │ ├── copy.png │ ├── delete.png │ ├── down.png │ ├── error.png │ ├── file-properties.png │ ├── go-to-rule.png │ ├── info-tooltip.png │ ├── log.png │ ├── open-folder.png │ ├── process-list.png │ ├── process-rules.png │ ├── process.png │ ├── redo.png │ ├── refresh.png │ ├── save.png │ ├── select-all.png │ ├── service-properties.png │ ├── service-rules.png │ ├── service.png │ ├── sort-down.png │ ├── sort-empty.png │ ├── sort-up.png │ ├── undo.png │ ├── up.png │ └── warn-tooltip.png ├── app.ico └── startup.bat └── src ├── configuration ├── config.py ├── handler │ └── affinity.py ├── logs.py ├── migration │ ├── all_migration.py │ ├── base.py │ ├── m0_rules_to_split_rules_config.py │ ├── m1_new_fields_in_rule.py │ └── m2_remove_high_io_priority_and_logging.py └── rule.py ├── constants ├── app_info.py ├── files.py ├── log.py ├── resources.py ├── threads.py ├── ui.py └── updates.py ├── enums ├── bool.py ├── filters.py ├── io_priority.py ├── messages.py ├── priority.py ├── process.py ├── rules.py └── selector.py ├── main_loop.py ├── model ├── process.py └── service.py ├── service ├── config_service.py ├── processes_info_service.py ├── rules_service.py └── services_info_service.py ├── ui ├── settings.py ├── settings_actions.py ├── tray.py └── widget │ ├── common │ ├── button.py │ ├── combobox.py │ ├── entry.py │ ├── label.py │ └── treeview │ │ ├── editable.py │ │ ├── extended.py │ │ ├── pydantic.py │ │ ├── scrollable.py │ │ └── sortable.py │ └── settings │ ├── settings_tabs.py │ ├── tabs │ ├── base_tab.py │ ├── processes │ │ ├── process_list.py │ │ ├── process_list_actions.py │ │ ├── process_list_context_menu.py │ │ └── process_tab.py │ └── rules │ │ ├── base_rules_tab.py │ │ ├── rules_list.py │ │ ├── rules_list_actions.py │ │ └── rules_tabs.py │ └── tooltip.py └── util ├── cpu.py ├── decorators.py ├── files.py ├── history.py ├── lock_instance.py ├── messages.py ├── scheduler.py ├── startup.py ├── ui.py ├── updates.py ├── utils.py └── windows_scheduler.py /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | about: Create a bug report 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ### Steps to reproduce 15 | 16 | Steps to reproduce the behavior. 17 | 18 | ### Expected behavior 19 | 20 | A clear and concise description of what you expected to happen. 21 | 22 | ### Environment 23 | 24 | - Windows Version: [e.g. 7/8/10/11] 25 | - Other details that you think may affect. 26 | 27 | ### Additional context 28 | 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | about: Suggest an idea 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | Brief explanation of the feature. 13 | 14 | ### Basic example 15 | 16 | Include a basic example or links here. 17 | 18 | ### Motivation 19 | 20 | Why are we doing this? What use cases does it support? What is the expected outcome? 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # IDEs 156 | .idea/ 157 | .vscode/ 158 | .cursor/ 159 | 160 | # Project ignores 161 | /downloads_for_van/ 162 | /dist/ 163 | /config.json 164 | /config_build/config.json 165 | /logging.txt 166 | 167 | /pg.lock 168 | /sandbox.py 169 | /versionfile.txt 170 | -------------------------------------------------------------------------------- /build_portable.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import shutil 4 | 5 | import PyInstaller.__main__ 6 | import pyinstaller_versionfile 7 | 8 | from constants.app_info import APP_NAME, APP_VERSION, APP_AUTHOR, APP_NAME_WITH_VERSION 9 | from constants.files import CONFIG_FILE_NAME 10 | from util.files import explore 11 | 12 | # Setting paths and configuration parameters 13 | VERSION_FILE = "versionfile.txt" 14 | DIST = os.path.join(os.getcwd(), 'dist') 15 | APP_DIST = os.path.join(DIST, APP_NAME) 16 | APP_DIST_WITH_VERSION = os.path.join(DIST, APP_NAME_WITH_VERSION) 17 | CONFIG_FILE_FOR_TESTS = os.path.join(os.getcwd(), CONFIG_FILE_NAME) 18 | CONFIG_FILE_IN_APP_DIST = os.path.join(APP_DIST, CONFIG_FILE_NAME) 19 | SPEC_FILES = r".\*.spec" 20 | 21 | # Deleting existing .spec files to clean up the directory 22 | for filename in glob.glob(SPEC_FILES): 23 | os.remove(filename) 24 | 25 | # Creating a version file for the executable 26 | pyinstaller_versionfile.create_versionfile( 27 | output_file=VERSION_FILE, 28 | version=APP_VERSION, 29 | company_name=APP_AUTHOR, 30 | file_description=APP_NAME, 31 | internal_name=APP_NAME, 32 | legal_copyright=f"© {APP_AUTHOR}", 33 | original_filename=f"{APP_NAME}.exe", 34 | product_name=APP_NAME 35 | ) 36 | 37 | # Running PyInstaller to build the application 38 | PyInstaller.__main__.run([ 39 | 'process-governor.py', # Source script file 40 | '--clean', # Clean previous builds 41 | '--noconfirm', # No confirmation when deleting dist directory 42 | '--onedir', # Build the app in one directory 43 | '--uac-admin', # Request admin rights on launch 44 | '--hide-console', 'hide-early', # Hide the console on startup 45 | '--add-data', './resources/;./resources', # Add additional resources 46 | '--contents-directory', 'scripts', # Directory for Python and app scripts in the built package 47 | '--icon', 'resources/app.ico', # Application icon 48 | '--debug', 'noarchive', # Disables bundling of application scripts inside the exe 49 | '--name', APP_NAME, # Name of the executable file 50 | '--version-file', VERSION_FILE, # Path to the version file 51 | '--distpath', DIST, # Directory to save the built application 52 | '--collect-all', 'tksvg', 53 | ]) 54 | 55 | # Creating an archive of the built application 56 | archive_file_path = shutil.make_archive(APP_DIST_WITH_VERSION, 'zip', APP_DIST) 57 | 58 | # Copying the configuration file for tests into the built application 59 | if os.path.isfile(CONFIG_FILE_FOR_TESTS): 60 | shutil.copyfile(CONFIG_FILE_FOR_TESTS, CONFIG_FILE_IN_APP_DIST) 61 | 62 | explore(archive_file_path) -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Contributors][contributors-shield]][contributors-url] 4 | [![Forks][forks-shield]][forks-url] 5 | [![Stargazers][stars-shield]][stars-url] 6 | [![Issues][issues-shield]][issues-url] 7 | [![License][license-shield]][license-url] 8 | 9 |
10 |
11 | 12 | Logo 13 | 14 | 15 |

Process Governor

16 | 17 |

18 | A utility to automate Windows process and service management. 19 |
20 | Explore the docs » 21 |
22 |
23 | Report Bug 24 | · 25 | Request Feature 26 |

27 |
28 | 29 |
30 | Table of Contents 31 |
    32 |
  1. About The Project
  2. 33 |
  3. Getting Started
  4. 34 |
  5. Documentation
  6. 35 |
  7. Star History
  8. 36 |
  9. License
  10. 37 |
38 |
39 | 40 | ## About The Project 41 | 42 |
43 | 44 |
45 | 46 | **Process Governor** is a Python utility that automates the management of Windows processes and services by adjusting 47 | their priorities, I/O priorities, and core affinity according to user-defined rules. 48 | 49 | ### Features 50 | 51 | - Adjust process and service priorities for better performance. 52 | - Control I/O priorities to optimize resource utilization. 53 | - Define core affinity for processes. 54 | - Fine-tune Windows services and processes based on user-defined rules. 55 | 56 | ### Screenshots 57 | 58 |
59 | Click to expand 60 | 61 | > ![](images/screenshots/process_list.png) 62 | > 63 | > ![](images/screenshots/process_rules.png) 64 | > 65 | > ![](images/screenshots/tray_menu.png) 66 |
67 | 68 |

(back to top)

69 | 70 | ## Getting Started 71 | 72 | To get started with **Process Governor**, follow these steps: 73 | 74 | 1. Download the latest ready-to-use build from the following 75 | link: [Latest Release](https://github.com/SystemXFiles/process-governor/releases/latest). 76 | 2. Run the `Process Governor.exe` executable with **administrative privileges**. 77 | 3. Configure the rules for processes and services. 78 | 4. **Optionally**, enable auto-start for the program to launch automatically with the system. 79 | 80 | You can close the program by accessing the tray icon. 81 | 82 |

(back to top)

83 | 84 | ## Documentation 85 | 86 | - [Process Governor UI](ui_process_governor.md) 87 | - [Rule Behavior and Tips](rule_behavior_and_tips.md) 88 | - [Configuration file](configuration_file.md) 89 | - [Running from source and creating a portable build](run_and_build.md) 90 | 91 |

(back to top)

92 | 93 | ## Star History 94 | 95 | [![Star History Chart](https://api.star-history.com/svg?repos=SystemXFiles/process-governor&type=Date)](https://star-history.com/#SystemXFiles/process-governor&Date) 96 | 97 |

(back to top)

98 | 99 | ## License 100 | 101 | This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](../LICENSE) file for details. 102 | 103 |

(back to top)

104 | 105 | 106 | 107 | [contributors-shield]: https://img.shields.io/github/contributors/SystemXFiles/process-governor.svg?style=for-the-badge 108 | 109 | [contributors-url]: https://github.com/SystemXFiles/process-governor/graphs/contributors 110 | 111 | [forks-shield]: https://img.shields.io/github/forks/SystemXFiles/process-governor.svg?style=for-the-badge 112 | 113 | [forks-url]: https://github.com/SystemXFiles/process-governor/network/members 114 | 115 | [stars-shield]: https://img.shields.io/github/stars/SystemXFiles/process-governor.svg?style=for-the-badge 116 | 117 | [stars-url]: https://github.com/SystemXFiles/process-governor/stargazers 118 | 119 | [issues-shield]: https://img.shields.io/github/issues/SystemXFiles/process-governor.svg?style=for-the-badge 120 | 121 | [issues-url]: https://github.com/SystemXFiles/process-governor/issues 122 | 123 | [license-shield]: https://img.shields.io/github/license/SystemXFiles/process-governor.svg?style=for-the-badge 124 | 125 | [license-url]: https://github.com/SystemXFiles/process-governor/blob/master/LICENSE -------------------------------------------------------------------------------- /docs/configuration_file.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Configuration File 4 | 5 | [![README](icons/readme.png) README](README.md#documentation) 6 | 7 | The `config.json` configuration file manages the behavior of the **Process Governor** application. This file allows 8 | users to define rules for regulating process priorities, I/O priorities, and CPU core affinity, as well as manage 9 | services with similar settings. 10 | 11 | The application regularly checks the configuration file for changes and applies the updates accordingly. 12 | 13 | ## Configuration File Example 14 | 15 | Below is an example of the configuration file with several rules defined for processes and services: 16 | 17 |
18 | See an example 19 | 20 | ```json 21 | { 22 | "ruleApplyIntervalSeconds": 1, 23 | "processRules": [ 24 | { 25 | "selectorBy": "Name", 26 | "selector": "aida_bench64.dll", 27 | "force": "N" 28 | }, 29 | { 30 | "selectorBy": "Name", 31 | "selector": "bg3*.exe", 32 | "affinity": "0-15", 33 | "force": "N", 34 | "delay": "30" 35 | }, 36 | { 37 | "selectorBy": "Name", 38 | "selector": "logioptionsplus_*.exe", 39 | "priority": "Idle", 40 | "ioPriority": "Low", 41 | "affinity": "0-15", 42 | "force": "N" 43 | }, 44 | { 45 | "selectorBy": "Name", 46 | "selector": "discord.exe", 47 | "priority": "Normal", 48 | "affinity": "0-15", 49 | "force": "Y" 50 | }, 51 | { 52 | "selectorBy": "Name", 53 | "selector": "audiodg.exe", 54 | "priority": "Realtime", 55 | "affinity": "16-23", 56 | "force": "N" 57 | }, 58 | { 59 | "selectorBy": "Name", 60 | "selector": "*", 61 | "affinity": "0-15", 62 | "force": "N" 63 | } 64 | ], 65 | "serviceRules": [ 66 | { 67 | "priority": "Realtime", 68 | "selector": "*audio*", 69 | "force": "N" 70 | } 71 | ], 72 | "version": 3 73 | } 74 | ``` 75 | 76 |
77 | 78 |

(back to top)

79 | 80 | ## Structure of the `config.json` 81 | 82 | The configuration file contains several sections, each serving a specific purpose. 83 | 84 | ### `ruleApplyIntervalSeconds` 85 | 86 | This parameter defines the interval, in seconds, at which the application applies the rules to processes and services. 87 | The default value is `1`, meaning rules are applied every second. 88 | 89 | ### `processRules` 90 | 91 | This section lists the rules applied to processes. Each rule object specifies how the application should manage a 92 | process based on several key parameters: 93 | 94 | #### Possible parameters: 95 | 96 | - **`selectorBy`** (string): Determines how the `selector` value is interpreted for process matching. 97 | **Valid values:** 98 | - `"Name"`: Match by process name (e.g., `"notepad.exe"`). 99 | - `"Path"`: Match by full executable path (e.g., `"C:/Windows/System32/notepad.exe"`). 100 | - `"Command line"`: Match by command line (e.g., `"App.exe Document.txt"`). 101 | 102 | 103 | - **`selector`** (string): Specifies the name, pattern, or path to the process. 104 | **Supported wildcards:** 105 | - `*`: Matches any number of characters. 106 | - `?`: Matches a single character. 107 | - `**`: Matches any sequence of directories. 108 | 109 | **Examples:** 110 | - `"selector": "name.exe"` 111 | - `"selector": "logioptionsplus_*.exe"` 112 | - `"selector": "C:/Program Files/**/app.exe --file Document.txt"` 113 | 114 | 115 | - **`priority`** (string, optional): Sets the priority level of the process. 116 | **Valid values:** 117 | - `"Idle"` 118 | - `"BelowNormal"` 119 | - `"Normal"` 120 | - `"AboveNormal"` 121 | - `"High"` 122 | - `"Realtime"` 123 | 124 | **Example:** `"priority": "High"` 125 | 126 | 127 | - **`ioPriority`** (string, optional): Sets the I/O priority of the process. 128 | **Valid values:** 129 | - `"VeryLow"` 130 | - `"Low"` 131 | - `"Normal"` 132 | 133 | **Example:** `"ioPriority": "Low"` 134 | 135 | 136 | - **`affinity`** (string, optional): Sets the CPU core affinity for the process. 137 | **Formats:** 138 | - Range (inclusive): `"affinity": "0-3"` 139 | - Specific cores: `"affinity": "0;2;4"` 140 | - Combination: `"affinity": "1;3-5"` 141 | 142 | 143 | - **`force`** (string, optional): Forces the application of the settings. 144 | **Valid values:** 145 | - `"Y"` for continuous enforcement. 146 | - `"N"` for one-time application. 147 | 148 | 149 | - **`delay`** (integer, optional): Delay in seconds before applying the settings. 150 | **Examples:** 151 | - If not specified, the settings are applied immediately. 152 | - Positive values set a delay in seconds before applying the settings. 153 | 154 |

(back to top)

155 | 156 | ### `serviceRules` 157 | 158 | This section contains a list of rules applied to services. Unlike `processRules`, the **Service Rule** does not include 159 | the `selectorBy` field because service rules only match by service name using the `selector` field. 160 | 161 | #### Possible parameters: 162 | 163 | - **`selector`** (string): Specifies the name or pattern of the service to match. 164 | **Supported wildcards:** 165 | - `*`: Matches any number of characters. 166 | - `?`: Matches a single character. 167 | 168 | **Examples:** 169 | - `"selector": "ServiceName"` 170 | - `"selector": "*audio*"` 171 | 172 | Other parameters such as `priority`, `ioPriority`, `affinity`, `force`, and `delay` are similar to those 173 | in `processRules`. 174 | 175 | ### `version` 176 | 177 | This field specifies the version of the configuration. It is required for ensuring proper migration and updates when the 178 | program configuration changes over time. 179 | 180 |

(back to top)

181 | 182 | ## Validation 183 | 184 | The configuration file undergoes validation to ensure consistency and correctness. If there are any issues, such as 185 | invalid parameter combinations or missing required fields, the application will notify the user and prevent the 186 | configuration from being applied until the errors are resolved. 187 | 188 |

(back to top)

-------------------------------------------------------------------------------- /docs/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/icons/logo.png -------------------------------------------------------------------------------- /docs/icons/readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/icons/readme.png -------------------------------------------------------------------------------- /docs/images/headers/github-banner-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/headers/github-banner-readme.png -------------------------------------------------------------------------------- /docs/images/screenshots/other_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/other_actions.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_list.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_list_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_list_actions.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_list_faded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_list_faded.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_list_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_list_menu.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_list_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_list_only.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules_actions.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules_add_rule.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules_add_rule.gif -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules_error.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules_menu.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules_only.png -------------------------------------------------------------------------------- /docs/images/screenshots/process_rules_unsaved.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/process_rules_unsaved.png -------------------------------------------------------------------------------- /docs/images/screenshots/save_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/save_action.png -------------------------------------------------------------------------------- /docs/images/screenshots/service_rules_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/service_rules_only.png -------------------------------------------------------------------------------- /docs/images/screenshots/tooltips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/tooltips.png -------------------------------------------------------------------------------- /docs/images/screenshots/tray_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/docs/images/screenshots/tray_menu.png -------------------------------------------------------------------------------- /docs/rule_behavior_and_tips.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Rule Behavior and Tips 4 | 5 | [![README](icons/readme.png) README](README.md#documentation) 6 | 7 | > [!TIP] 8 | > Before proceeding, it is recommended to familiarize yourself with [Process Governor UI](ui_process_governor.md). 9 | 10 | ## Table of Contents 11 | 12 | 1. [Rule Priority](#rule-priority) 13 | 2. [Core Affinity Inheritance in Windows](#core-affinity-inheritance-in-windows) 14 | 3. Common Rule Usage Tips 15 | - [Ignoring a Process](#ignoring-a-process) 16 | - [Rule for All Processes](#rule-for-all-processes) 17 | - [Disabling Hyperthreading](#disabling-hyperthreading) 18 | - [Using Delay to Avoid Side Effects](#using-delay-to-avoid-side-effects) 19 | - [Optimizing for Older or Single-Threaded Games](#optimizing-for-older-or-single-threaded-games) 20 | - [Fixing Audio Crackling Issues](#fixing-audio-crackling-issues) 21 | 22 | ## Rule Priority 23 | 24 | When applying rules, the program first checks **service rules** and then moves to **process rules**. This means that if 25 | a service matches a rule, it will take precedence. If no matching service rule is found, the program then applies the 26 | first matching process rule. 27 | 28 | > [!IMPORTANT] 29 | > Only the first matching rule is applied, so the order of the rules in the configuration is important. 30 | 31 |

(back to top)

32 | 33 | ## Core Affinity Inheritance in Windows 34 | 35 | In **Windows**, child processes inherit the core affinity settings from their parent processes. For example, if the 36 | parent process (`explorer.exe`) is restricted to cores 0 and 1, any process launched by it, **for example,** `app.exe`, 37 | will inherit this restriction and be limited to those same cores, unless the application itself or the user changes the 38 | core affinity (for example, via Task Manager or Process Governor). 39 | 40 | This behavior means you should carefully configure rules, especially when using wildcard rules like `*`, which apply to 41 | [all processes](#rule-for-all-processes). 42 | 43 | ### Sources: 44 | 45 | - [Windows Inheritance Documentation](https://learn.microsoft.com/en-us/windows/win32/procthread/inheritance) 46 | - [Why is processor affinity inherited by child processes?](https://devblogs.microsoft.com/oldnewthing/20050817-10/?p=34553) 47 | 48 |

(back to top)

49 | 50 | ## Ignoring a Process 51 | 52 | To ignore a process without applying any specific settings: 53 | 54 | 1. Go to the **Process Rules** tab. 55 | 2. Add a new rule. 56 | 3. Set **Process Selector** to the name of the process you want to ignore (e.g., `someprocess.exe`). 57 | 4. Leave all other fields unchanged. 58 | 59 | This will ensure that the process is excluded from any modifications by the governor. 60 | 61 |

(back to top)

62 | 63 | ## Rule for All Processes 64 | 65 | To apply a rule to all processes: 66 | 67 | 1. Go to the **Process Rules** tab. 68 | 2. Add a new rule. 69 | 3. Set **Process Selector** to `*` to match all processes (the `*` symbol acts as a wildcard, matching any sequence of 70 | characters in the process name). 71 | 4. Configure the desired settings (e.g., affinity, priority). 72 | 5. Place this rule at the bottom of the list to allow more specific rules to take precedence. 73 | 74 |

(back to top)

75 | 76 | ## Disabling Hyperthreading 77 | 78 | To limit a process to physical CPU cores and disable the use of hyperthreaded (logical) cores: 79 | 80 | 1. Go to the **Process Rules** tab. 81 | 2. Add a new rule. 82 | 3. Set **Process Selector** to the target process. 83 | 4. Set **Affinity** to even-numbered cores only (e.g., `0;2;4;6;8;10;12;14`). 84 | 85 | This will prevent the process from using hyperthreaded cores, which can be beneficial for certain workloads. 86 | 87 |

(back to top)

88 | 89 | ## Using Delay to Avoid Side Effects 90 | 91 | For some applications, especially games, applying settings like core affinity immediately upon startup can cause issues. 92 | Adding a delay ensures the process has time to initialize before adjustments are applied. 93 | 94 | 1. Go to the **Process Rules** tab. 95 | 2. Add a new rule. 96 | 3. Set **Process Selector** to the game executable (e.g., `bg3.exe`). 97 | 4. Set **Affinity** as needed (e.g., `0-15`). 98 | 5. Set a **Delay** of around 10 seconds to prevent early changes during startup. 99 | 100 | This helps avoid potential problems like sound not working. 101 | 102 |

(back to top)

103 | 104 | ## Optimizing for Older or Single-Threaded Games 105 | 106 | Older or poorly optimized games that don’t efficiently use multiple cores can stutter if run with the default core 107 | affinity settings. To improve performance: 108 | 109 | 1. Go to the **Process Rules** tab. 110 | 2. Add a new rule. 111 | 3. Set **Process Selector** to the game process. 112 | 4. Set **Priority** to a higher level (e.g., `AboveNormal` or `High`). 113 | 5. Adjust the **Affinity** to exclude CPU core 0 (e.g., `1-15`). 114 | 115 | This setup can help distribute the load more effectively and reduce stuttering. 116 | 117 |

(back to top)

118 | 119 | ## Fixing Audio Crackling Issues 120 | 121 | To address audio crackling or stuttering under high CPU load, it’s recommended to increase the priority of audio-related 122 | processes and services to ensure they have sufficient CPU resources. 123 | 124 | ### Steps for Optimizing Audio Processes: 125 | 126 | 1. Go to the **Process Rules** tab. 127 | 2. Add a new rule for each audio-related process. 128 | 3. Set **Process Selector** to the name of the audio process (e.g., `audiodg.exe`, `voicemeeter8x64.exe`). 129 | 4. Set **Priority** to `Realtime` or `High` depending on the process's importance. 130 | 131 | ### Steps for Optimizing Audio Services: 132 | 133 | 1. Go to the **Service Rules** tab. 134 | 2. Add a new rule for each audio-related service. 135 | 3. Set **Service Selector** to the service name (e.g., `AudioSrv`, `AudioEndpointBuilder`). 136 | 4. Set **Priority** to `Realtime` or `High`. 137 | 138 | This approach prioritizes audio processing over other tasks, preventing interruptions in sound quality during heavy CPU 139 | usage. 140 | 141 | ### Advanced Setup: Load Distribution Across CPU Cores 142 | 143 | For all previously added rules related to audio processes, it is recommended to configure **Affinity** to assign 144 | specific CPU cores dedicated to audio processing tasks. This helps ensure that audio processes have sufficient CPU 145 | resources, minimizing interference from other tasks. 146 | 147 | For example, if you have a **16-thread processor with 8 cores**, you can allocate the last 2 cores (threads 12-15) for 148 | audio tasks, while the first 6 cores (threads 0-11) can be reserved for other applications. 149 | 150 | #### Steps: 151 | 152 | 1. For each previously configured audio process rule: 153 | - Set **Affinity** to the last 2 cores (e.g., threads 12-15) for handling audio processing tasks. 154 | 155 | 2. After configuring the audio processes, add a new rule for all other processes: 156 | - Set **Process Selector** to `*`. 157 | - Set **Affinity** to allocate the remaining CPU cores (e.g., threads 0-11) for non-audio tasks. 158 | - **Important:** This rule must be placed **last** in the rule list, as it serves as a fallback for any processes 159 | that are not explicitly defined in previous rules. 160 | 161 | > [!WARNING] 162 | > Avoid modifying the **Affinity** for audio services like **AudioSrv** or **AudioEndpointBuilder**, as this 163 | > may worsen performance. Adjusting the priority for these services is usually sufficient to resolve audio issues such 164 | > as crackling and stuttering. 165 | 166 | This configuration helps distribute the CPU load, isolating audio processes to specific cores, ensuring smoother and 167 | more stable sound under high system load. 168 | 169 |

(back to top)

170 | -------------------------------------------------------------------------------- /docs/run_and_build.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Running from source and creating a portable build 4 | 5 | [![README](icons/readme.png) README](README.md#documentation) 6 | 7 | ## Running from source code 8 | 9 | To run **Process Governor** from source code, follow these steps: 10 | 11 | 1. Clone this repository. 12 | 2. Install the required dependencies using `pip`: `pip install -r requirements.txt` 13 | 3. Run the `process-governor.py` script with **administrative privileges**: `python process-governor.py` 14 | 4. [Configure the rules](docs/ui_rule_configurator.md) for processes and services. 15 | 16 |

(back to top)

17 | 18 | ## Creating a portable build 19 | 20 | You can create a portable version of the program using **PyInstaller**. Follow these steps to build the portable 21 | version: 22 | 23 | 1. Install PyInstaller using `pip install pyinstaller`. 24 | 2. Run the `python build_portable.py` script. 25 | 3. After the script completes, you will find the portable build in the `dist` folder. 26 | 27 | Now you have a portable version of the program that you can use without installation. 28 | 29 |

(back to top)

30 | -------------------------------------------------------------------------------- /docs/ui_process_governor.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Process Governor UI 4 | 5 | [![README](icons/readme.png) README](README.md#documentation) 6 | 7 | ## Table of Contents 8 | 9 | 1. [System Tray](#system-tray) 10 | 2. [Main Window](#main-window) 11 | 3. [Process List](#process-list) 12 | 4. [Rule Lists](#rule-lists) 13 | - [Process Rules](#process-rules) 14 | - [Service Rules](#service-rules) 15 | - [Managing Rules](#managing-rules) 16 | - [Rules List Context Menu](#rules-list-context-menu) 17 | - [Editing Rules](#editing-rules) 18 | - [Error Handling](#error-handling) 19 | 20 | ## System Tray 21 | 22 | ![](images/screenshots/tray_menu.png) 23 | 24 | The system tray menu provides quick access to the main program functions: 25 | 26 | - **Process Governor**: Open settings. 27 | - **Open config file**: Open the configuration file for manual settings editing. 28 | - **Open log file**: Open the log file with the program's operation records. 29 | - **Run on Startup**: Enable or disable program startup with the system. 30 | - **Check for Updates**: Check for program updates. 31 | - **Quit**: Exit the program. 32 | 33 |

(back to top)

34 | 35 | ## Main Window 36 | 37 | ![](images/screenshots/process_list.png) 38 | 39 | ### Tooltips 40 | 41 | ![](images/screenshots/tooltips.png) 42 | 43 | - In the settings interface, tooltips are available, describing the functions of buttons and fields. 44 | - Hover over an interface element to view its tooltip. 45 | 46 |

(back to top)

47 | 48 | ### Action Buttons 49 | 50 | ![](images/screenshots/other_actions.png) 51 | ![](images/screenshots/save_action.png) 52 | 53 | Main action buttons are available in the settings interface: 54 | 55 | - **Open config file**: Open the configuration file. 56 | - **Open log file**: Open the log file. 57 | - **Save**: Save changes made to the settings. 58 | Hotkey: **Ctrl+S**. 59 | **Details:** 60 | - The button is disabled if there are errors in the settings. 61 | - If no changes have been made, the save button is also disabled. 62 | 63 |

(back to top)

64 | 65 | ### Unsaved Changes Status 66 | 67 | ![](images/screenshots/process_rules_unsaved.png) 68 | 69 | Unsaved changes are marked with an asterisk (`*`) on the corresponding rule tab. 70 | 71 |

(back to top)

72 | 73 | ## Process List 74 | 75 | ![](images/screenshots/process_list_only.png) 76 | 77 | The process list provides important information about running processes: 78 | 79 | - **PID**: Unique process identifier. 80 | - **Process Name**: Name of the process. 81 | - **Service Name**: Name of the service, if applicable. 82 | - **Executable Path**: Full path to the executable file. 83 | - **Command Line**: Command with which the process was started. 84 | 85 |

(back to top)

86 | 87 | ### Filtering and Search 88 | 89 | ![](images/screenshots/process_list_actions.png) 90 | 91 | - **Search**: Enter text into the search bar to find processes by name or other attributes (e.g., command line). 92 | Hotkey: **Ctrl+F**. 93 | 94 | - **Filter by type**: Use the filter to display processes by type: 95 | - **Show All**: Display all processes. 96 | - **Show Processes**: Display only processes. 97 | - **Show Services**: Display only services. 98 | 99 | - **Refresh**: Refresh the process list. 100 | Hotkey: **F5**. 101 | 102 |

(back to top)

103 | 104 | ### Process Context Menu 105 | 106 | ![](images/screenshots/process_list_menu.png) 107 | 108 | - **Add Process Rule**: Add a rule for a process by name, path, or command line. 109 | - **Add Service Rule**: Add a rule for a service by name. 110 | - **Go to Rule**: Go to the existing rule for a process/service. 111 | - **Copy Special**: Copy process attributes (PID, process name, etc.). 112 | - **Open file location**: Open the folder containing the process’s executable file. 113 | - **File Properties**: Open file properties. 114 | - **Service Properties**: Open service properties. 115 | 116 |

(back to top)

117 | 118 | ## Rule Lists 119 | 120 | ![](images/screenshots/process_rules.png) 121 | 122 | The rule lists are divided into two categories: **Process Rules** and **Service Rules**. 123 | 124 | ### Process Rules 125 | 126 | ![](images/screenshots/process_rules_only.png) 127 | 128 | - **Selector By**: Determines how the **Process Selector** value is interpreted for process matching: 129 | - `Name` — by process name (e.g., `notepad.exe`). 130 | - `Path` — by the full executable path (e.g., `C:/Windows/System32/notepad.exe`). 131 | - `Command line` — by command line arguments (e.g., `App.exe Document.txt` or `D:/Folder/App.exe Document.txt`). 132 | 133 | 134 | - **Process Selector**: Specifies the name, pattern, or path to the process. 135 | **Supported wildcards:** 136 | - `*` — matches any number of characters. 137 | - `?` — matches a single character. 138 | - `**` — matches any sequence of directories. 139 | **Examples:** `name.exe`, `logioptionsplus_*.exe`, `D:/FolderName/App.exe`, 140 | or `C:/Program Files/**/app.exe --file Document.txt`. 141 | 142 | 143 | - **Priority**: Sets the priority level of the process. 144 | 145 | - **I/O Priority**: Sets the I/O priority of the process. 146 | 147 | 148 | - **Affinity**: Sets the CPU core affinity for the process. 149 | **Formats:** 150 | - Range: `0-3`, 151 | - Specific cores: `0;2;4`, 152 | - Combination: `1;3-5`. 153 | 154 | 155 | - **Force**: Forces the application of the settings. 156 | **Possible values:** 157 | - `Y` — for continuous application, 158 | - `N` — for one-time application. 159 | 160 | 161 | - **Delay**: Delay in seconds before applying the settings. 162 | **Possible values:** 163 | - If not specified, the settings are applied immediately. 164 | - Positive values set a delay in seconds before applying the settings. 165 | 166 |

(back to top)

167 | 168 | ### Service Rules 169 | 170 | ![](images/screenshots/service_rules_only.png) 171 | 172 | - **Service Selector**: Specifies the name or pattern of the service to match. 173 | **Supported wildcards:** 174 | - `*`: Matches any number of characters. 175 | - `?`: Matches a single character. 176 | 177 | **Examples:** 178 | - `"selector": "ServiceName"` 179 | - `"selector": "*audio*"` 180 | 181 | Other parameters such as **Priority**, **I/O Priority**, **Affinity**, **Force**, and **Delay** are similar to those in 182 | **Process Rules**. 183 | > [!TIP] 184 | > The **Selector By** field is not used in **Service Rules** since services are matched only by name. 185 | 186 |

(back to top)

187 | 188 | ### Managing Rules 189 | 190 | ![](images/screenshots/process_rules_actions.png) 191 | 192 | - **Add**: Add a new rule. 193 | - **Del**: Delete the selected rules. 194 | - **Up/Down**: Move the selected rule up or down in the rule list. 195 | 196 |

(back to top)

197 | 198 | ### Rules List Context Menu 199 | 200 | ![](images/screenshots/process_rules_menu.png) 201 | 202 | - **Undo**: Undo the last action. 203 | Hotkey: **Ctrl+Z**. 204 | 205 | 206 | - **Redo**: Redo the undone action. 207 | Hotkeys: **Ctrl+Shift+Z** or **Ctrl+Y**. 208 | 209 | 210 | - **Add**: Add a new rule. 211 | Hotkey: **Ctrl+D**. 212 | 213 | 214 | - **Select all**: Select all rules in the list. 215 | Hotkey: **Ctrl+A**. 216 | 217 | 218 | - **Delete**: Delete the selected rules. 219 | Hotkey: **Del**. 220 | 221 |

(back to top)

222 | 223 | ### Editing Rules 224 | 225 | ![](images/screenshots/process_rules_add_rule.gif) 226 | 227 | 1. Double-click on the rule cell you want to edit. 228 | 2. Enter new data into the selected cell. 229 | 3. After editing, the changes are automatically saved in the table. 230 | 231 |

(back to top)

232 | 233 | ### Error Handling 234 | 235 | ![](images/screenshots/process_rules_error.png) 236 | 237 | - If incorrect data is entered, the corresponding cell is highlighted, and an error icon appears next to it. 238 | - Hover over the error icon to view the error description. 239 | 240 |

(back to top)

-------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | 4 | [mypy-pystray.*] 5 | ignore_missing_imports = True 6 | 7 | [mypy-pyuac.*] 8 | ignore_missing_imports = True 9 | -------------------------------------------------------------------------------- /process-governor.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | import pyuac 5 | 6 | from constants.app_info import APP_NAME 7 | from constants.log import LOG 8 | from main_loop import start_app 9 | from util.lock_instance import create_lock_file, remove_lock_file 10 | from util.messages import show_error 11 | 12 | if __name__ == "__main__": 13 | if not platform.system() == "Windows": 14 | LOG.error(f"{APP_NAME} is intended to run on Windows only.") 15 | sys.exit(1) 16 | 17 | if not pyuac.isUserAdmin(): 18 | message = ("This program requires administrator privileges to run.\n" 19 | "Please run the program as an administrator to ensure proper functionality.") 20 | 21 | LOG.error(message) 22 | show_error(message) 23 | sys.exit(1) 24 | 25 | create_lock_file() 26 | try: 27 | start_app() 28 | finally: 29 | remove_lock_file() 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/requirements.txt -------------------------------------------------------------------------------- /resources/UI/add-process-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/add-process-rule.png -------------------------------------------------------------------------------- /resources/UI/add-service-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/add-service-rule.png -------------------------------------------------------------------------------- /resources/UI/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/add.png -------------------------------------------------------------------------------- /resources/UI/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/config.png -------------------------------------------------------------------------------- /resources/UI/copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/copy.png -------------------------------------------------------------------------------- /resources/UI/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/delete.png -------------------------------------------------------------------------------- /resources/UI/down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/down.png -------------------------------------------------------------------------------- /resources/UI/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/error.png -------------------------------------------------------------------------------- /resources/UI/file-properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/file-properties.png -------------------------------------------------------------------------------- /resources/UI/go-to-rule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/go-to-rule.png -------------------------------------------------------------------------------- /resources/UI/info-tooltip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/info-tooltip.png -------------------------------------------------------------------------------- /resources/UI/log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/log.png -------------------------------------------------------------------------------- /resources/UI/open-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/open-folder.png -------------------------------------------------------------------------------- /resources/UI/process-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/process-list.png -------------------------------------------------------------------------------- /resources/UI/process-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/process-rules.png -------------------------------------------------------------------------------- /resources/UI/process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/process.png -------------------------------------------------------------------------------- /resources/UI/redo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/redo.png -------------------------------------------------------------------------------- /resources/UI/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/refresh.png -------------------------------------------------------------------------------- /resources/UI/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/save.png -------------------------------------------------------------------------------- /resources/UI/select-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/select-all.png -------------------------------------------------------------------------------- /resources/UI/service-properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/service-properties.png -------------------------------------------------------------------------------- /resources/UI/service-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/service-rules.png -------------------------------------------------------------------------------- /resources/UI/service.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/service.png -------------------------------------------------------------------------------- /resources/UI/sort-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/sort-down.png -------------------------------------------------------------------------------- /resources/UI/sort-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/sort-empty.png -------------------------------------------------------------------------------- /resources/UI/sort-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/sort-up.png -------------------------------------------------------------------------------- /resources/UI/undo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/undo.png -------------------------------------------------------------------------------- /resources/UI/up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/up.png -------------------------------------------------------------------------------- /resources/UI/warn-tooltip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/UI/warn-tooltip.png -------------------------------------------------------------------------------- /resources/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemXFiles/process-governor/75457621feceecef28255527c60463f7563539e5/resources/app.ico -------------------------------------------------------------------------------- /resources/startup.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set "exe_path=%~1" 3 | set "working_directory=%~dp1" 4 | cd /d "%working_directory%" 5 | start "" "%exe_path%" 6 | exit -------------------------------------------------------------------------------- /src/configuration/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from configuration.rule import ProcessRule, ServiceRule 6 | 7 | 8 | class Config(BaseModel): 9 | """ 10 | The Config class represents a configuration object for application. 11 | 12 | It defines the structure of the configuration, including rule application interval, and rules. 13 | """ 14 | 15 | version: Optional[int] = Field(default=None) 16 | """ 17 | The version of the configuration. 18 | This field can be None if the version is not set. 19 | """ 20 | 21 | ruleApplyIntervalSeconds: int = Field(default=1) 22 | """ 23 | The time interval (in seconds) at which rules are applied to processes and services. 24 | Default is 1 second. 25 | """ 26 | 27 | processRules: list[ProcessRule] = Field(default_factory=list) 28 | """ 29 | A list of Rule objects that specify how application manages processes based on user-defined rules. 30 | """ 31 | 32 | serviceRules: list[ServiceRule] = Field(default_factory=list) 33 | """ 34 | A list of Rule objects that specify how application manages services based on user-defined rules. 35 | """ 36 | -------------------------------------------------------------------------------- /src/configuration/handler/affinity.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import PlainSerializer, WithJsonSchema, BeforeValidator 4 | from typing_extensions import Annotated 5 | 6 | from util.cpu import parse_affinity, format_affinity 7 | 8 | 9 | def __to_list(value) -> Optional[list[int]]: 10 | if isinstance(value, list): 11 | return value 12 | 13 | if not value.strip(): 14 | return None 15 | 16 | return parse_affinity(value) 17 | 18 | 19 | def __to_str(value) -> Optional[str]: 20 | if not value: 21 | return None 22 | 23 | if isinstance(value, list): 24 | return format_affinity(value) 25 | 26 | return value 27 | 28 | 29 | Affinity = Annotated[ 30 | Optional[list[int]], 31 | BeforeValidator(__to_list), 32 | PlainSerializer(__to_str, return_type=str), 33 | WithJsonSchema({'type': 'string'}, mode='serialization'), 34 | ] 35 | -------------------------------------------------------------------------------- /src/configuration/logs.py: -------------------------------------------------------------------------------- 1 | from logging import getLevelName 2 | from typing import Literal 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class Logs(BaseModel): 8 | """ 9 | The Logs class represents the logging configuration for application. 10 | 11 | It defines the settings for enabling/disabling logging, specifying the log file name, log level, maximum log file size, 12 | and the number of backup log files to keep. 13 | """ 14 | 15 | enable: bool = Field(default=True) 16 | """ 17 | A boolean flag to enable or disable logging. Default is True (logging is enabled). 18 | """ 19 | 20 | level: Literal['CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'WARN', 'INFO', 'DEBUG', 'NOTSET'] = Field(default='DEBUG') 21 | """ 22 | The log level for filtering log messages. Default is 'INFO'. 23 | Valid log levels include: 'CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'WARN', 'INFO', 'DEBUG', 'NOTSET'. 24 | """ 25 | 26 | maxBytes: int = Field(default=1024 * 1024) 27 | """ 28 | The maximum size (in bytes) of the log file. When the log file exceeds this size, it will be rotated. 29 | Default is 1 megabyte (1024 * 1024 bytes). 30 | """ 31 | 32 | backupCount: int = Field(default=1) 33 | """ 34 | The number of backup log files to keep. When log rotation occurs, old log files beyond this count will be deleted. 35 | Default is 1 backup log files. 36 | """ 37 | 38 | def level_as_int(self): 39 | """ 40 | Get the log level as an integer value. 41 | 42 | This method converts the log level string into its corresponding integer representation. 43 | """ 44 | return getLevelName(self.level) 45 | -------------------------------------------------------------------------------- /src/configuration/migration/all_migration.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from configuration.migration.base import BaseMigration 4 | from configuration.migration.m0_rules_to_split_rules_config import MigrationRules2SplitRulesConfig 5 | from configuration.migration.m1_new_fields_in_rule import NewFieldsInRule 6 | from configuration.migration.m2_remove_high_io_priority_and_logging import RemoveHighIoPriorityAndLogging 7 | from constants.log import LOG 8 | from service.config_service import ConfigService 9 | from util.messages import show_error 10 | 11 | MIGRATIONS: list[type[BaseMigration]] = [ 12 | MigrationRules2SplitRulesConfig, 13 | NewFieldsInRule, 14 | RemoveHighIoPriorityAndLogging 15 | ] 16 | """ 17 | List of migration classes to be executed in order. 18 | """ 19 | 20 | 21 | def run_all_migration(): 22 | """ 23 | Runs all necessary migrations on the configuration. 24 | Creates a backup before migration, applies each migration in order, 25 | logs progress, and saves the updated configuration if successful. 26 | Shows an error and stops if any migration fails. 27 | """ 28 | 29 | config: dict = ConfigService.load_config_raw() 30 | need_migrate = any(migration.should_migrate(config) for migration in MIGRATIONS) 31 | 32 | if not need_migrate: 33 | return 34 | 35 | LOG.info(f"Creating backup of the current configuration before migration...") 36 | ConfigService.backup_config() 37 | 38 | has_error = False 39 | 40 | for migration in MIGRATIONS: 41 | migration_name = migration.__name__ 42 | 43 | try: 44 | if not migration.should_migrate(copy.deepcopy(config)): 45 | continue 46 | 47 | LOG.info(f"[{migration_name}] Starting migration...") 48 | 49 | migrated_config = migration.migrate(copy.deepcopy(config)) 50 | migrated_config['version'] = migration.get_target_version() 51 | 52 | LOG.info(f"[{migration_name}] Migration completed to version {migrated_config['version']}.") 53 | config = migrated_config 54 | except Exception as e: 55 | has_error = True 56 | LOG.exception(f"[{migration_name}] Migration failed.") 57 | show_error(f"Migration `{migration_name}` failed: \n{str(e)}") 58 | break 59 | 60 | if not has_error: 61 | ConfigService.save_config_raw(config) 62 | 63 | 64 | if __name__ == '__main__': 65 | run_all_migration() 66 | -------------------------------------------------------------------------------- /src/configuration/migration/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from pydantic.config import JsonDict 4 | 5 | 6 | class BaseMigration(ABC): 7 | @staticmethod 8 | @abstractmethod 9 | def should_migrate(config: JsonDict) -> bool: 10 | """ 11 | Checks if migration is necessary. 12 | 13 | :param config: The current configuration dictionary. 14 | :return: True if migration is required, otherwise False. 15 | """ 16 | pass 17 | 18 | @staticmethod 19 | @abstractmethod 20 | def migrate(config: JsonDict) -> JsonDict: 21 | """ 22 | Performs the migration and returns the updated configuration. 23 | 24 | :param config: The current configuration dictionary. 25 | :return: Updated configuration after migration. 26 | """ 27 | pass 28 | 29 | @staticmethod 30 | @abstractmethod 31 | def get_target_version() -> int: 32 | """ 33 | Returns the target version for the configuration after migration. 34 | 35 | :return: The target version number. 36 | """ 37 | pass 38 | -------------------------------------------------------------------------------- /src/configuration/migration/m0_rules_to_split_rules_config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic.config import JsonDict 4 | 5 | from configuration.migration.base import BaseMigration 6 | 7 | 8 | class MigrationRules2SplitRulesConfig(BaseMigration): 9 | @staticmethod 10 | def get_target_version() -> int: 11 | return 1 12 | 13 | @staticmethod 14 | def should_migrate(config: JsonDict) -> bool: 15 | return 'version' not in config or config['version'] is None 16 | 17 | @staticmethod 18 | def migrate(config: JsonDict) -> Optional[JsonDict]: 19 | if 'rules' not in config: 20 | return config 21 | 22 | process_rules = list(filter(lambda o: 'processSelector' in o, config['rules'])) 23 | service_rules = list(filter(lambda o: 'serviceSelector' in o, config['rules'])) 24 | 25 | for processRule in process_rules: 26 | processRule['selector'] = processRule['processSelector'] 27 | del processRule['processSelector'] 28 | 29 | for serviceRule in service_rules: 30 | serviceRule['selector'] = serviceRule['serviceSelector'] 31 | del serviceRule['serviceSelector'] 32 | 33 | del config['rules'] 34 | config['processRules'] = process_rules 35 | config['serviceRules'] = service_rules 36 | 37 | return config 38 | -------------------------------------------------------------------------------- /src/configuration/migration/m1_new_fields_in_rule.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic.config import JsonDict 4 | 5 | from configuration.migration.base import BaseMigration 6 | from enums.bool import BoolStr 7 | from enums.selector import SelectorType 8 | 9 | 10 | class NewFieldsInRule(BaseMigration): 11 | @staticmethod 12 | def get_target_version() -> int: 13 | return 2 14 | 15 | @staticmethod 16 | def should_migrate(config: JsonDict) -> bool: 17 | return config['version'] == 1 18 | 19 | @staticmethod 20 | def migrate(config: JsonDict) -> Optional[JsonDict]: 21 | for rule in config.get('processRules', []): 22 | rule['selectorBy'] = SelectorType.NAME.value 23 | rule['force'] = BoolStr.NO.value 24 | 25 | for rule in config.get('serviceRules', []): 26 | rule['force'] = BoolStr.NO.value 27 | 28 | return config 29 | -------------------------------------------------------------------------------- /src/configuration/migration/m2_remove_high_io_priority_and_logging.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import Optional 3 | 4 | from pydantic.config import JsonDict 5 | 6 | from configuration.migration.base import BaseMigration 7 | 8 | 9 | class RemoveHighIoPriorityAndLogging(BaseMigration): 10 | @staticmethod 11 | def get_target_version() -> int: 12 | return 3 13 | 14 | @staticmethod 15 | def should_migrate(config: JsonDict) -> bool: 16 | return config['version'] == 2 17 | 18 | @staticmethod 19 | def migrate(config: JsonDict) -> Optional[JsonDict]: 20 | for rule in itertools.chain(config.get('processRules', []), config.get('serviceRules', [])): 21 | if rule.get('ioPriority') == 'High': 22 | del rule['ioPriority'] 23 | 24 | if 'logging' in config: 25 | del config['logging'] 26 | 27 | return config 28 | -------------------------------------------------------------------------------- /src/configuration/rule.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from configuration.handler.affinity import Affinity 6 | from enums.bool import BoolStr 7 | from enums.io_priority import IOPriorityStr 8 | from enums.priority import PriorityStr 9 | from enums.selector import SelectorType 10 | 11 | 12 | class ProcessRule(BaseModel): 13 | selectorBy: SelectorType = Field( 14 | default=SelectorType.NAME, 15 | title="Selector By", 16 | description="Determines how the __Process Selector__ value is interpreted, influencing how the __process__ is matched.\n" 17 | "**Options:**\n" 18 | "- `Name` - matches by process name (e.g., `notepad.exe`);\n" 19 | "- `Path` - matches by the full executable path (e.g., `C:/Windows/System32/notepad.exe`);\n" 20 | "- `Command line` - matches by command line arguments (e.g., `App.exe Document.txt` or `D:/Folder/App.exe Document.txt`)." 21 | ) 22 | 23 | selector: str = Field( 24 | title="Process Selector", 25 | description="Specifies the **name**, **pattern** or **path** of the __process__ to which this rule applies.\n\n" 26 | "**Supports wildcard:** `*` (matches any characters), `?` (matches any single character) and `**` (matches any sequence of directories).\n" 27 | "**Examples:** `name.exe`, `logioptionsplus_*.exe`, `D:/FolderName/App.exe` or `C:/Program Files/**/app.exe --file Document.txt`.", 28 | stretchable_column_ui=True, 29 | justify_ui="left" 30 | ) 31 | 32 | priority: Optional[PriorityStr] = Field( 33 | default=None, 34 | title="Priority", 35 | description="Sets the **priority level** for the __process__.\n" 36 | "Higher priority tasks are allocated more CPU time compared to lower priority tasks." 37 | ) 38 | 39 | ioPriority: Optional[IOPriorityStr] = Field( 40 | default=None, 41 | title="I/O Priority", 42 | description="Sets the **I/O priority** for the __process__.\n" 43 | "Higher I/O priority means more disk resources and better I/O performance." 44 | ) 45 | 46 | affinity: Optional[Affinity] = Field( 47 | default=None, 48 | title="Affinity", 49 | description="Sets the **CPU core affinity** for the __process__, defining which CPU cores are allowed for execution.\n\n" 50 | "**Format:** range `0-3`, specific cores `0;2;4`, combination `1;3-5`.", 51 | justify_ui="left", 52 | width_ui=200 53 | ) 54 | 55 | force: BoolStr = Field( 56 | default=BoolStr.NO, 57 | title="Force", 58 | description="**Forces** the settings to be continuously applied to the __process__, even if the application tries to override them.\n\n" 59 | "**Possible values:**\n" 60 | "- `Y` for continuous application;\n" 61 | "- `N` for one-time application.", 62 | ) 63 | 64 | delay: Optional[int] = Field( 65 | gt=0, 66 | default=0, 67 | title="Delay", 68 | description="Specifies the **delay** in __seconds__ before the settings are applied to the __process__.\n\n" 69 | "**Possible values:**\n" 70 | "- If not specified, the settings are applied immediately;\n" 71 | "- Positive values set a delay in seconds before applying the settings." 72 | ) 73 | 74 | 75 | class ServiceRule(BaseModel): 76 | selector: str = Field( 77 | title="Service Selector", 78 | description="Specifies the **name** of the __service__ to which this rule applies.\n\n" 79 | "**Supports wildcard:** `*` (matches any characters) and `?` (matches any single character)\n" 80 | "**Examples:** `ServiceName` or `Audio*`.", 81 | stretchable_column_ui=True, 82 | justify_ui="left" 83 | ) 84 | 85 | priority: Optional[PriorityStr] = Field( 86 | default=None, 87 | title="Priority", 88 | description="Sets the **priority level** for the __service__.\n" 89 | "Higher priority tasks are allocated more CPU time compared to lower priority tasks." 90 | ) 91 | 92 | ioPriority: Optional[IOPriorityStr] = Field( 93 | default=None, 94 | title="I/O Priority", 95 | description="Sets the **I/O priority** for the __service__.\n" 96 | "Higher I/O priority means more disk resources and better I/O performance." 97 | ) 98 | 99 | affinity: Optional[Affinity] = Field( 100 | default=None, 101 | title="Affinity", 102 | description="Sets the **CPU core affinity** for the __service__, defining which CPU cores are allowed for execution.\n\n" 103 | "**Format:** range `0-3`, specific cores `0;2;4`, combination `1;3-5`.", 104 | justify_ui="left", 105 | width_ui=200 106 | ) 107 | 108 | force: BoolStr = Field( 109 | default=BoolStr.NO, 110 | title="Force", 111 | description="**Forces** the settings to be continuously applied to the __service__, even if the application tries to override them.\n\n" 112 | "**Possible values:**\n" 113 | "- `Y` for continuous application;\n" 114 | "- `N` for one-time application.", 115 | ) 116 | 117 | delay: Optional[int] = Field( 118 | gt=0, 119 | default=0, 120 | title="Delay", 121 | description="Specifies the **delay** in __seconds__ before the settings are applied to the __service__.\n\n" 122 | "**Possible values:**\n" 123 | "- If not specified, the settings are applied immediately;\n" 124 | "- Positive values set a delay in seconds before applying the settings." 125 | ) 126 | -------------------------------------------------------------------------------- /src/constants/app_info.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Final 4 | 5 | from util.utils import is_portable 6 | 7 | APP_AUTHOR: Final[str] = "System X - Files" 8 | APP_NAME: Final[str] = "Process Governor" 9 | APP_VERSION: Final[str] = "1.3.1" 10 | APP_PROCESSES = {f"{APP_NAME}.exe", "python.exe"} 11 | 12 | CURRENT_TAG: Final[str] = f"v{APP_VERSION}" 13 | APP_NAME_WITH_VERSION: Final[str] = f"{APP_NAME} {CURRENT_TAG}" 14 | APP_TITLE = f"{APP_NAME_WITH_VERSION} by {APP_AUTHOR}" 15 | TITLE_ERROR: Final[str] = f"Error Detected - {APP_NAME_WITH_VERSION}" 16 | 17 | if is_portable(): 18 | APP_PATH: Final[str] = sys._MEIPASS 19 | else: 20 | APP_PATH: Final[str] = os.getcwd() 21 | 22 | STARTUP_TASK_NAME: Final[str] = f"{APP_NAME} Autostart" 23 | -------------------------------------------------------------------------------- /src/constants/files.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | LOCK_FILE_NAME: Final[str] = "pg.lock" 4 | 5 | CONFIG_FILE_NAME: Final[str] = "config.json" 6 | CONFIG_FILE_ENCODING: Final[str] = "utf-8" 7 | LOG_FILE_NAME: Final[str] = "logging.txt" 8 | -------------------------------------------------------------------------------- /src/constants/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from logging import StreamHandler, Logger, WARNING 4 | from logging.handlers import RotatingFileHandler 5 | from typing import Final 6 | 7 | from configuration.logs import Logs 8 | from constants.files import LOG_FILE_NAME 9 | 10 | logging.addLevelName(WARNING, 'WARN') 11 | 12 | 13 | def __log_setup() -> Logger: 14 | """ 15 | Sets up the logging configuration. 16 | 17 | Retrieves the logging configuration from the `ConfigService` and sets up the logging handlers and formatters 18 | accordingly. If the logging configuration is disabled, the function does nothing. 19 | """ 20 | 21 | log: Logger = logging.getLogger("proc-gov") 22 | log_cfg: Logs = Logs() 23 | 24 | log.setLevel(log_cfg.level_as_int()) 25 | 26 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 27 | 28 | console_handler = StreamHandler(sys.stdout) 29 | console_handler.setFormatter(formatter) 30 | log.addHandler(console_handler) 31 | 32 | if not log_cfg.enable: 33 | return log 34 | 35 | file_handler = RotatingFileHandler( 36 | LOG_FILE_NAME, 37 | maxBytes=log_cfg.maxBytes, 38 | backupCount=log_cfg.backupCount, 39 | encoding='utf-8', 40 | ) 41 | file_handler.setFormatter(formatter) 42 | log.addHandler(file_handler) 43 | 44 | return log 45 | 46 | 47 | LOG: Final[logging.Logger] = __log_setup() 48 | -------------------------------------------------------------------------------- /src/constants/resources.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from constants.app_info import APP_PATH 4 | 5 | STARTUP_SCRIPT: Final[str] = f"{APP_PATH}/resources/startup.bat" 6 | APP_ICON: Final[str] = f"{APP_PATH}/resources/app.ico" 7 | 8 | UI_PROCESS_RULES: Final[str] = f"{APP_PATH}/resources/UI/process-rules.png" 9 | UI_SERVICE_RULES: Final[str] = f"{APP_PATH}/resources/UI/service-rules.png" 10 | UI_PROCESS_LIST: Final[str] = f"{APP_PATH}/resources/UI/process-list.png" 11 | 12 | UI_ADD: Final[str] = f"{APP_PATH}/resources/UI/add.png" 13 | UI_ADD_PROCESS_RULE: Final[str] = f"{APP_PATH}/resources/UI/add-process-rule.png" 14 | UI_ADD_SERVICE_RULE: Final[str] = f"{APP_PATH}/resources/UI/add-service-rule.png" 15 | UI_DELETE: Final[str] = f"{APP_PATH}/resources/UI/delete.png" 16 | UI_UP: Final[str] = f"{APP_PATH}/resources/UI/up.png" 17 | UI_DOWN: Final[str] = f"{APP_PATH}/resources/UI/down.png" 18 | UI_SAVE: Final[str] = f"{APP_PATH}/resources/UI/save.png" 19 | UI_REFRESH: Final[str] = f"{APP_PATH}/resources/UI/refresh.png" 20 | UI_CONFIG: Final[str] = f"{APP_PATH}/resources/UI/config.png" 21 | UI_LOG: Final[str] = f"{APP_PATH}/resources/UI/log.png" 22 | UI_REDO: Final[str] = f"{APP_PATH}/resources/UI/redo.png" 23 | UI_UNDO: Final[str] = f"{APP_PATH}/resources/UI/undo.png" 24 | UI_SELECT_ALL: Final[str] = f"{APP_PATH}/resources/UI/select-all.png" 25 | UI_COPY: Final[str] = f"{APP_PATH}/resources/UI/copy.png" 26 | UI_SERVICE: Final[str] = f"{APP_PATH}/resources/UI/service.png" 27 | UI_PROCESS: Final[str] = f"{APP_PATH}/resources/UI/process.png" 28 | UI_OPEN_FOLDER: Final[str] = f"{APP_PATH}/resources/UI/open-folder.png" 29 | UI_OPEN_FILE_PROPERTIES: Final[str] = f"{APP_PATH}/resources/UI/file-properties.png" 30 | UI_OPEN_SERVICE_PROPERTIES: Final[str] = f"{APP_PATH}/resources/UI/service-properties.png" 31 | UI_GO_TO_RULE: Final[str] = f"{APP_PATH}/resources/UI/go-to-rule.png" 32 | 33 | UI_INFO_TOOLTIP: Final[str] = f"{APP_PATH}/resources/UI/info-tooltip.png" 34 | UI_WARN_TOOLTIP: Final[str] = f"{APP_PATH}/resources/UI/warn-tooltip.png" 35 | 36 | UI_ERROR: Final[str] = f"{APP_PATH}/resources/UI/error.png" 37 | 38 | UI_SORT_UP: Final[str] = f"{APP_PATH}/resources/UI/sort-up.png" 39 | UI_SORT_DOWN: Final[str] = f"{APP_PATH}/resources/UI/sort-down.png" 40 | UI_SORT_EMPTY: Final[str] = f"{APP_PATH}/resources/UI/sort-empty.png" 41 | -------------------------------------------------------------------------------- /src/constants/threads.py: -------------------------------------------------------------------------------- 1 | THREAD_SETTINGS = "settings" 2 | THREAD_TRAY = "tray" 3 | THREAD_PROCESS_LIST_DATA = "process_list_data" 4 | THREAD_PROCESS_LIST_ICONS = "process_list_icons" 5 | THREAD_PROCESS_LIST_OPEN_SERVICE_PROPERTIES = "process_list_open_service_properties" 6 | -------------------------------------------------------------------------------- /src/constants/ui.py: -------------------------------------------------------------------------------- 1 | from tkinter import LEFT, RIGHT 2 | from typing import Final 3 | 4 | UI_PADDING = 10 5 | RC_WIN_SIZE = (1280, 768) 6 | COLUMN_WIDTH_WITH_ICON = 45 7 | TRIM_LENGTH_OF_ITEM_IN_CONTEXT_MENU = 128 8 | 9 | LEFT_PACK = dict(side=LEFT, padx=(0, UI_PADDING)) 10 | RIGHT_PACK = dict(side=RIGHT, padx=(UI_PADDING, 0)) 11 | 12 | COLUMN_TITLE_PADDING = 30 13 | ERROR_ROW_COLOR = "#ffcdd2" 14 | 15 | SETTINGS_TITLE = "Settings" 16 | OPEN_CONFIG_LABEL = "Open config file" 17 | OPEN_LOG_LABEL = "Open log file" 18 | 19 | 20 | class ActionEvents: 21 | ADD: Final[str] = "<>" 22 | DELETE: Final[str] = "<>" 23 | UP: Final[str] = "<>" 24 | DOWN: Final[str] = "<>" 25 | CANCEL: Final[str] = "<>" 26 | SAVE: Final[str] = "<>" 27 | APPLY_N_CLOSE: Final[str] = "<>" 28 | REFRESH: Final[str] = "<>" 29 | CONFIG: Final[str] = "<>" 30 | LOG: Final[str] = "<>" 31 | FILTER_BY_TYPE: Final[str] = "<>" 32 | SEARCH_CHANGE: Final[str] = "<>" 33 | 34 | 35 | class ExtendedTreeviewEvents: 36 | CHANGE: Final[str] = "<>" 37 | BEFORE_CHANGE: Final[str] = "<>" 38 | 39 | 40 | class EditableTreeviewEvents: 41 | ESCAPE: Final[str] = "<>" 42 | START_EDIT_CELL: Final[str] = "<>" 43 | SAVE_CELL: Final[str] = "<>" 44 | 45 | 46 | class ScrollableTreeviewEvents: 47 | SCROLL: Final[str] = "<>" 48 | 49 | 50 | ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE = 'main thread is not in main loop' 51 | -------------------------------------------------------------------------------- /src/constants/updates.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | API_UPDATE_URL: Final[str] = "https://api.github.com/repos/SystemXFiles/process-governor/releases/latest" 4 | UPDATE_URL: Final[str] = "https://github.com/SystemXFiles/process-governor/releases/latest" 5 | -------------------------------------------------------------------------------- /src/enums/bool.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class BoolStr(StrEnum): 5 | NO = "N" 6 | YES = "Y" 7 | -------------------------------------------------------------------------------- /src/enums/filters.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class FilterByProcessType(StrEnum): 5 | ALL = 'Show All' 6 | PROCESSES = 'Show Processes' 7 | SERVICES = 'Show Services' 8 | -------------------------------------------------------------------------------- /src/enums/io_priority.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from typing import Final 3 | 4 | from psutil._pswindows import IOPriority 5 | 6 | 7 | class IOPriorityStr(StrEnum): 8 | VERYLOW = 'VeryLow' 9 | LOW = 'Low' 10 | NORMAL = 'Normal' 11 | 12 | 13 | to_iopriority: Final[dict[IOPriorityStr, IOPriority]] = { 14 | IOPriorityStr.VERYLOW: IOPriority.IOPRIO_VERYLOW, 15 | IOPriorityStr.LOW: IOPriority.IOPRIO_LOW, 16 | IOPriorityStr.NORMAL: IOPriority.IOPRIO_NORMAL, 17 | None: None 18 | } 19 | -------------------------------------------------------------------------------- /src/enums/messages.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | from win32con import MB_ICONWARNING, MB_ICONINFORMATION, MB_ICONERROR, MB_ICONQUESTION, MB_OK, MB_OKCANCEL, \ 4 | MB_ABORTRETRYIGNORE, MB_YESNOCANCEL, MB_YESNO, MB_RETRYCANCEL, IDOK, IDCANCEL, IDABORT, IDRETRY, IDIGNORE, IDYES, \ 5 | IDNO, IDCLOSE, IDHELP 6 | 7 | 8 | class MBIcon(IntEnum): 9 | WARNING = MB_ICONWARNING 10 | INFORMATION = MB_ICONINFORMATION 11 | ERROR = MB_ICONERROR 12 | QUESTION = MB_ICONQUESTION 13 | 14 | 15 | class MBButton(IntEnum): 16 | OK = MB_OK 17 | OKCANCEL = MB_OKCANCEL 18 | ABORTRETRYIGNORE = MB_ABORTRETRYIGNORE 19 | YESNOCANCEL = MB_YESNOCANCEL 20 | YESNO = MB_YESNO 21 | RETRYCANCEL = MB_RETRYCANCEL 22 | 23 | 24 | class MBResult(IntEnum): 25 | OK = IDOK 26 | CANCEL = IDCANCEL 27 | ABORT = IDABORT 28 | RETRY = IDRETRY 29 | IGNORE = IDIGNORE 30 | YES = IDYES 31 | NO = IDNO 32 | CLOSE = IDCLOSE 33 | HELP = IDHELP 34 | -------------------------------------------------------------------------------- /src/enums/priority.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from typing import Final 3 | 4 | from psutil._pswindows import Priority 5 | 6 | 7 | class PriorityStr(StrEnum): 8 | IDLE = 'Idle' 9 | BELOW_NORMAL = 'BelowNormal' 10 | NORMAL = 'Normal' 11 | ABOVE_NORMAL = 'AboveNormal' 12 | HIGH = 'High' 13 | REALTIME = 'Realtime' 14 | 15 | 16 | to_priority: Final[dict[PriorityStr, Priority]] = { 17 | PriorityStr.IDLE: Priority.IDLE_PRIORITY_CLASS, 18 | PriorityStr.BELOW_NORMAL: Priority.BELOW_NORMAL_PRIORITY_CLASS, 19 | PriorityStr.NORMAL: Priority.NORMAL_PRIORITY_CLASS, 20 | PriorityStr.ABOVE_NORMAL: Priority.ABOVE_NORMAL_PRIORITY_CLASS, 21 | PriorityStr.HIGH: Priority.HIGH_PRIORITY_CLASS, 22 | PriorityStr.REALTIME: Priority.REALTIME_PRIORITY_CLASS, 23 | None: None 24 | } 25 | -------------------------------------------------------------------------------- /src/enums/process.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class ProcessParameter(StrEnum): 5 | AFFINITY = "affinity" 6 | NICE = "priority" 7 | IONICE = "I/O priority" 8 | -------------------------------------------------------------------------------- /src/enums/rules.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel 4 | 5 | from configuration.rule import ProcessRule, ServiceRule 6 | 7 | 8 | class RuleType(Enum): 9 | PROCESS = (ProcessRule, "processRules") 10 | SERVICE = (ServiceRule, "serviceRules") 11 | 12 | def __init__(self, clazz, field_in_config): 13 | self.clazz: type[BaseModel] = clazz 14 | self.field_in_config: str = field_in_config 15 | -------------------------------------------------------------------------------- /src/enums/selector.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class SelectorType(StrEnum): 5 | NAME = "Name" 6 | PATH = "Path" 7 | CMDLINE = "CommandLine" 8 | -------------------------------------------------------------------------------- /src/main_loop.py: -------------------------------------------------------------------------------- 1 | import os 2 | from time import sleep 3 | from typing import Optional 4 | 5 | import psutil 6 | from psutil._pswindows import Priority, IOPriority 7 | from pystray._win32 import Icon 8 | 9 | from configuration.config import Config 10 | from configuration.migration.all_migration import run_all_migration 11 | from constants.app_info import APP_NAME 12 | from constants.files import LOG_FILE_NAME 13 | from constants.log import LOG 14 | from constants.threads import THREAD_SETTINGS, THREAD_TRAY 15 | from constants.ui import SETTINGS_TITLE 16 | from service.config_service import ConfigService 17 | from service.rules_service import RulesService 18 | from ui.settings import open_settings 19 | from ui.tray import init_tray 20 | from util.messages import yesno_error_box, show_error 21 | from util.scheduler import TaskScheduler 22 | from util.startup import update_startup 23 | from util.updates import check_updates 24 | 25 | 26 | def priority_setup(): 27 | """ 28 | Set process priority and I/O priority. 29 | 30 | This function sets the process priority to BELOW_NORMAL_PRIORITY_CLASS and the I/O priority to IOPRIO_LOW. 31 | """ 32 | try: 33 | process = psutil.Process() 34 | process.nice(Priority.BELOW_NORMAL_PRIORITY_CLASS) 35 | process.ionice(IOPriority.IOPRIO_LOW) 36 | except psutil.Error: 37 | pass 38 | 39 | 40 | def main_loop(tray: Icon): 41 | """ 42 | Main application loop for applying rules at regular intervals, updating the configuration, and managing the system tray icon. 43 | 44 | Args: 45 | tray (Icon): The system tray icon instance to be managed within the loop. It will be stopped gracefully 46 | when the loop exits. 47 | """ 48 | TaskScheduler.schedule_task(THREAD_TRAY, tray.run) 49 | 50 | LOG.info('Application started') 51 | 52 | config: Optional[Config] = None 53 | is_changed: bool 54 | last_error_message = None 55 | 56 | while TaskScheduler.check_task(THREAD_TRAY): 57 | try: 58 | config, is_changed = ConfigService.reload_if_changed(config) 59 | 60 | if is_changed: 61 | LOG.info("Configuration file has been modified. Reloading all rules to apply changes.") 62 | 63 | RulesService.apply_rules(config, not is_changed) 64 | last_error_message = None 65 | except KeyboardInterrupt as e: 66 | raise e 67 | except BaseException as e: 68 | if not config: 69 | config = Config() 70 | 71 | current_error_message = str(e) 72 | 73 | if current_error_message != last_error_message: 74 | LOG.exception("Error in the loop of loading and applying rules.") 75 | 76 | last_error_message = current_error_message 77 | 78 | if ConfigService.rules_has_error(): 79 | show_rules_error_message() 80 | else: 81 | show_abstract_error_message(False) 82 | 83 | sleep(config.ruleApplyIntervalSeconds) 84 | 85 | LOG.info('The application has stopped') 86 | 87 | 88 | def start_app(): 89 | """ 90 | Start application. 91 | 92 | This function loads the configuration, sets up logging and process priorities, and starts the main application loop. 93 | """ 94 | tray: Optional[Icon] = None 95 | 96 | try: 97 | run_all_migration() 98 | update_startup() 99 | priority_setup() 100 | check_updates(True) 101 | 102 | tray: Icon = init_tray() 103 | main_loop(tray) 104 | except KeyboardInterrupt: 105 | pass 106 | except: 107 | LOG.exception("A critical error occurred, causing the application to stop.") 108 | show_abstract_error_message(True) 109 | finally: 110 | if tray: 111 | tray.stop() 112 | 113 | 114 | def show_rules_error_message(): 115 | message = "An error has occurred while loading or applying the rules.\n" 116 | 117 | if TaskScheduler.check_task(THREAD_SETTINGS): 118 | message += "Please check the correctness of the rules." 119 | show_error(message) 120 | else: 121 | message += f"Would you like to open the {SETTINGS_TITLE} to review and correct the rules?" 122 | if yesno_error_box(message): 123 | open_settings() 124 | 125 | 126 | def show_abstract_error_message(will_closed: bool): 127 | will_closed_text = 'The application will now close.' if will_closed else '' 128 | message = ( 129 | f"An error has occurred in the {APP_NAME} application. {will_closed_text}\n" 130 | f"To troubleshoot, please check the log file `{LOG_FILE_NAME}` for details.\n\n" 131 | f"Would you like to open the log file?" 132 | ) 133 | 134 | if yesno_error_box(message): 135 | os.startfile(LOG_FILE_NAME) 136 | -------------------------------------------------------------------------------- /src/model/process.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import psutil 4 | from psutil._pswindows import Priority, IOPriority 5 | from pydantic import BaseModel, Field, ConfigDict 6 | 7 | from model.service import Service 8 | 9 | 10 | class Process(BaseModel): 11 | model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) 12 | 13 | """ 14 | The Process class represents information about a running process. 15 | 16 | It includes attributes such as process ID (pid), executable name (exe), process name (name), priority (nice), I/O priority 17 | (ionice), CPU core affinity, and the associated psutil.Process object. 18 | """ 19 | 20 | pid: int = Field( 21 | title="PID", 22 | description="The unique identifier of the __process__ (**Process ID**).", 23 | default_sort_column_ui=True, 24 | width_ui=75 25 | ) 26 | 27 | process_name: Optional[str] = Field( 28 | title="Process Name", 29 | description="The **name** of the __process__.", 30 | justify_ui="left", 31 | width_ui=200 32 | ) 33 | 34 | service_name: Optional[str] = Field( 35 | title="Service Name", 36 | description="The **name** of the __service__ associated with this __process__.\n" 37 | "This field may be absent if the process is not a service.", 38 | justify_ui="left", 39 | width_ui=250 40 | ) 41 | 42 | bin_path: Optional[str] = Field( 43 | title="Executable Path", 44 | description="The **full path** to the executable binary of the __process__.", 45 | stretchable_column_ui=True, 46 | justify_ui="left" 47 | ) 48 | 49 | cmd_line: Optional[str] = Field( 50 | title="Command Line", 51 | description="The **command line** used to start the __process__, including all arguments.", 52 | stretchable_column_ui=True, 53 | justify_ui="left" 54 | ) 55 | 56 | priority: Optional[Priority] = Field( 57 | title="Priority", 58 | description="The **priority level** of the __process__.", 59 | exclude=True 60 | ) 61 | 62 | io_priority: Optional[IOPriority] = Field( 63 | title="I/O Priority", 64 | description="The **I/O priority** of the __process__.", 65 | exclude=True 66 | ) 67 | 68 | affinity: Optional[list[int]] = Field( 69 | title="CPU Core Affinity", 70 | description="A list of integers representing the CPU cores to which the __process__ is bound (**CPU core affinity**).", 71 | exclude=True 72 | ) 73 | 74 | process: psutil.Process = Field( 75 | description="The psutil.Process object associated with the __process__, providing access to additional control.", 76 | exclude=True 77 | ) 78 | 79 | service: Optional[Service] = Field( 80 | description="Contains information about the service if the current __process__ is associated with one.\n" 81 | "If the __process__ is not related to a service, this will be None.", 82 | exclude=True 83 | ) 84 | 85 | is_new: bool = Field(exclude=True) 86 | 87 | def __hash__(self): 88 | return hash((self.pid, self.bin_path, self.process_name, self.cmd_line)) 89 | 90 | def __eq__(self, other): 91 | if isinstance(other, Process): 92 | return ((self.pid, self.bin_path, self.process_name, self.cmd_line) == 93 | (other.pid, other.bin_path, other.process_name, other.cmd_line)) 94 | return False 95 | -------------------------------------------------------------------------------- /src/model/service.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Service: 6 | """ 7 | The Service class represents information about a Windows service. 8 | 9 | It includes attributes such as service process ID (pid), name, display name and current status. 10 | """ 11 | 12 | pid: int 13 | """ 14 | The process ID (pid) associated with the Windows service. 15 | """ 16 | 17 | name: str 18 | """ 19 | The name of the Windows service. 20 | """ 21 | 22 | display_name: str 23 | """ 24 | The display name of the Windows service. 25 | """ 26 | 27 | status: str 28 | """ 29 | The current status of the Windows service. 30 | """ 31 | -------------------------------------------------------------------------------- /src/service/config_service.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | from abc import ABC 4 | from datetime import datetime 5 | from os.path import exists 6 | from typing import Optional, Any 7 | 8 | from pydantic.config import JsonDict 9 | 10 | from configuration.config import Config 11 | from constants.files import CONFIG_FILE_NAME, CONFIG_FILE_ENCODING 12 | from enums.rules import RuleType 13 | from util.decorators import cached 14 | 15 | 16 | class ConfigService(ABC): 17 | """ 18 | ConfigService is responsible for managing the application's configuration data. 19 | 20 | This class provides methods for saving, loading, and accessing the configuration. 21 | """ 22 | 23 | @classmethod 24 | def save_config(cls, config: Config): 25 | """ 26 | Save the provided configuration object to a JSON file. 27 | 28 | If the configuration is not initialized, it creates a new one. 29 | 30 | Args: 31 | config (Config): The configuration object to be saved. 32 | """ 33 | if config is None: 34 | raise ValueError("config is None") 35 | 36 | with open(CONFIG_FILE_NAME, 'w', encoding=CONFIG_FILE_ENCODING) as file: 37 | json_data = config.model_dump_json(indent=4, exclude_none=True, warnings=False) 38 | file.write(json_data) 39 | 40 | @classmethod 41 | @cached(1) 42 | def load_config(cls, validate=True) -> Config: 43 | """ 44 | Load the configuration from a JSON file or create a new one if the file doesn't exist. 45 | 46 | Args: 47 | validate (bool): Whether to validate the configuration upon loading. Defaults to True. 48 | 49 | Returns: 50 | Config: The loaded or newly created configuration object. 51 | """ 52 | if not exists(CONFIG_FILE_NAME): 53 | cls.save_config(config := Config()) 54 | return config 55 | 56 | with open(CONFIG_FILE_NAME, 'r', encoding=CONFIG_FILE_ENCODING) as file: 57 | if validate: 58 | return Config(**json.load(file)) 59 | 60 | return Config.model_construct(**json.load(file)) 61 | 62 | __prev_mtime = 0 63 | 64 | @classmethod 65 | def reload_if_changed(cls, prev_config: Optional[Config]) -> tuple[Config, bool]: 66 | """ 67 | Reloads the configuration if it has changed since the last reload and returns the updated configuration and a flag indicating whether the configuration has changed. 68 | 69 | Args: 70 | prev_config (Optional[Config]): The previous configuration object. Can be None if there is no previous configuration. 71 | 72 | Returns: 73 | tuple[Config, bool]: A tuple containing the updated configuration object and a boolean flag indicating whether the configuration has changed. If the configuration has changed or there is no previous configuration, the updated configuration is loaded from the file. Otherwise, the previous configuration is returned. 74 | """ 75 | current_mtime = os.path.getmtime(CONFIG_FILE_NAME) 76 | is_changed = current_mtime > cls.__prev_mtime 77 | 78 | cls.__prev_mtime = current_mtime 79 | 80 | if is_changed or prev_config is None: 81 | return cls.load_config(), True 82 | 83 | return prev_config, False 84 | 85 | @classmethod 86 | def rules_has_error(cls) -> bool: 87 | """ 88 | Checks if there are any errors in the rules defined in the configuration. 89 | 90 | Returns: 91 | bool: True if there are errors in the rules, otherwise False. 92 | """ 93 | try: 94 | for rule_type in RuleType: 95 | rules: list[Any] = cls.load_config_raw().get(rule_type.field_in_config, []) 96 | 97 | try: 98 | for rule in rules: 99 | rule_type.clazz(**rule) 100 | except: 101 | return True 102 | except: 103 | pass # Yes, this is indeed a pass. 104 | 105 | return False 106 | 107 | @classmethod 108 | def load_config_raw(cls) -> JsonDict: 109 | """ 110 | Loads the raw configuration as a dictionary from the configuration file. 111 | 112 | Returns: 113 | dict: The raw configuration data. 114 | """ 115 | if not exists(CONFIG_FILE_NAME): 116 | cls.save_config(Config()) 117 | 118 | with open(CONFIG_FILE_NAME, 'r', encoding=CONFIG_FILE_ENCODING) as file: 119 | return json.load(file) 120 | 121 | @classmethod 122 | def save_config_raw(cls, config: JsonDict): 123 | """ 124 | Saves the raw configuration dictionary to the configuration file. 125 | 126 | Args: 127 | config (dict): The configuration data to be saved. 128 | """ 129 | if config is None: 130 | raise ValueError("config is None") 131 | 132 | with open(CONFIG_FILE_NAME, 'w', encoding=CONFIG_FILE_ENCODING) as file: 133 | json.dump(config, file, indent=4) 134 | 135 | @classmethod 136 | def backup_config(cls): 137 | """ 138 | Creates a backup of the current configuration file in the same directory where the original configuration file is located. 139 | 140 | If the configuration file does not exist, no backup is created. 141 | 142 | Raises: 143 | IOError: If the backup process fails. 144 | """ 145 | if not exists(CONFIG_FILE_NAME): 146 | return 147 | 148 | base_name, ext = os.path.splitext(CONFIG_FILE_NAME) 149 | timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') 150 | backup_filename = f"{base_name}_backup_{timestamp}.{ext}" 151 | 152 | try: 153 | with open(CONFIG_FILE_NAME, 'r', encoding=CONFIG_FILE_ENCODING) as src_file: 154 | with open(backup_filename, 'w', encoding=CONFIG_FILE_ENCODING) as dst_file: 155 | dst_file.write(src_file.read()) 156 | except IOError as e: 157 | raise IOError(f"Failed to create backup: {e}") 158 | 159 | 160 | if __name__ == '__main__': 161 | print(ConfigService.rules_has_error()) 162 | -------------------------------------------------------------------------------- /src/service/processes_info_service.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from abc import ABC 3 | from typing import Optional 4 | 5 | import psutil 6 | from psutil import NoSuchProcess 7 | 8 | from model.process import Process 9 | from service.services_info_service import ServicesInfoService 10 | from util.utils import none_int 11 | 12 | 13 | class ProcessesInfoService(ABC): 14 | """ 15 | The ProcessesInfoService class provides methods for retrieving information about running processes. 16 | """ 17 | 18 | _cache: dict[int, Process] = {} 19 | 20 | @classmethod 21 | def get_processes(cls) -> dict[int, Process]: 22 | """ 23 | Returns a dictionary with information about running processes. 24 | 25 | Returns: 26 | dict[int, Process]: A dictionary with information about running processes. 27 | """ 28 | 29 | cache = cls._cache 30 | services: Optional[dict] = None 31 | pids = set(psutil.pids()) 32 | 33 | for pid in pids: 34 | try: 35 | process_info = psutil.Process(pid) 36 | info = process_info.as_dict(attrs=[ 37 | 'exe', 38 | 'nice', 'ionice', 'cpu_affinity' 39 | ]) 40 | 41 | if pid in cache: 42 | process = cache[pid] 43 | 44 | if process.bin_path == info['exe']: 45 | process.priority = none_int(info['nice']) 46 | process.io_priority = none_int(info['ionice']) 47 | process.affinity = info['cpu_affinity'] 48 | process.is_new = False 49 | continue 50 | 51 | if services is None: 52 | services = ServicesInfoService.get_running_services() 53 | 54 | service = services.get(pid) 55 | info = process_info.as_dict(attrs=[ 56 | 'name', 'exe', 'cmdline', 57 | 'nice', 'ionice', 'cpu_affinity' 58 | ]) 59 | 60 | cache[pid] = Process.model_construct( 61 | pid=pid, 62 | process_name=info['name'], 63 | service_name=getattr(service, 'name', None), 64 | bin_path=info['exe'], 65 | cmd_line=cls._get_command_line(pid, info), 66 | priority=none_int(info['nice']), 67 | io_priority=none_int(info['ionice']), 68 | affinity=info['cpu_affinity'], 69 | process=process_info, 70 | service=service, 71 | is_new=True 72 | ) 73 | except NoSuchProcess: 74 | pass 75 | 76 | deleted_pids = cache.keys() - pids 77 | 78 | for pid in deleted_pids: 79 | del cache[pid] 80 | 81 | return cache.copy() 82 | 83 | @staticmethod 84 | def _get_command_line(pid, info): 85 | if pid == 0: 86 | return '' 87 | 88 | cmdline = info['cmdline'] or [''] 89 | 90 | if not cmdline[0]: 91 | cmdline[0] = info['exe'] or info['name'] 92 | 93 | if not cmdline[0]: 94 | return '' 95 | 96 | return subprocess.list2cmdline(cmdline) 97 | -------------------------------------------------------------------------------- /src/service/rules_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC 3 | from typing import Optional, Callable 4 | 5 | import psutil 6 | from psutil import AccessDenied, NoSuchProcess 7 | 8 | from configuration.config import Config 9 | from configuration.rule import ProcessRule, ServiceRule 10 | from constants.log import LOG 11 | from enums.bool import BoolStr 12 | from enums.io_priority import to_iopriority 13 | from enums.priority import to_priority 14 | from enums.process import ProcessParameter 15 | from enums.selector import SelectorType 16 | from model.process import Process 17 | from service.processes_info_service import ProcessesInfoService 18 | from util.cpu import format_affinity 19 | from util.decorators import cached 20 | from util.scheduler import TaskScheduler 21 | from util.utils import path_match 22 | 23 | 24 | class RulesService(ABC): 25 | """ 26 | The RulesService class provides methods for applying rules to processes and services. 27 | """ 28 | 29 | __ignore_pids: set[int] = {0, os.getpid()} 30 | __ignored_process_parameters: dict[Process, set[ProcessParameter]] = {} 31 | 32 | @classmethod 33 | def apply_rules(cls, config: Config, only_new: bool): 34 | """ 35 | Apply the rules defined in the configuration to handle processes and services. 36 | 37 | Args: 38 | config (Config): The configuration object containing the rules. 39 | only_new (bool, optional): If set to True, all processes will be fetched, regardless of their status. 40 | 41 | Returns: 42 | None 43 | """ 44 | if not (config.serviceRules or config.processRules): 45 | return 46 | 47 | cls.__light_gc_ignored_process_parameters() 48 | cls.__handle_processes(config, ProcessesInfoService.get_processes(), only_new) 49 | 50 | @classmethod 51 | def __handle_processes( 52 | cls, config: Config, processes: dict[int, Process], only_new: bool 53 | ): 54 | for pid, process in processes.items(): 55 | if pid in cls.__ignore_pids: 56 | continue 57 | 58 | rule: Optional[ProcessRule | ServiceRule] = cls.__first_rule_by_process( 59 | config, process 60 | ) 61 | 62 | if not rule: 63 | continue 64 | 65 | if rule.force == BoolStr.NO and only_new and not process.is_new: 66 | continue 67 | 68 | if rule.delay > 0: 69 | TaskScheduler.schedule_task( 70 | process, cls.__handle_process, process, rule, delay=rule.delay 71 | ) 72 | else: 73 | cls.__handle_process(process, rule) 74 | 75 | @classmethod 76 | def __handle_process(cls, process: Process, rule: ProcessRule | ServiceRule): 77 | parameter_methods: dict[ 78 | ProcessParameter, 79 | tuple[Callable[[Process, ProcessRule | ServiceRule], bool], str], 80 | ] = { 81 | ProcessParameter.AFFINITY: ( 82 | cls.__set_affinity, 83 | format_affinity(rule.affinity), 84 | ), 85 | ProcessParameter.NICE: (cls.__set_nice, rule.priority), 86 | ProcessParameter.IONICE: (cls.__set_ionice, rule.ioPriority), 87 | } 88 | 89 | try: 90 | ignored_parameters = cls.__ignored_process_parameters.setdefault( 91 | process, set() 92 | ) 93 | 94 | for param, (method, logger_value) in parameter_methods.items(): 95 | if param in ignored_parameters: 96 | continue 97 | 98 | service_name = f", {process.service_name}" if process.service else "" 99 | logger_string = f"{param.value} `{logger_value}` for {process.process_name} ({process.pid}{service_name})" 100 | 101 | try: 102 | if method(process, rule): 103 | LOG.info(f"Set {logger_string}.") 104 | except AccessDenied: 105 | ignored_parameters.add(param) 106 | LOG.warning(f"Failed to set {logger_string}.") 107 | 108 | except NoSuchProcess: 109 | pass 110 | 111 | @classmethod 112 | def __set_ionice(cls, process: Process, rule: ProcessRule | ServiceRule): 113 | io_priority = to_iopriority[rule.ioPriority] 114 | 115 | if io_priority and process.io_priority != io_priority: 116 | process.process.ionice(io_priority) 117 | return True 118 | 119 | @classmethod 120 | def __set_nice(cls, process: Process, rule: ProcessRule | ServiceRule): 121 | priority = to_priority[rule.priority] 122 | 123 | if priority and process.priority != priority: 124 | process.process.nice(priority) 125 | return True 126 | 127 | @classmethod 128 | def __set_affinity(cls, process: Process, rule: ProcessRule | ServiceRule): 129 | if rule.affinity and process.affinity != rule.affinity: 130 | process.process.cpu_affinity(rule.affinity) 131 | return True 132 | 133 | @classmethod 134 | def __first_rule_by_process( 135 | cls, config: Config, process: Process 136 | ) -> Optional[ProcessRule | ServiceRule]: 137 | if process.service: 138 | for rule in config.serviceRules: 139 | if path_match(rule.selector, process.service_name): 140 | return rule 141 | 142 | for rule in config.processRules: 143 | value = cls._get_value_for_matching(process, rule) 144 | 145 | if path_match(rule.selector, value): 146 | return rule 147 | 148 | return None 149 | 150 | @classmethod 151 | def find_rules_ids_by_process( 152 | cls, 153 | process: Process, 154 | process_rules: dict[str, ProcessRule], 155 | service_rules: dict[str, ServiceRule], 156 | ) -> list[tuple[str, ProcessRule | ServiceRule]]: 157 | result = [] 158 | 159 | if process.service: 160 | for row_id, rule in service_rules.items(): 161 | if path_match(rule.selector, process.service_name): 162 | result.append((row_id, rule)) 163 | 164 | for row_id, rule in process_rules.items(): 165 | value = cls._get_value_for_matching(process, rule) 166 | 167 | if path_match(rule.selector, value): 168 | result.append((row_id, rule)) 169 | 170 | return result 171 | 172 | @classmethod 173 | def _get_value_for_matching(cls, process, rule): 174 | if rule.selectorBy == SelectorType.NAME: 175 | return process.process_name 176 | elif rule.selectorBy == SelectorType.PATH: 177 | return process.bin_path 178 | elif rule.selectorBy == SelectorType.CMDLINE: 179 | return process.cmd_line 180 | 181 | message = f"Unknown selector type: {rule.selectorBy}" 182 | LOG.error(message) 183 | raise ValueError(message) 184 | 185 | @classmethod 186 | @cached(5) # Workaround to ensure the procedure runs only once every 5 seconds 187 | def __light_gc_ignored_process_parameters(cls) -> None: 188 | pids = psutil.pids() 189 | # Create a copy of the items to iterate over, preventing modification issues 190 | current_items = list(cls.__ignored_process_parameters.items()) 191 | cls.__ignored_process_parameters = { 192 | key: value for key, value in current_items if key.pid in pids 193 | } 194 | -------------------------------------------------------------------------------- /src/service/services_info_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | import psutil 4 | from psutil import STATUS_STOPPED, NoSuchProcess, ZombieProcess, AccessDenied 5 | from psutil._pswindows import WindowsService 6 | 7 | from model.service import Service 8 | from util.decorators import suppress_exception 9 | 10 | # Fix bug of psutil 11 | WindowsService.description = suppress_exception( 12 | WindowsService.description, 13 | (FileNotFoundError, ZombieProcess, AccessDenied, OSError), 14 | lambda: "" 15 | ) 16 | WindowsService._query_config = suppress_exception( 17 | WindowsService._query_config, 18 | (FileNotFoundError, ZombieProcess, AccessDenied, OSError), 19 | lambda: dict(display_name="", binpath="", username="", start_type="") 20 | ) 21 | 22 | 23 | class ServicesInfoService(ABC): 24 | """ 25 | The ServicesInfoService class provides methods for retrieving information about Windows services. 26 | """ 27 | 28 | @staticmethod 29 | def get_running_services() -> dict[int, Service]: 30 | result: dict[int, Service] = {} 31 | 32 | for service in psutil.win_service_iter(): 33 | try: 34 | # noinspection PyUnresolvedReferences 35 | info = service._query_status() 36 | status = info['status'] 37 | pid = info['pid'] 38 | 39 | if pid == STATUS_STOPPED: 40 | continue 41 | 42 | result[pid] = Service( 43 | pid, 44 | service.name(), 45 | service.display_name(), 46 | status 47 | ) 48 | except NoSuchProcess: 49 | pass 50 | 51 | return result 52 | 53 | @staticmethod 54 | def get_services() -> list[Service]: 55 | result: list[Service] = [] 56 | 57 | for service in psutil.win_service_iter(): 58 | try: 59 | # noinspection PyUnresolvedReferences 60 | info = service._query_status() 61 | 62 | result.append(Service( 63 | info['pid'], 64 | service.name(), 65 | info['status'] 66 | )) 67 | except NoSuchProcess: 68 | pass 69 | 70 | return result 71 | -------------------------------------------------------------------------------- /src/ui/settings_actions.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk, DISABLED 2 | 3 | from constants.resources import UI_CONFIG, UI_LOG, UI_SAVE 4 | from constants.ui import OPEN_CONFIG_LABEL, ActionEvents, OPEN_LOG_LABEL, LEFT_PACK, RIGHT_PACK 5 | from ui.widget.common.button import ExtendedButton 6 | from util.ui import load_img 7 | 8 | 9 | class SettingsActions(ttk.Frame): 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | self._setup_btn() 13 | 14 | def _setup_btn(self): 15 | self.open_config = open_config = ExtendedButton( 16 | self, 17 | text=f"{OPEN_CONFIG_LABEL}", 18 | event=ActionEvents.CONFIG, 19 | image=load_img(file=UI_CONFIG), 20 | description="**Opens** the __config file__." 21 | ) 22 | 23 | self.open_log = open_log = ExtendedButton( 24 | self, 25 | text=f"{OPEN_LOG_LABEL}", 26 | event=ActionEvents.LOG, 27 | image=load_img(file=UI_LOG), 28 | description="**Opens** the __log file__." 29 | ) 30 | 31 | self.save = save = ExtendedButton( 32 | self, 33 | text="Save", 34 | state=DISABLED, 35 | event=ActionEvents.SAVE, 36 | image=load_img(file=UI_SAVE), 37 | description="**Saves** the __settings__. \n**Hotkey:** __Ctrl+S__." 38 | ) 39 | 40 | open_config.pack(**LEFT_PACK) 41 | open_log.pack(**LEFT_PACK) 42 | save.pack(**RIGHT_PACK) 43 | -------------------------------------------------------------------------------- /src/ui/tray.py: -------------------------------------------------------------------------------- 1 | import pystray 2 | from PIL import Image 3 | from pystray import MenuItem, Menu 4 | from pystray._win32 import Icon 5 | 6 | from constants.app_info import APP_NAME_WITH_VERSION, APP_TITLE 7 | from constants.resources import APP_ICON 8 | from constants.ui import OPEN_LOG_LABEL, OPEN_CONFIG_LABEL 9 | from ui.settings import open_settings, is_opened_settings, get_settings 10 | from util.files import open_log_file, open_config_file 11 | from util.startup import toggle_startup, is_in_startup 12 | from util.updates import check_updates 13 | from util.utils import is_portable 14 | 15 | 16 | def close_app(item): 17 | if not is_opened_settings(): 18 | return item.stop() 19 | 20 | settings = get_settings() 21 | 22 | def close(): 23 | if settings.close(): 24 | item.stop() 25 | 26 | settings.after_idle(close) 27 | 28 | 29 | def init_tray() -> Icon: 30 | """ 31 | Initializes and returns a system tray icon. 32 | 33 | Returns: 34 | Icon: The system tray icon. 35 | """ 36 | image: Image = Image.open(APP_ICON) 37 | 38 | menu: tuple[MenuItem, ...] = ( 39 | MenuItem(APP_NAME_WITH_VERSION, lambda item: open_settings(), default=True), 40 | Menu.SEPARATOR, 41 | 42 | MenuItem(OPEN_CONFIG_LABEL, lambda _: open_config_file()), 43 | MenuItem(OPEN_LOG_LABEL, lambda _: open_log_file()), 44 | Menu.SEPARATOR, 45 | 46 | MenuItem( 47 | 'Run on Startup', 48 | lambda item: toggle_startup(), 49 | lambda item: is_in_startup(), 50 | visible=is_portable() 51 | ), 52 | MenuItem( 53 | 'Check for Updates', 54 | lambda item: check_updates() 55 | ), 56 | Menu.SEPARATOR, 57 | 58 | MenuItem('Quit', close_app), 59 | ) 60 | 61 | return pystray.Icon("tray_icon", image, APP_TITLE, menu) 62 | -------------------------------------------------------------------------------- /src/ui/widget/common/button.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk, LEFT, PhotoImage 2 | from typing import Optional 3 | 4 | from constants.ui import UI_PADDING 5 | from util.ui import get_button_font, single_font_width 6 | 7 | 8 | class ExtendedButton(ttk.Button): 9 | 10 | def __init__( 11 | self, 12 | master, 13 | *args, 14 | event: str, 15 | text: Optional[str] = None, 16 | width: Optional[int] = None, 17 | image=None, 18 | description=None, 19 | compound=None, 20 | **kwargs 21 | ): 22 | self._image = image 23 | self._font = get_button_font() 24 | self.description = description 25 | 26 | if compound is None: 27 | compound = LEFT if text else None 28 | 29 | super().__init__( 30 | master, 31 | *args, 32 | text=f" {text}" if image and text else text, 33 | command=lambda: master.event_generate(event), 34 | image=image, 35 | compound=compound, 36 | width=self._calc_width(text, image, width), 37 | **kwargs 38 | ) 39 | 40 | def _calc_width(self, text: Optional[str], image: Optional[PhotoImage], width: Optional[int]) -> Optional[int]: 41 | if width: 42 | return width 43 | 44 | width_image = getattr(image, 'width', lambda: 0)() 45 | 46 | if not text: 47 | return None 48 | 49 | return int((self._font.measure(text) + width_image + UI_PADDING) / single_font_width()) + 1 50 | -------------------------------------------------------------------------------- /src/ui/widget/common/combobox.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from tkinter import ttk, Tk 3 | from typing import Optional 4 | 5 | from util.ui import get_default_font, single_font_width 6 | from util.utils import extract_type, get_values_from_enum 7 | 8 | 9 | class EnumCombobox(ttk.Combobox): 10 | def __init__( 11 | self, 12 | master, 13 | annotation: type[Enum] | Optional[type[Enum]], 14 | description=None, 15 | auto_width=True, 16 | *args, 17 | **kwargs 18 | ): 19 | self.description = description 20 | self._font = get_default_font() if auto_width else None 21 | self._enum_type = extract_type(annotation) 22 | self._values = get_values_from_enum(annotation) 23 | 24 | super().__init__( 25 | master, 26 | values=self._values, 27 | width=self._calculate_max_width() if auto_width else None, 28 | *args, 29 | **kwargs 30 | ) 31 | 32 | def get_enum_value(self) -> Optional[Enum]: 33 | selected_value = self.get() 34 | 35 | if selected_value: 36 | return self._enum_type(selected_value) 37 | 38 | return None 39 | 40 | def _calculate_max_width(self): 41 | return max(map(self._font.measure, self._values)) // single_font_width() + 1 42 | 43 | 44 | if __name__ == "__main__": 45 | # Пример использования 46 | 47 | class MyEnum(str, Enum): 48 | OPTION1 = "Option 1" 49 | OPTION2 = "Option 2" 50 | OPTION3 = "Option 3" 51 | 52 | 53 | root = Tk() 54 | 55 | combobox = EnumCombobox(root, MyEnum, state="readonly") 56 | combobox.pack(padx=10, pady=10) 57 | 58 | 59 | def on_select(event): 60 | enum_value = combobox.get_enum_value() 61 | print(f"Selected Enum: {enum_value}") 62 | 63 | 64 | combobox.bind("<>", on_select, '+') 65 | 66 | root.mainloop() 67 | -------------------------------------------------------------------------------- /src/ui/widget/common/entry.py: -------------------------------------------------------------------------------- 1 | from tkinter import END, Entry 2 | 3 | from util.history import HistoryManager 4 | 5 | 6 | class ExtendedEntry(Entry): 7 | def __init__(self, master=None, placeholder=None, color='grey', description=None, *args, **kwargs): 8 | super().__init__( 9 | master, 10 | *args, 11 | **kwargs 12 | ) 13 | 14 | self._commit_history = True 15 | self.description = description 16 | self._setup_placeholder(color, placeholder) 17 | self._setup_history() 18 | 19 | def _setup_placeholder(self, color, placeholder): 20 | self._placeholder = placeholder 21 | 22 | if placeholder is not None: 23 | self._placeholder_color = color 24 | self._default_fg_color = self['fg'] 25 | 26 | self.bind("", self._focus_in, '+') 27 | self.bind("", self._focus_out, '+') 28 | 29 | self._focus_out() 30 | 31 | def _setup_history(self): 32 | self.history = history = HistoryManager(self.get, self.set) 33 | 34 | # Doesn't work globally 35 | # self.bind("<>", lambda _: history.redo() or print('redo'), '+') 36 | # self.bind("<>", lambda _: history.undo() or print('undo'), '+') 37 | 38 | self.config(validate="key", validatecommand=(self.register(self._text_changed))) 39 | 40 | def _text_changed(self): 41 | if self._commit_history: 42 | self.history.commit() 43 | return True 44 | 45 | def _set_placeholder(self): 46 | self.set(self._placeholder) 47 | self['fg'] = self._placeholder_color 48 | 49 | def _focus_in(self, *args): 50 | if super().get() == self._placeholder: 51 | self.delete(0, END) 52 | self['fg'] = self._default_fg_color 53 | 54 | def _focus_out(self, *args): 55 | if not self.get(): 56 | self._set_placeholder() 57 | 58 | def get(self): 59 | current_text = super().get() 60 | 61 | if self._placeholder is not None and current_text == self._placeholder: 62 | return "" 63 | else: 64 | return current_text 65 | 66 | def set(self, value): 67 | try: 68 | self._commit_history = False 69 | self.delete(0, END) 70 | self.insert(0, value) 71 | finally: 72 | self._commit_history = True 73 | -------------------------------------------------------------------------------- /src/ui/widget/common/label.py: -------------------------------------------------------------------------------- 1 | import re 2 | from tkinter import font, END, Text, ttk, Label 3 | 4 | from util.ui import get_parent_with_bg, get_label_font 5 | 6 | 7 | class WrappingLabel(Label): 8 | def __init__(self, master=None, **kwargs): 9 | Label.__init__(self, master, **kwargs) 10 | self.bind('', lambda e: self.config(wraplength=self.winfo_width()), '+') 11 | 12 | 13 | class RichLabel(Text): 14 | def __init__(self, *args, text="", textvariable=None, **kwargs): 15 | kwargs.setdefault("borderwidth", 0) 16 | kwargs.setdefault("relief", "flat") 17 | kwargs.setdefault("highlightthickness", 0) 18 | kwargs.setdefault("cursor", "arrow") 19 | kwargs["takefocus"] = False 20 | 21 | super().__init__(*args, **kwargs) 22 | self._textvariable = textvariable 23 | 24 | if "bg" not in kwargs: 25 | def _set_bg(event): 26 | parent = get_parent_with_bg(event.widget) 27 | if parent: 28 | self.configure(bg=parent.cget("bg")) 29 | 30 | self.bind("", _set_bg, '+') 31 | 32 | default_font = get_label_font() 33 | 34 | self.configure(font=default_font) 35 | 36 | font_configure = default_font.configure() 37 | bold_font = font.Font(**font_configure) 38 | italic_font = font.Font(**font_configure) 39 | underline_font = font.Font(**font_configure) 40 | overstrike_font = font.Font(**font_configure) 41 | code_font = self.get_monospace_font(default_font) 42 | 43 | bold_font.configure(weight="bold") 44 | italic_font.configure(slant="italic") 45 | underline_font.configure(underline=True) 46 | overstrike_font.configure(overstrike=True) 47 | 48 | self.tag_configure("bold", font=bold_font) 49 | self.tag_configure("italic", font=italic_font) 50 | self.tag_configure("underline", font=underline_font) 51 | self.tag_configure("overstrike", font=overstrike_font) 52 | self.tag_configure("code", font=code_font) 53 | 54 | if text: 55 | self.configure(text=text) 56 | 57 | self.bind("<1>", lambda event: "break", '+') 58 | self.bind("", lambda event: "break", '+') 59 | self.bind("", lambda event: "break", '+') 60 | 61 | if textvariable: 62 | self._set_text(self._textvariable.get()) 63 | textvariable.trace("w", self._on_var_changed) 64 | self.bind("<>", self._on_text_changed, '+') 65 | 66 | def get_monospace_font(self, default_font): 67 | code_configure = default_font.configure() 68 | del code_configure["family"] 69 | 70 | code_font = font.nametofont("TkFixedFont") 71 | code_font.configure(**code_configure) 72 | 73 | code_font.configure( 74 | size=round(code_configure["size"] * default_font.metrics('linespace') / code_font.metrics('linespace')) 75 | ) 76 | 77 | return code_font 78 | 79 | def _on_var_changed(self, *args): 80 | self._set_text(self._textvariable.get()) 81 | 82 | def _on_text_changed(self, event): 83 | if self._textvariable: 84 | self._textvariable.set(self.get("1.0", END)) 85 | 86 | def config(self, **kwargs): 87 | text = kwargs.pop('text', None) 88 | 89 | if text is not None: 90 | self._set_text(text) 91 | 92 | if kwargs: 93 | super().config(**kwargs) 94 | 95 | def _set_text(self, text): 96 | self.delete("1.0", END) 97 | 98 | tokens = self._tokenize(text) 99 | pos = "1.0" 100 | 101 | for token, style in tokens: 102 | self.insert(pos, token) 103 | end_pos = self.index(f"{pos}+{len(token)}c") 104 | 105 | if style: 106 | self.tag_add(style, pos, end_pos) 107 | pos = end_pos 108 | 109 | def _tokenize(self, text): 110 | pattern = ( 111 | r'(? last_end: 124 | unescaped_text = self._unescape(text[last_end:start]) 125 | tokens.append((unescaped_text, None)) 126 | 127 | bold, italic, underline, overstrike, code \ 128 | = match.group(1), match.group(3), match.group(5), match.group(7), match.group(9) 129 | 130 | if bold: 131 | tokens.append((self._unescape(match.group(2)), "bold")) 132 | elif italic: 133 | tokens.append((self._unescape(match.group(4)), "italic")) 134 | elif underline: 135 | tokens.append((self._unescape(match.group(6)), "underline")) 136 | elif overstrike: 137 | tokens.append((self._unescape(match.group(8)), "overstrike")) 138 | elif code: 139 | tokens.append((self._unescape(f"`{match.group(10)}`"), "code")) 140 | 141 | last_end = end 142 | 143 | if last_end < len(text): 144 | unescaped_text = self._unescape(text[last_end:]) 145 | tokens.append((unescaped_text, None)) 146 | 147 | return tokens 148 | 149 | def _unescape(self, text): 150 | return re.sub(r"\\(\*+|_+|~+|`+)", r'\1', text) 151 | 152 | def configure(self, **kwargs): 153 | return self.config(**kwargs) 154 | 155 | 156 | class Image(ttk.Label): 157 | def __init__(self, *args, image=None, **kwargs): 158 | self._image = image 159 | super().__init__(*args, image=image, **kwargs) 160 | 161 | def config(self, *args, **kwargs): 162 | image = kwargs.get('image') 163 | 164 | if image is not None: 165 | self._image = image 166 | 167 | super().config(*args, **kwargs) 168 | 169 | configure = config 170 | -------------------------------------------------------------------------------- /src/ui/widget/common/treeview/editable.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from tkinter import END, BOTH, Widget 3 | from typing import Optional, Literal 4 | 5 | from constants.ui import EditableTreeviewEvents, ScrollableTreeviewEvents, ExtendedTreeviewEvents 6 | from ui.widget.common.combobox import EnumCombobox 7 | from ui.widget.common.entry import ExtendedEntry 8 | from ui.widget.common.treeview.extended import CellInfo, ExtendedTreeview, RegionType 9 | from ui.widget.common.treeview.scrollable import ScrollableTreeview 10 | from util.history import HistoryManager 11 | from util.ui import full_visible_bbox 12 | from util.utils import extract_type 13 | 14 | ColumnType = Literal["text", "list"] 15 | 16 | _justify_mapping = { 17 | "w": "left", 18 | "e": "right", 19 | "center": "center" 20 | } 21 | 22 | 23 | class CellEditor: 24 | def __init__( 25 | self, 26 | master: ExtendedTreeview, 27 | cell_info: CellInfo 28 | ): 29 | self.cell = cell_info 30 | self._setup_widgets(master) 31 | 32 | def _setup_widgets(self, master): 33 | def on_change(_): 34 | self.event_generate(EditableTreeviewEvents.SAVE_CELL) 35 | master.focus_set() 36 | 37 | def on_escape(_): 38 | self.event_generate(EditableTreeviewEvents.ESCAPE) 39 | master.focus_set() 40 | 41 | cell = self.cell 42 | column_settings = master.column(cell.column_id) 43 | annotation = column_settings.get('type') 44 | justify = _justify_mapping[column_settings.get('anchor', 'center')] 45 | 46 | if issubclass(extract_type(annotation), Enum): 47 | editor = EnumCombobox( 48 | master, 49 | annotation, 50 | justify=justify, 51 | state="readonly", 52 | auto_width=False 53 | ) 54 | editor.set(cell.value) 55 | editor.bind("<>", on_change, '+') 56 | else: 57 | editor = ExtendedEntry(master, justify=justify) 58 | editor.set(cell.value) 59 | editor.history.clear() 60 | editor.select_range(0, END) 61 | editor.bind("", on_change, '+') 62 | 63 | editor.bind("", on_change, '+') 64 | editor.bind("", on_escape, '+') 65 | editor.pack(fill=BOTH) 66 | 67 | self.widget = editor 68 | 69 | def get(self): 70 | return self.widget.get().strip() 71 | 72 | def __getattr__(self, name): 73 | return getattr(self.widget, name) 74 | 75 | 76 | class EditableTreeview(ScrollableTreeview): 77 | def __init__(self, *args, **kwargs): 78 | super().__init__(*args, **kwargs) 79 | 80 | self._editor: Optional[Widget | CellEditor] = None 81 | 82 | self.bind("", self._save_and_destroy_editor, '+') 83 | self.bind("", self._on_dbl_click, '+') 84 | self.bind(ScrollableTreeviewEvents.SCROLL, self._place_editor, '+') 85 | self.bind("", lambda _: self.after(1, self._place_editor), '+') 86 | self.bind("<>", lambda _: self.select_all_rows(), "+") 87 | self.bind("", lambda _: self.delete_selected_rows(), "+") 88 | 89 | self._setup_history() 90 | 91 | def _setup_history(self): 92 | self.history = history = HistoryManager( 93 | self._get_historical_data, 94 | self._set_historical_data, 95 | 50 96 | ) 97 | 98 | self.bind(ExtendedTreeviewEvents.BEFORE_CHANGE, lambda _: history.commit(), "+") 99 | 100 | def _get_historical_data(self): 101 | return self.selection_indices(), self.as_list_of_list() 102 | 103 | def _set_historical_data(self, indices_values): 104 | try: 105 | self.begin_changes(True) 106 | self.clear() 107 | 108 | indices, values = indices_values 109 | 110 | for value in values: 111 | self.insert('', 'end', values=value) 112 | 113 | self.selection_indices_set(indices) 114 | finally: 115 | self.end_changes() 116 | 117 | self.update_focus() 118 | 119 | def editor(self) -> CellEditor: 120 | return self._editor 121 | 122 | def _on_dbl_click(self, event): 123 | self.edit_cell( 124 | self.identify_column(event.x), 125 | self.identify_row(event.y), 126 | self.identify_region(event.x, event.y) 127 | ) 128 | 129 | def _create_editor(self, cell): 130 | if cell.region != 'cell': 131 | return 132 | 133 | self._editor = editor = CellEditor(self, cell) 134 | 135 | editor.bind(EditableTreeviewEvents.SAVE_CELL, self._save_and_destroy_editor, '+') 136 | editor.bind(EditableTreeviewEvents.ESCAPE, self._destroy_editor, '+') 137 | editor.bind("", self._on_editor_destroy, '+') 138 | 139 | self._place_editor() 140 | self.event_generate(EditableTreeviewEvents.START_EDIT_CELL) 141 | 142 | def _place_editor(self, _=None): 143 | editor = self._editor 144 | 145 | if not editor: 146 | return 147 | 148 | cell = editor.cell 149 | bbox = full_visible_bbox(self, cell.row_id, cell.column_id) 150 | 151 | if bbox: 152 | x, y, width, height = bbox 153 | editor.place(x=x, y=y, width=width, height=height) 154 | editor.after(0, lambda: editor.focus_set()) # fixing focus_force not working from time to time 155 | else: 156 | editor.place_forget() 157 | 158 | def _save_and_destroy_editor(self, _=None): 159 | self._save_cell_changes() 160 | self._destroy_editor() 161 | 162 | def _destroy_editor(self, _=None): 163 | if self._editor: 164 | self._editor.destroy() 165 | 166 | def _save_cell_changes(self): 167 | editor = self._editor 168 | 169 | if not editor: 170 | return 171 | 172 | new_value = editor.get() 173 | cell = editor.cell 174 | 175 | if cell.value != new_value: 176 | self.set(cell.row_id, cell.column_id, new_value) 177 | 178 | def _on_editor_destroy(self, _=None): 179 | self._editor = None 180 | 181 | def edit_cell(self, column_id, row_id, region: RegionType): 182 | self._destroy_editor() 183 | self._create_editor(self.get_cell_info_by_ids(column_id, row_id, region)) 184 | -------------------------------------------------------------------------------- /src/ui/widget/common/treeview/pydantic.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | from tkinter import END 4 | from typing import Any, Optional, Iterable 5 | 6 | from pydantic import BaseModel, ValidationError 7 | from pydantic.config import JsonDict 8 | from pydantic_core import PydanticUndefined 9 | 10 | from ui.widget.common.treeview.extended import ExtendedTreeview 11 | 12 | _anchor_mapping = { 13 | "left": "w", 14 | "center": "center", 15 | "right": "e" 16 | } 17 | 18 | 19 | class PydanticTreeviewLoader: 20 | def __init__(self, treeview: ExtendedTreeview, model: type[BaseModel]): 21 | self._treeview = treeview 22 | self._model = model 23 | self._original_data = [] 24 | self._setup_columns() 25 | 26 | def _setup_columns(self): 27 | treeview = self._treeview 28 | model_fields = self._model.model_fields 29 | 30 | for column_name in treeview["columns"]: 31 | field_info = model_fields.get(column_name) 32 | 33 | if field_info: 34 | title = field_info.title or column_name 35 | else: 36 | raise RuntimeError("field_info is None") 37 | 38 | extra = field_info.json_schema_extra or dict() if field_info else dict() 39 | 40 | if extra.get('default_sort_column_ui', False) and hasattr(treeview, 'sort_column_name'): 41 | treeview.sort_column_name(column_name) 42 | 43 | kw = { 44 | 'anchor': _anchor_mapping[extra.get('justify_ui', 'center')], 45 | 'stretch': extra.get('stretchable_column_ui', False), 46 | 'type': field_info.annotation 47 | } 48 | 49 | width = extra.get('width_ui', None) 50 | if width: 51 | kw['minwidth'] = kw['width'] = width 52 | 53 | treeview.heading(column_name, text=title) 54 | treeview.column(column_name, **kw) 55 | 56 | def set_data(self, rules: Iterable[JsonDict | BaseModel]): 57 | treeview = self._treeview 58 | 59 | try: 60 | self._original_data = [] 61 | 62 | treeview.begin_changes() 63 | treeview.clear() 64 | 65 | column_names = treeview["columns"] 66 | 67 | for rule in rules: 68 | values = [ 69 | getattr(rule, column_name, '') 70 | if isinstance(rule, BaseModel) 71 | else rule.get(column_name, '') 72 | for column_name in column_names 73 | ] 74 | values = tuple(map(self._to_str, values)) 75 | 76 | self._original_data.append(values) 77 | self._treeview.insert('', END, values=values) 78 | 79 | if hasattr(treeview, 'sort_column'): 80 | treeview.sort_column() 81 | finally: 82 | treeview.end_changes() 83 | 84 | @staticmethod 85 | def _to_str(value) -> str: 86 | if value is None or str(value) == '': 87 | return '' 88 | 89 | return str(value) 90 | 91 | def has_changes(self) -> bool: 92 | return self._original_data != self._treeview.as_list_of_list() 93 | 94 | def commit_changes(self): 95 | self._original_data = self._treeview.as_list_of_list() 96 | 97 | def get_data(self) -> list[JsonDict]: 98 | return self._treeview.as_list_of_dict() 99 | 100 | def get_error_if_available(self, row_id) -> Optional[tuple[Any, Any]]: 101 | try: 102 | # noinspection PyCallingNonCallable 103 | self._model(**self._treeview.as_dict(row_id)) 104 | return None 105 | except ValidationError as e: 106 | return row_id, json.loads(e.json()) 107 | 108 | def get_default_row(self) -> JsonDict: 109 | treeview = self._treeview 110 | result = OrderedDict() 111 | model_fields = self._model.model_fields 112 | 113 | for column_name in treeview["columns"]: 114 | field_info = model_fields.get(column_name) or '' 115 | default_value = '' if field_info.default == PydanticUndefined else field_info.default or '' 116 | result[column_name] = str(default_value) 117 | 118 | return result 119 | 120 | def as_dict_of_models(self, validate: bool) -> dict[str, BaseModel]: 121 | treeview = self._treeview 122 | constructor = self._model if validate else self._model.model_construct 123 | result = OrderedDict() 124 | 125 | for row_id in treeview.get_children(): 126 | result[row_id] = constructor(**treeview.as_dict(row_id)) 127 | 128 | return result 129 | -------------------------------------------------------------------------------- /src/ui/widget/common/treeview/scrollable.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk, LEFT, BOTH, RIGHT, Y 2 | 3 | from constants.ui import ScrollableTreeviewEvents 4 | from ui.widget.common.treeview.extended import ExtendedTreeview 5 | 6 | 7 | class ScrollableTreeview(ExtendedTreeview): 8 | def __init__(self, master=None, *args, **kwargs): 9 | self._frame = ttk.Frame(master) 10 | self._scrollbar = ttk.Scrollbar( 11 | self._frame, 12 | orient="vertical", 13 | takefocus=0 14 | ) 15 | 16 | super().__init__(self._frame, *args, **kwargs) 17 | 18 | self._scrollbar.configure(command=self._on_scrollbar) 19 | super().configure(yscrollcommand=self._on_scrollbar_mouse) 20 | 21 | self._scrollbar.pack(side=RIGHT, fill=Y) 22 | super().pack(fill=BOTH, expand=True, side=LEFT) 23 | 24 | self.bind("<>", self.__on_select, '+') 25 | 26 | def on_scroll(self): 27 | pass 28 | 29 | def _forward_geometry_options(self, method, *args, **kwargs): 30 | geometry_options = ['anchor', 'expand', 'fill', 'in_', 'ipadx', 'ipady', 'padx', 'pady', 'side'] 31 | frame_kwargs = {key: value for key, value in kwargs.items() if key in geometry_options} 32 | other_kwargs = {key: value for key, value in kwargs.items() if key not in geometry_options} 33 | 34 | if frame_kwargs: 35 | getattr(self._frame, method)(*args, **frame_kwargs) 36 | 37 | if other_kwargs: 38 | getattr(super(ScrollableTreeview, self), method)(*args, **other_kwargs) 39 | 40 | def pack_configure(self, *args, **kwargs): 41 | return self._forward_geometry_options('pack_configure', *args, **kwargs) 42 | 43 | def pack_forget(self): 44 | self._frame.pack_forget() 45 | 46 | def pack_info(self): 47 | return self._frame.pack_info() 48 | 49 | def place_configure(self, *args, **kwargs): 50 | self._frame.place_configure(*args, **kwargs) 51 | 52 | def place_forget(self): 53 | self._frame.place_forget() 54 | 55 | def place_info(self): 56 | return self._frame.place_info() 57 | 58 | pack = pack_configure 59 | forget = pack_forget 60 | info = pack_info 61 | 62 | def _on_scrollbar(self, *args): 63 | self.yview(*args) 64 | self.event_generate(ScrollableTreeviewEvents.SCROLL) 65 | 66 | def _on_scrollbar_mouse(self, first, last): 67 | self._scrollbar.set(first, last) 68 | self.event_generate(ScrollableTreeviewEvents.SCROLL) 69 | 70 | def __on_select(self, _): 71 | for item in self.selection(): 72 | self.see(item) 73 | -------------------------------------------------------------------------------- /src/ui/widget/common/treeview/sortable.py: -------------------------------------------------------------------------------- 1 | from tkinter import Tk, BOTH 2 | 3 | from constants.resources import UI_SORT_UP, UI_SORT_DOWN, UI_SORT_EMPTY 4 | from ui.widget.common.treeview.scrollable import ScrollableTreeview 5 | from util.ui import load_img 6 | 7 | 8 | class SortableTreeview(ScrollableTreeview): 9 | def __init__(self, master=None, *args, **kwargs): 10 | super().__init__(master, *args, **kwargs, hand_on_title=True) 11 | self._sort_column_name = None 12 | self._sort_reverse = False 13 | 14 | self._sort_up_icon = load_img(UI_SORT_UP) 15 | self._sort_down_icon = load_img(UI_SORT_DOWN) 16 | self._sort_empty_icon = load_img(UI_SORT_EMPTY) 17 | 18 | self._setup_sorting() 19 | 20 | def _setup_sorting(self): 21 | for column_name in self["columns"]: 22 | self.heading(column_name, command=lambda c=column_name: self._on_heading_click(c)) 23 | 24 | self._update_heading_text() 25 | 26 | def _on_heading_click(self, column): 27 | if self._sort_column_name == column: 28 | self._sort_reverse = not self._sort_reverse 29 | else: 30 | self._sort_column_name = column 31 | self._sort_reverse = False 32 | 33 | self.sort_column() 34 | 35 | def sort_column(self): 36 | if not self._sort_column_name: 37 | return 38 | 39 | column = self._sort_column_name 40 | reverse = self._sort_reverse 41 | 42 | def sorter(value): 43 | if isinstance(value, str): 44 | return (True, value.strip() == '', value) 45 | 46 | return (False, False, value) 47 | 48 | items = [(self._get_value_as_type(self.set(k, column)), k) for k in self.get_children()] 49 | items.sort(key=lambda x: sorter(x[0]), reverse=reverse) 50 | 51 | for index, (_, k) in enumerate(items): 52 | self.move(k, '', index) 53 | 54 | self._update_heading_text() 55 | 56 | @staticmethod 57 | def _get_value_as_type(value): 58 | if not value: 59 | return value 60 | 61 | if value.replace('.', '', 1).isdigit(): 62 | return float(value) if '.' in value else int(value) 63 | 64 | return str.lower(value) 65 | 66 | def _update_heading_text(self): 67 | sort_icon = self._sort_up_icon if self._sort_reverse else self._sort_down_icon 68 | 69 | for column_name in self["columns"]: 70 | if column_name == self._sort_column_name: 71 | self.heading(column_name, image=sort_icon) 72 | else: 73 | self.heading(column_name, image=self._sort_empty_icon) 74 | 75 | def sort_column_name(self, column_name): 76 | self._sort_column_name = column_name 77 | 78 | 79 | if __name__ == "__main__": 80 | root = Tk() 81 | treeview = SortableTreeview(root, columns=('Column1', 'Column2', 'Column3'), show='headings') 82 | 83 | data = [ 84 | ('Apple', 3, 50), 85 | ('1', 3, 50), 86 | ('2', 3, 50), 87 | ('Banana', 1, 30), 88 | ('Cherry', 2, 40), 89 | ('Date', 5, 25), 90 | ('Elderberry', 10, 100), 91 | ('Fig', 7, 45), 92 | ('Grape', 12, 20), 93 | ('Honeydew', 4, 60), 94 | ('Indian Fig', 8, 70), 95 | ('Jackfruit', 6, 150), 96 | ('Kiwi', 9, 85), 97 | ('Lemon', 13, 10), 98 | ('Mango', 14, 200), 99 | ('Nectarine', 11, 90), 100 | ('Orange', 2, 40), 101 | ('Papaya', 16, 120), 102 | ('Quince', 15, 110), 103 | ('Raspberry', 18, 35), 104 | ('', 19, 95), 105 | ('Tangerine', 17, 55), 106 | ('Ugli Fruit', 21, 130), 107 | ('Vanilla', 22, 300), 108 | ('Watermelon', 20, 40), 109 | ('Xigua', 23, 80), 110 | ('Yellow Passion Fruit', 24, 75), 111 | ('Zucchini', 25, 15) 112 | ] 113 | 114 | for item in data: 115 | treeview.insert('', 'end', values=item) 116 | 117 | treeview.heading('Column1', text='Fruit Name') 118 | treeview.heading('Column2', text='Quantity') 119 | treeview.heading('Column3', text='Price') 120 | 121 | treeview.pack(fill=BOTH, expand=True) 122 | root.mainloop() 123 | -------------------------------------------------------------------------------- /src/ui/widget/settings/settings_tabs.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk, messagebox 2 | from typing import Optional 3 | 4 | from pydantic.config import JsonDict 5 | 6 | from constants.app_info import TITLE_ERROR 7 | from constants.log import LOG 8 | from constants.ui import ExtendedTreeviewEvents 9 | from service.config_service import ConfigService 10 | from ui.widget.settings.tabs.base_tab import BaseTab 11 | from ui.widget.settings.tabs.processes.process_tab import ProcessesTab 12 | from ui.widget.settings.tabs.rules.rules_tabs import ServiceRulesTab, ProcessRulesTab 13 | 14 | 15 | class SettingsTabs(ttk.Notebook): 16 | _DEFAULT_TOOLTIP = ( 17 | "To add a new rule, click the **Add** button.\n" 18 | "To edit a rule, **double-click** on the corresponding cell.\n" 19 | "Use the **context menu** for additional actions." 20 | ) 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | 25 | self._config: Optional[JsonDict] = None 26 | self._create_tabs() 27 | 28 | self.bind(ExtendedTreeviewEvents.CHANGE, lambda _: self._update_tabs_state(), "+") 29 | 30 | def _create_tabs(self): 31 | self._process_rules_tab = ProcessRulesTab(self) 32 | self._service_rules_tab = ServiceRulesTab(self) 33 | self._process_list_tab = ProcessesTab(self) 34 | 35 | self._process_list_tab.place() 36 | self._process_rules_tab.place() 37 | self._service_rules_tab.place() 38 | 39 | self._update_tabs_state() 40 | 41 | def current_tab(self) -> BaseTab: 42 | current_index = self.index(self.select()) 43 | tab_id = self.tabs()[current_index] 44 | return self.nametowidget(tab_id) 45 | 46 | def has_unsaved_changes(self) -> bool: 47 | for tab in self.frames(): 48 | if tab.has_changes(): 49 | return True 50 | return False 51 | 52 | def has_error(self) -> bool: 53 | for tab in self.frames(): 54 | if tab.has_error(): 55 | return True 56 | return False 57 | 58 | def load_data(self): 59 | self._config = ConfigService.load_config_raw() 60 | 61 | for tab in self.frames(): 62 | tab.load_from_config(self._config) 63 | 64 | def save_data(self) -> bool: 65 | try: 66 | if not self.has_unsaved_changes() or self._config is None: 67 | return True 68 | 69 | if self.has_error(): 70 | messagebox.showerror( 71 | TITLE_ERROR, 72 | "Unable to save: The current rules are invalid. " 73 | "Ensure all rules are correct before saving." 74 | ) 75 | return False 76 | 77 | for tab in self.frames(): 78 | if not tab.has_changes(): 79 | continue 80 | 81 | tab.save_to_config(self._config) 82 | ConfigService.save_config_raw(self._config) 83 | tab.commit_changes() 84 | 85 | return True 86 | except: 87 | LOG.exception("An error occurred while saving.") 88 | messagebox.showerror(TITLE_ERROR, "An error occurred while saving.") 89 | return False 90 | 91 | def frames(self) -> list[BaseTab]: 92 | return [self.nametowidget(tab_id) for tab_id in self.tabs()] 93 | 94 | def frames_by_tab(self) -> dict[str, BaseTab]: 95 | return { 96 | tab_id: self.nametowidget(tab_id) 97 | for tab_id in self.tabs() 98 | } 99 | 100 | def get_default_tooltip(self): 101 | return self.current_tab().default_tooltip() 102 | 103 | def _update_tabs_state(self): 104 | tabs = self.frames_by_tab() 105 | 106 | for id, tab in tabs.items(): 107 | star = "*" if tab.has_changes() or tab.has_error() else " " 108 | self.tab(id, text=f" {tab.title()} {star}") 109 | 110 | def next_tab(self): 111 | current_tab = self.index(self.select()) 112 | next_tab = (current_tab + 1) % self.index("end") 113 | self.select(next_tab) 114 | 115 | def prev_tab(self): 116 | current_tab = self.index(self.select()) 117 | next_tab = (current_tab - 1) % self.index("end") 118 | self.select(next_tab) 119 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/base_tab.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from tkinter import PhotoImage, ttk, LEFT 3 | from tkinter.ttk import Notebook 4 | 5 | 6 | class BaseTab(ttk.Frame, ABC): 7 | def __init__(self, master: Notebook): 8 | super().__init__(master) 9 | 10 | self.master: Notebook = master 11 | 12 | @staticmethod 13 | @abstractmethod 14 | def icon() -> PhotoImage: 15 | pass 16 | 17 | @staticmethod 18 | @abstractmethod 19 | def title() -> str: 20 | pass 21 | 22 | @staticmethod 23 | @abstractmethod 24 | def description() -> str: 25 | pass 26 | 27 | @staticmethod 28 | @abstractmethod 29 | def default_tooltip() -> str: 30 | pass 31 | 32 | @abstractmethod 33 | def load_from_config(self, config: dict): 34 | pass 35 | 36 | @abstractmethod 37 | def save_to_config(self, config: dict): 38 | pass 39 | 40 | @abstractmethod 41 | def has_changes(self) -> bool: 42 | pass 43 | 44 | @abstractmethod 45 | def commit_changes(self): 46 | pass 47 | 48 | @abstractmethod 49 | def has_error(self) -> bool: 50 | pass 51 | 52 | def place(self): 53 | self._icon = self.icon() 54 | self.master.add(self, text=f" {self.title()}", image=self._icon, compound=LEFT) 55 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/processes/process_list.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from typing import Callable, Optional 3 | 4 | from PIL import ImageTk 5 | from pydantic import BaseModel 6 | 7 | from configuration.rule import ProcessRule, ServiceRule 8 | from constants.log import LOG 9 | from constants.resources import UI_SERVICE, \ 10 | UI_PROCESS 11 | from constants.threads import THREAD_PROCESS_LIST_ICONS 12 | from constants.ui import COLUMN_WIDTH_WITH_ICON, \ 13 | ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE 14 | from enums.filters import FilterByProcessType 15 | from enums.rules import RuleType 16 | from enums.selector import SelectorType 17 | from model.process import Process 18 | from ui.widget.common.treeview.pydantic import PydanticTreeviewLoader 19 | from ui.widget.common.treeview.sortable import SortableTreeview 20 | from ui.widget.settings.tabs.processes.process_list_context_menu import ProcessContextMenu 21 | from util.scheduler import TaskScheduler 22 | from util.ui import load_img 23 | from util.utils import get_icon_from_exe 24 | 25 | 26 | class ProcessList(SortableTreeview): 27 | def __init__( 28 | self, 29 | model: type[BaseModel], 30 | add_rule_command: Callable[[RuleType, Optional[SelectorType]], None], 31 | find_rules_by_process_command: Callable[[Process], list[tuple[str, ProcessRule | ServiceRule]]], 32 | go_to_rule_command: Callable[[str, RuleType], None], 33 | *args, **kwargs 34 | ): 35 | columns = [key for key, field_info in model.model_fields.items() if not field_info.exclude] 36 | 37 | super().__init__( 38 | *args, 39 | **kwargs, 40 | show='tree headings', 41 | columns=columns, 42 | selectmode="browse" 43 | ) 44 | 45 | self.heading("#0", text="") 46 | self.column("#0", width=COLUMN_WIDTH_WITH_ICON, stretch=False) 47 | 48 | self._filter_by_type: FilterByProcessType = FilterByProcessType.ALL 49 | self._filter_by_search_query: Optional[str] = None 50 | 51 | self._process_icon = load_img(UI_PROCESS) 52 | self._service_icon = load_img(UI_SERVICE) 53 | self._icons = {} 54 | 55 | self._data: dict[int, Process] = {} 56 | self._loader = PydanticTreeviewLoader(self, model) 57 | self._setup_context_menu(add_rule_command, find_rules_by_process_command, go_to_rule_command) 58 | 59 | def set_data(self, values: dict[int, Process]): 60 | self._data = values 61 | 62 | def set_display_columns(self, filter_by_type): 63 | display_columns = list(self['columns']) 64 | 65 | if filter_by_type == FilterByProcessType.PROCESSES: 66 | display_columns.remove('service_name') 67 | 68 | self['displaycolumns'] = display_columns 69 | 70 | def set_filter(self, by_type: FilterByProcessType, by_search_query: str): 71 | self._filter_by_type = by_type 72 | self._filter_by_search_query = by_search_query 73 | 74 | def update_ui(self): 75 | filter_by_type = self._filter_by_type 76 | search_query = self._filter_by_search_query 77 | data = self._get_filtered_data(filter_by_type, search_query) 78 | 79 | self.set_display_columns(filter_by_type) 80 | self._loader.set_data(data) 81 | 82 | self._update_process_icons() 83 | 84 | def _update_process_icons(self): 85 | def set_icons(icons): 86 | try: 87 | for row_id, icon in icons: 88 | self.item(row_id, image=icon) 89 | except BaseException as e: 90 | if ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE not in str(e): 91 | LOG.exception("Update process icons error") 92 | 93 | def get_icons(): 94 | try: 95 | icons = {row_id: self.get_process_icon(self.as_model(row_id)) for row_id in self.get_children()} 96 | self.after(0, lambda: set_icons(icons.items())) 97 | except BaseException as e: 98 | if ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE not in str(e): 99 | LOG.exception("Get process icons error") 100 | 101 | TaskScheduler.schedule_task(THREAD_PROCESS_LIST_ICONS, get_icons) 102 | 103 | def _get_filtered_data(self, filter_by_type, search_query): 104 | data = [] 105 | 106 | for row in self._data.values(): 107 | by_type = (filter_by_type == FilterByProcessType.ALL 108 | or filter_by_type == FilterByProcessType.PROCESSES and row.service is None 109 | or filter_by_type == FilterByProcessType.SERVICES and row.service is not None) 110 | 111 | by_search = not search_query or any( 112 | value is not None and search_query in str(value).lower() 113 | for value in row.model_dump().values() 114 | ) 115 | 116 | if by_type and by_search: 117 | data.append(row) 118 | 119 | return data 120 | 121 | def as_model(self, row_id) -> Process: 122 | pid = int(self.as_dict(row_id)['pid']) 123 | return self._data[pid] 124 | 125 | def get_process_icon(self, process: Process): 126 | bin_path = process.bin_path 127 | 128 | if bin_path in self._icons: 129 | return self._icons[bin_path] 130 | 131 | image = None 132 | 133 | if bin_path: 134 | path = os.path.abspath(bin_path) 135 | 136 | if os.path.exists(path): 137 | try: 138 | pil_image = get_icon_from_exe(path) 139 | image = ImageTk.PhotoImage(pil_image) if pil_image else None 140 | except: 141 | pass 142 | 143 | if image is None: 144 | if process.service: 145 | image = self._service_icon 146 | else: 147 | image = self._process_icon 148 | 149 | self._icons[bin_path] = image 150 | return image 151 | 152 | def _setup_context_menu( 153 | self, 154 | add_rule_command: Callable[[RuleType, Optional[SelectorType]], None], 155 | find_rules_by_process_command: Callable[[Process], list[tuple[str, ProcessRule | ServiceRule]]], 156 | go_to_rule_command: Callable[[str, RuleType], None] 157 | ): 158 | self._context_menu = ProcessContextMenu(self, add_rule_command, find_rules_by_process_command, 159 | go_to_rule_command) 160 | self.bind("", self._context_menu.show, '+') 161 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/processes/process_list_actions.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk 2 | 3 | from constants.resources import UI_REFRESH 4 | from constants.ui import ActionEvents, LEFT_PACK 5 | from enums.filters import FilterByProcessType 6 | from ui.widget.common.button import ExtendedButton 7 | from ui.widget.common.combobox import EnumCombobox 8 | from ui.widget.common.entry import ExtendedEntry 9 | from util.ui import load_img 10 | 11 | 12 | class ProcessListActions(ttk.Frame): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | 16 | self._search_delay_timer = None 17 | self._setup_btn() 18 | 19 | def _setup_btn(self): 20 | self.refresh = refresh = ExtendedButton( 21 | self, 22 | text="Refresh", 23 | event=ActionEvents.REFRESH, 24 | image=load_img(file=UI_REFRESH), 25 | description="**Refreshes** the list of __processes__.\n**Hotkey:** __F5__." 26 | ) 27 | 28 | self.filterByType = filterByType = EnumCombobox( 29 | self, 30 | FilterByProcessType, 31 | description="**Filters** processes by __type__.", 32 | state="readonly" 33 | ) 34 | 35 | self.search = search = ExtendedEntry( 36 | self, 37 | description="**Searches** processes by __name__ or __attribute__.\n**Hotkey:** __Ctrl+F__.", 38 | placeholder="Search", 39 | width=30 40 | ) 41 | 42 | search.bind('', self._on_search_key_release, '+') 43 | 44 | filterByType.set(FilterByProcessType.ALL) 45 | filterByType.bind('<>', lambda _: self.event_generate(ActionEvents.FILTER_BY_TYPE), '+') 46 | 47 | search.pack(**LEFT_PACK) 48 | filterByType.pack(**LEFT_PACK) 49 | refresh.pack(**LEFT_PACK) 50 | 51 | def _on_search_key_release(self, _): 52 | if self._search_delay_timer: 53 | self.after_cancel(self._search_delay_timer) 54 | 55 | self._search_delay_timer = self.after(125, self._trigger_search_change_event) 56 | 57 | def _trigger_search_change_event(self): 58 | self.event_generate(ActionEvents.SEARCH_CHANGE) 59 | self._search_delay_timer = None 60 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/processes/process_list_context_menu.py: -------------------------------------------------------------------------------- 1 | import os 2 | from tkinter import Menu, LEFT, END, NORMAL, DISABLED 3 | from typing import Callable, Optional 4 | 5 | from configuration.rule import ProcessRule, ServiceRule 6 | from constants.resources import UI_ADD_PROCESS_RULE, UI_ADD_SERVICE_RULE, UI_COPY, UI_OPEN_FOLDER, \ 7 | UI_OPEN_FILE_PROPERTIES, UI_OPEN_SERVICE_PROPERTIES, UI_GO_TO_RULE, UI_PROCESS_RULES, UI_SERVICE_RULES 8 | from enums.rules import RuleType 9 | from enums.selector import SelectorType 10 | from model.process import Process 11 | from util.files import explore, show_file_properties, show_service_properties 12 | from util.ui import load_img, trim_cmenu_label 13 | 14 | CMENU_ADD_PROCESS_RULE_LABEL = " Add Process Rule" 15 | CMENU_ADD_SERVICE_RULE_LABEL = " Add Service Rule" 16 | CMENU_COPY_LABEL = " Copy Special" 17 | CMENU_OPEN_PROCESS_FOLDER_LABEL = " Open file location" 18 | CMENU_OPEN_FILE_PROPERTIES_LABEL = " File Properties" 19 | CMENU_OPEN_SERVICE_PROPERTIES_LABEL = " Service Properties" 20 | CMENU_GO_TO_RULE_LABEL = " Go to Rule" 21 | 22 | 23 | class ProcessContextMenu: 24 | def __init__( 25 | self, 26 | master, 27 | add_rule_command: Callable[[RuleType, Optional[SelectorType]], None], 28 | find_rules_by_process_command: Callable[[Process], list[tuple[str, ProcessRule | ServiceRule]]], 29 | go_to_rule_command: Callable[[str, RuleType], None] 30 | ): 31 | self.master = master 32 | self._add_rule = add_rule_command 33 | self._find_rules_by_process = find_rules_by_process_command 34 | self._go_to_rule = go_to_rule_command 35 | 36 | self._context_menu_icons = { 37 | CMENU_ADD_PROCESS_RULE_LABEL: load_img(UI_ADD_PROCESS_RULE), 38 | CMENU_ADD_SERVICE_RULE_LABEL: load_img(UI_ADD_SERVICE_RULE), 39 | CMENU_COPY_LABEL: load_img(UI_COPY), 40 | CMENU_OPEN_PROCESS_FOLDER_LABEL: load_img(UI_OPEN_FOLDER), 41 | CMENU_OPEN_FILE_PROPERTIES_LABEL: load_img(UI_OPEN_FILE_PROPERTIES), 42 | CMENU_OPEN_SERVICE_PROPERTIES_LABEL: load_img(UI_OPEN_SERVICE_PROPERTIES), 43 | CMENU_GO_TO_RULE_LABEL: load_img(UI_GO_TO_RULE), 44 | ProcessRule: load_img(UI_PROCESS_RULES), 45 | ServiceRule: load_img(UI_SERVICE_RULES) 46 | } 47 | 48 | self._setup_context_menu() 49 | 50 | def _setup_context_menu(self): 51 | icons = self._context_menu_icons 52 | 53 | self._root_menu = menu = Menu(self.master, tearoff=0) 54 | self._process_menu = Menu(menu, tearoff=0) 55 | self._copy_menu = Menu(menu, tearoff=0) 56 | self._rules_menu = Menu(menu, tearoff=0) 57 | 58 | menu.add_cascade( 59 | label=CMENU_ADD_PROCESS_RULE_LABEL, 60 | menu=self._process_menu, 61 | image=icons[CMENU_ADD_PROCESS_RULE_LABEL], 62 | compound=LEFT 63 | ) 64 | 65 | menu.add_command( 66 | label=CMENU_ADD_SERVICE_RULE_LABEL, 67 | command=lambda: self._add_rule(RuleType.SERVICE, None), 68 | image=icons[CMENU_ADD_SERVICE_RULE_LABEL], 69 | compound=LEFT 70 | ) 71 | 72 | menu.add_cascade( 73 | label=CMENU_GO_TO_RULE_LABEL, 74 | menu=self._rules_menu, 75 | image=icons[CMENU_GO_TO_RULE_LABEL], 76 | compound=LEFT 77 | ) 78 | 79 | menu.add_separator() 80 | 81 | menu.add_cascade( 82 | label=CMENU_COPY_LABEL, 83 | menu=self._copy_menu, 84 | image=icons[CMENU_COPY_LABEL], 85 | compound=LEFT 86 | ) 87 | 88 | menu.add_separator() 89 | 90 | menu.add_command( 91 | label=CMENU_OPEN_PROCESS_FOLDER_LABEL, 92 | command=self._open_process_folder, 93 | image=icons[CMENU_OPEN_PROCESS_FOLDER_LABEL], 94 | compound=LEFT 95 | ) 96 | 97 | menu.add_command( 98 | label=CMENU_OPEN_FILE_PROPERTIES_LABEL, 99 | command=self._open_file_properties, 100 | image=icons[CMENU_OPEN_FILE_PROPERTIES_LABEL], 101 | compound=LEFT 102 | ) 103 | 104 | menu.add_command( 105 | label=CMENU_OPEN_SERVICE_PROPERTIES_LABEL, 106 | command=self.open_service_properties, 107 | image=icons[CMENU_OPEN_SERVICE_PROPERTIES_LABEL], 108 | compound=LEFT 109 | ) 110 | 111 | def _open_file_properties(self): 112 | selected_item = self.master.selection() 113 | 114 | if not selected_item: 115 | return 116 | 117 | row = self.master.as_model(selected_item[0]) 118 | show_file_properties(row.bin_path, self.master.winfo_toplevel().winfo_id()) 119 | 120 | def _open_process_folder(self): 121 | selected_item = self.master.selection() 122 | 123 | if not selected_item: 124 | return 125 | 126 | row = self.master.as_model(selected_item[0]) 127 | explore(row.bin_path) 128 | 129 | def open_service_properties(self): 130 | selected_item = self.master.selection() 131 | 132 | if not selected_item: 133 | return 134 | 135 | row = self.master.as_model(selected_item[0]) 136 | show_service_properties(row.service.display_name) 137 | 138 | def _copy_to_clipboard(self, text): 139 | self.master.clipboard_clear() 140 | self.master.clipboard_append(text) 141 | self.master.update() 142 | 143 | def _update_process_menu(self, row): 144 | process_menu = self._process_menu 145 | exists = False 146 | 147 | process_menu.delete(0, END) 148 | 149 | if row.process_name: 150 | exists = True 151 | process_menu.add_command( 152 | label=f"By {SelectorType.NAME.value}: {row.process_name}", 153 | command=lambda: self._add_rule(RuleType.PROCESS, SelectorType.NAME) 154 | ) 155 | 156 | if row.bin_path: 157 | exists = True 158 | process_menu.add_command( 159 | label=trim_cmenu_label(f"By {SelectorType.PATH.value}: {row.bin_path}"), 160 | command=lambda: self._add_rule(RuleType.PROCESS, SelectorType.PATH) 161 | ) 162 | 163 | if row.cmd_line: 164 | exists = True 165 | process_menu.add_command( 166 | label=trim_cmenu_label(f"By {SelectorType.CMDLINE.value}: {row.cmd_line}"), 167 | command=lambda: self._add_rule(RuleType.PROCESS, SelectorType.CMDLINE) 168 | ) 169 | 170 | return exists 171 | 172 | def _update_copy_menu(self, row): 173 | copy_menu = self._copy_menu 174 | copy_menu.delete(0, END) 175 | 176 | values = [ 177 | row.pid, 178 | row.process_name, 179 | row.service_name, 180 | row.bin_path, 181 | row.cmd_line 182 | ] 183 | 184 | values = map(lambda o: str(o) if o else '', values) 185 | values = filter(lambda o: o, values) 186 | values = [*dict.fromkeys(values)] 187 | 188 | for value in values: 189 | copy_menu.add_command(label=trim_cmenu_label(value), command=lambda v=value: self._copy_to_clipboard(v)) 190 | 191 | def _update_rules_menu(self, row): 192 | icons = self._context_menu_icons 193 | rules_menu = self._rules_menu 194 | rules_menu.delete(0, END) 195 | 196 | rules = self._find_rules_by_process(row) 197 | 198 | if not rules: 199 | return False 200 | 201 | to_state = (DISABLED, NORMAL) 202 | is_first = True 203 | 204 | for row_id, rule in rules: 205 | rule_type = RuleType.PROCESS if isinstance(rule, ProcessRule) else RuleType.SERVICE 206 | 207 | rules_menu.add_command( 208 | label=f" {trim_cmenu_label(rule.selector)}", 209 | command=lambda ri=row_id, rt=rule_type: self._go_to_rule(ri, rt), 210 | image=icons[type(rule)], 211 | compound=LEFT, 212 | state=to_state[is_first] 213 | ) 214 | 215 | is_first = False 216 | 217 | return True 218 | 219 | def show(self, event): 220 | context_menu = self._root_menu 221 | process_list = self.master 222 | row_id = process_list.identify_row(event.y) 223 | 224 | if row_id: 225 | process_list.selection_set(row_id) 226 | row: Process = process_list.as_model(row_id) 227 | is_file = os.path.isfile(row.bin_path or '') 228 | is_service = row.service is not None 229 | to_state = (DISABLED, NORMAL) 230 | 231 | not_empty_process_menu = self._update_process_menu(row) 232 | self._update_copy_menu(row) 233 | not_empty_rules_menu = self._update_rules_menu(row) 234 | 235 | context_menu.entryconfig(CMENU_ADD_PROCESS_RULE_LABEL, state=to_state[not_empty_process_menu]) 236 | context_menu.entryconfig(CMENU_ADD_SERVICE_RULE_LABEL, state=to_state[is_service]) 237 | context_menu.entryconfig(CMENU_OPEN_PROCESS_FOLDER_LABEL, state=to_state[is_file]) 238 | context_menu.entryconfig(CMENU_OPEN_FILE_PROPERTIES_LABEL, state=to_state[is_file]) 239 | context_menu.entryconfig(CMENU_OPEN_SERVICE_PROPERTIES_LABEL, state=to_state[is_service]) 240 | context_menu.entryconfig(CMENU_GO_TO_RULE_LABEL, state=to_state[not_empty_rules_menu]) 241 | 242 | context_menu.post(event.x_root, event.y_root) 243 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/processes/process_tab.py: -------------------------------------------------------------------------------- 1 | from tkinter import PhotoImage, ttk, X, BOTH, NORMAL, DISABLED, CENTER 2 | from tkinter.ttk import Notebook 3 | from typing import Optional 4 | 5 | from configuration.rule import ServiceRule, ProcessRule 6 | from constants.log import LOG 7 | from constants.resources import UI_PROCESS_LIST 8 | from constants.threads import THREAD_PROCESS_LIST_DATA 9 | from constants.ui import UI_PADDING, ActionEvents, ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE 10 | from enums.rules import RuleType 11 | from enums.selector import SelectorType 12 | from model.process import Process 13 | from service.processes_info_service import ProcessesInfoService 14 | from service.rules_service import RulesService 15 | from ui.widget.settings.tabs.base_tab import BaseTab 16 | from ui.widget.settings.tabs.processes.process_list import ProcessList 17 | from ui.widget.settings.tabs.processes.process_list_actions import ProcessListActions 18 | from util.scheduler import TaskScheduler 19 | from util.ui import load_img 20 | 21 | 22 | class ProcessesTab(BaseTab): 23 | @staticmethod 24 | def default_tooltip() -> str: 25 | return "Use the **context menu** to add a __process__ or __service__ as a rule, as well as for other actions." 26 | 27 | @staticmethod 28 | def icon() -> PhotoImage: 29 | return load_img(file=UI_PROCESS_LIST) 30 | 31 | @staticmethod 32 | def title() -> str: 33 | return 'Process List' 34 | 35 | @staticmethod 36 | def description() -> str: 37 | return ("Interface for **browsing** the list of active __processes__ and __services__ with the option to " 38 | "**add** selected items to the rules configuration.") 39 | 40 | def __init__(self, master: Notebook): 41 | self.model = Process 42 | self.master = master 43 | 44 | super().__init__(master) 45 | 46 | self._create_process_list() 47 | self._create_progress_bar() 48 | self._create_actions() 49 | 50 | self._pack() 51 | 52 | def _create_process_list(self): 53 | self.process_list = process_list = ProcessList( 54 | self.model, 55 | self._add_rule, 56 | self._find_rules_by_process, 57 | self._go_to_rule, 58 | self 59 | ) 60 | process_list.bind("", lambda _: self._refresh(), "+") 61 | 62 | def _create_progress_bar(self): 63 | self._progress_bar = progress_bar = ttk.Progressbar(self.process_list, mode='indeterminate') 64 | progress_bar.start() 65 | 66 | def _create_actions(self): 67 | self.actions = actions = ProcessListActions(self) 68 | actions.bind(ActionEvents.REFRESH, lambda _: self._refresh(), "+") 69 | actions.bind(ActionEvents.FILTER_BY_TYPE, lambda _: self._update_process_list(), "+") 70 | actions.bind(ActionEvents.SEARCH_CHANGE, lambda _: self._update_process_list(), "+") 71 | 72 | def _pack(self): 73 | self.actions.pack(fill=X, padx=UI_PADDING, pady=(UI_PADDING, 0)) 74 | self.process_list.pack(fill=BOTH, expand=True, padx=UI_PADDING, pady=UI_PADDING) 75 | 76 | def load_from_config(self, config: dict): 77 | self._refresh() 78 | 79 | def _update_process_list(self): 80 | try: 81 | filter_by_type = self.actions.filterByType.get_enum_value() 82 | search_query = self.actions.search.get().strip().lower() 83 | 84 | self.process_list.set_filter(filter_by_type, search_query) 85 | self.process_list.update_ui() 86 | except BaseException as e: 87 | if ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE not in str(e): 88 | LOG.exception("Update process list error") 89 | 90 | def _refresh(self): 91 | def update_process_list(): 92 | # LOG.info("Updating process list...") 93 | 94 | try: 95 | self._update_process_list() 96 | finally: 97 | self._refresh_state() 98 | 99 | def load_data(): 100 | # LOG.info("Loading data...") 101 | 102 | try: 103 | self.process_list.set_data(ProcessesInfoService.get_processes()) 104 | self.after(0, update_process_list) 105 | except BaseException as e: 106 | if ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE not in str(e): 107 | LOG.exception("Load data error") 108 | 109 | self._refresh_state() 110 | 111 | try: 112 | self._refresh_state(True) 113 | self.process_list.clear() 114 | 115 | TaskScheduler.schedule_task(THREAD_PROCESS_LIST_DATA, load_data) 116 | except BaseException as e: 117 | if ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE not in str(e): 118 | LOG.exception("Refresh error") 119 | 120 | self._refresh_state() 121 | 122 | def _refresh_state(self, lock: bool = False): 123 | try: 124 | actions = self.actions 125 | actions.refresh['state'] = DISABLED if lock else NORMAL 126 | 127 | progress_bar = self._progress_bar 128 | 129 | if lock: 130 | progress_bar.place(relx=0.5, rely=0.5, anchor=CENTER) 131 | else: 132 | progress_bar.place_forget() 133 | except BaseException as e: 134 | if ERROR_TRYING_UPDATE_TERMINATED_TKINTER_INSTANCE not in str(e): 135 | LOG.exception("Refresh state error") 136 | 137 | def save_to_config(self, config: dict): 138 | pass 139 | 140 | def has_changes(self) -> bool: 141 | return False 142 | 143 | def has_error(self) -> bool: 144 | return False 145 | 146 | def commit_changes(self): 147 | pass 148 | 149 | def _add_rule(self, rule_type: RuleType, selector_type: Optional[SelectorType] = None): 150 | if rule_type == RuleType.SERVICE and selector_type is not None: 151 | raise ValueError("selector_type must be None when rule_type is SERVICE") 152 | 153 | process_list = self.process_list 154 | row_id = process_list.selection() 155 | row = process_list.as_dict(row_id) 156 | rules_list = None 157 | 158 | if rule_type == RuleType.PROCESS: 159 | rules_list = self.master._process_rules_tab.rules_list 160 | elif rule_type == RuleType.SERVICE: 161 | rules_list = self.master._service_rules_tab.rules_list 162 | 163 | if rules_list is None: 164 | raise ValueError("rules_list is None") 165 | 166 | rule_row = rules_list._loader.get_default_row() 167 | 168 | if selector_type is None: 169 | rule_row['selector'] = row['service_name'] 170 | else: 171 | rule_row['selectorBy'] = str(selector_type) 172 | 173 | if selector_type == SelectorType.NAME: 174 | rule_row['selector'] = row['process_name'] 175 | elif selector_type == SelectorType.PATH: 176 | rule_row['selector'] = row['bin_path'] 177 | elif selector_type == SelectorType.CMDLINE: 178 | rule_row['selector'] = row['cmd_line'] 179 | 180 | rules_list.add_row([*rule_row.values()], index=0) 181 | 182 | def _find_rules_by_process(self, process: Process) -> list[tuple[str, ProcessRule | ServiceRule]]: 183 | master = self.master 184 | 185 | return RulesService.find_rules_ids_by_process( 186 | process, 187 | master._process_rules_tab.rules_list.as_dict_of_models(), 188 | master._service_rules_tab.rules_list.as_dict_of_models() 189 | ) 190 | 191 | def _go_to_rule(self, row_id: str, rule_type: RuleType): 192 | master = self.master 193 | tab = None 194 | 195 | if rule_type == RuleType.PROCESS: 196 | tab = master._process_rules_tab 197 | elif rule_type == RuleType.SERVICE: 198 | tab = master._service_rules_tab 199 | 200 | if tab is None: 201 | raise ValueError("tab is None") 202 | 203 | master.select(tab) 204 | tab.rules_list.selection_set(row_id) 205 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/rules/base_rules_tab.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from tkinter import X, BOTH, NORMAL, DISABLED 3 | from tkinter.ttk import Notebook 4 | 5 | from constants.ui import UI_PADDING, ActionEvents, ExtendedTreeviewEvents 6 | from enums.rules import RuleType 7 | from ui.widget.settings.tabs.base_tab import BaseTab 8 | from ui.widget.settings.tabs.rules.rules_list import RulesList 9 | from ui.widget.settings.tabs.rules.rules_list_actions import RulesListActions 10 | 11 | 12 | class BaseRulesTab(BaseTab, ABC): 13 | @staticmethod 14 | def default_tooltip() -> str: 15 | return ( 16 | "To add a new rule, click the **Add** button.\n" 17 | "To edit a rule, **double-click** on the corresponding cell.\n" 18 | "Use the **context menu** for additional actions." 19 | ) 20 | 21 | def __init__(self, master: Notebook, rule_type: RuleType): 22 | super().__init__(master) 23 | 24 | self.rule_type: RuleType = rule_type 25 | self.model = rule_type.clazz 26 | self.rules_list = self._create_rules_list() 27 | self.actions = self._create_actions() 28 | 29 | self._pack() 30 | 31 | def _pack(self): 32 | self.actions.pack(fill=X, padx=UI_PADDING, pady=(UI_PADDING, 0)) 33 | self.rules_list.pack(fill=BOTH, expand=True, padx=UI_PADDING, pady=UI_PADDING) 34 | 35 | def _create_rules_list(self): 36 | rules_list = RulesList(self.rule_type.clazz, self) 37 | 38 | rules_list.bind("<>", lambda _: self._update_actions_state(), "+") 39 | rules_list.bind(ExtendedTreeviewEvents.CHANGE, lambda _: self._update_actions_state(), "+") 40 | rules_list.bind(ExtendedTreeviewEvents.CHANGE, 41 | lambda _: self.master.event_generate(ExtendedTreeviewEvents.CHANGE), "+") 42 | 43 | return rules_list 44 | 45 | def _create_actions(self): 46 | actions = RulesListActions(self) 47 | rules_list = self.rules_list 48 | 49 | actions.bind(ActionEvents.ADD, lambda _: rules_list.add_row(), "+") 50 | actions.bind(ActionEvents.DELETE, lambda _: rules_list.delete_selected_rows(), "+") 51 | actions.bind(ActionEvents.UP, lambda _: rules_list.move_rows_up(), "+") 52 | actions.bind(ActionEvents.DOWN, lambda _: rules_list.move_rows_down(), "+") 53 | 54 | return actions 55 | 56 | def _update_actions_state(self): 57 | rules_list = self.rules_list 58 | actions = self.actions 59 | selected_items = rules_list.selection() 60 | 61 | if selected_items: 62 | first_selected_item = selected_items[0] 63 | last_selected_item = selected_items[-1] 64 | first_index = rules_list.index(first_selected_item) 65 | last_index = rules_list.index(last_selected_item) 66 | total_items = len(rules_list.get_children()) 67 | 68 | actions.move_up["state"] = NORMAL if first_index > 0 else DISABLED 69 | actions.move_down["state"] = NORMAL if last_index < total_items - 1 else DISABLED 70 | else: 71 | actions.move_up["state"] = DISABLED 72 | actions.move_down["state"] = DISABLED 73 | 74 | actions.delete["state"] = NORMAL if selected_items else DISABLED 75 | 76 | def load_from_config(self, config: dict): 77 | self.rules_list.set_data(config.get(self.rule_type.field_in_config, [])) 78 | self.rules_list.history.clear() 79 | 80 | def save_to_config(self, config: dict): 81 | rules_list = self.rules_list 82 | config[self.rule_type.field_in_config] = rules_list.get_data() 83 | 84 | def has_changes(self) -> bool: 85 | return self.rules_list.has_changes() 86 | 87 | def commit_changes(self): 88 | self.rules_list.commit_changes() 89 | 90 | def has_error(self) -> bool: 91 | return self.rules_list.has_error() 92 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/rules/rules_list.py: -------------------------------------------------------------------------------- 1 | from tkinter import Menu, LEFT 2 | from typing import Any 3 | 4 | from pydantic import BaseModel 5 | from pydantic.config import JsonDict 6 | 7 | from constants.resources import UI_ERROR, UI_DELETE, UI_REDO, UI_UNDO, UI_SELECT_ALL, UI_ADD 8 | from constants.ui import ERROR_ROW_COLOR, ScrollableTreeviewEvents, ExtendedTreeviewEvents 9 | from ui.widget.common.label import Image 10 | from ui.widget.common.treeview.editable import EditableTreeview 11 | from ui.widget.common.treeview.pydantic import PydanticTreeviewLoader 12 | from util.ui import full_visible_bbox, load_img 13 | 14 | 15 | class RulesList(EditableTreeview): 16 | def __init__(self, model: type[BaseModel], *args, **kwargs): 17 | super().__init__( 18 | *args, 19 | **kwargs, 20 | show='headings', 21 | columns=list(model.model_fields.keys()), 22 | hand_on_title=True 23 | ) 24 | 25 | self._loader = PydanticTreeviewLoader(self, model) 26 | self._error_icons: dict[tuple[str, str], Image] = {} 27 | 28 | self.tag_configure("error", background=ERROR_ROW_COLOR) 29 | 30 | self.bind(ExtendedTreeviewEvents.CHANGE, lambda _: self._check_errors(), '+') 31 | self.bind(ScrollableTreeviewEvents.SCROLL, self._place_icons, '+') 32 | self.bind("", lambda _: self.after(1, self._place_icons), '+') 33 | 34 | self._setup_context_menu() 35 | 36 | def _setup_context_menu(self): 37 | history = self.history 38 | 39 | self._context_menu_icons = icons = { 40 | 'del': load_img(UI_DELETE), 41 | 'undo': load_img(UI_UNDO), 42 | 'redo': load_img(UI_REDO), 43 | 'select_all': load_img(UI_SELECT_ALL), 44 | 'add': load_img(UI_ADD), 45 | } 46 | self._context_menu = menu = Menu(self, tearoff=0) 47 | 48 | menu.add_command( 49 | label=" Undo", 50 | command=history.undo, 51 | image=icons['undo'], 52 | compound=LEFT, 53 | accelerator="Ctrl+Z" 54 | ) 55 | menu.add_command( 56 | label=" Redo", 57 | command=history.redo, 58 | image=icons['redo'], 59 | compound=LEFT, 60 | accelerator="Ctrl+Shift+Z" 61 | ) 62 | menu.add_separator() 63 | menu.add_command( 64 | label=" Add", 65 | command=self.add_row, 66 | image=icons['add'], 67 | compound=LEFT, 68 | accelerator="Ctrl+D" 69 | ) 70 | menu.add_command( 71 | label=" Select all", 72 | command=self.select_all_rows, 73 | image=icons['select_all'], 74 | compound=LEFT, 75 | accelerator="Ctrl+A" 76 | ) 77 | menu.add_command( 78 | label=" Delete", 79 | command=self.delete_selected_rows, 80 | image=icons['del'], 81 | compound=LEFT, 82 | accelerator="Del" 83 | ) 84 | 85 | self.bind("", self._show_context_menu, '+') 86 | 87 | def _show_context_menu(self, event): 88 | context_menu = self._context_menu 89 | process_list = self 90 | row_id = process_list.identify_row(event.y) 91 | selected_items = process_list.selection() 92 | 93 | if row_id: 94 | if selected_items and len(selected_items) == 1: 95 | process_list.selection_set(row_id) 96 | context_menu.post(event.x_root, event.y_root) 97 | 98 | def _errors(self) -> dict[Any, Any]: 99 | errors = [ 100 | self._loader.get_error_if_available(row_id) 101 | for row_id in self.get_children() 102 | ] 103 | 104 | return { 105 | error[0]: error[1] 106 | for error in errors 107 | if error is not None 108 | } 109 | 110 | def has_error(self): 111 | return len(self._errors()) > 0 112 | 113 | def _check_errors(self): 114 | self._destroy_error_icons() 115 | 116 | errors = self._errors() 117 | rows = self.get_children() 118 | 119 | for row_id in rows: 120 | errors_by_columns = errors.get(row_id) 121 | self._highlights_error(row_id, bool(errors_by_columns)) 122 | 123 | if errors_by_columns: 124 | for column_error in errors_by_columns: 125 | for column in column_error["loc"]: 126 | self._place_icon(row_id, column, column_error) 127 | 128 | def _destroy_error_icons(self): 129 | if self._error_icons: 130 | for icon in self._error_icons.values(): 131 | icon.destroy() 132 | 133 | self._error_icons = {} 134 | 135 | def _place_icons(self, _=None): 136 | for key, icon in self._error_icons.items(): 137 | self._place_icon(*key) 138 | 139 | def _place_icon(self, row_id, column_id, column_error=None): 140 | bbox = full_visible_bbox(self, row_id, column_id) 141 | key = (row_id, column_id) 142 | icon = self._error_icons.get(key) 143 | 144 | if not icon: 145 | self._error_icons[key] = icon = Image( 146 | self, 147 | image=load_img(file=UI_ERROR), 148 | background=ERROR_ROW_COLOR, 149 | cursor="hand2" 150 | ) 151 | icon.bind("", lambda _: self.edit_cell(column_id, row_id, 'cell'), "+") 152 | icon.bind("", lambda _: self.selection_set(row_id), '+') 153 | 154 | user_input = "" 155 | 156 | if isinstance(column_error['input'], str): 157 | user_input = f"**User Input:** `{column_error['input']}`" 158 | 159 | tooltip = ( 160 | "The value you entered is __incorrect__. Please check and update it.\n\n" 161 | f"**Cause:** {column_error['msg']}.\n" 162 | + user_input 163 | ) 164 | self.error_icon_created(icon, tooltip) 165 | 166 | if bbox: 167 | x, y, _, height = bbox 168 | icon.place(x=x, y=y, height=height) 169 | else: 170 | icon.place_forget() 171 | 172 | def _highlights_error(self, row_id, has_error: bool): 173 | kwargs = dict() 174 | 175 | if has_error: 176 | kwargs["tags"] = ("error",) 177 | else: 178 | kwargs["tags"] = ("",) 179 | 180 | self.item(row_id, **kwargs) 181 | 182 | def error_icon_created(self, icon, tooltip): 183 | pass 184 | 185 | def add_row(self, values=None, index=None): 186 | if not values: 187 | values = [*self._loader.get_default_row().values()] 188 | super().add_row(values, index) 189 | 190 | def set_data(self, rules_raw: list[JsonDict]): 191 | self._loader.set_data(rules_raw) 192 | 193 | def get_data(self) -> list[JsonDict]: 194 | return self._loader.get_data() 195 | 196 | def has_changes(self) -> bool: 197 | return self._loader.has_changes() 198 | 199 | def commit_changes(self): 200 | self._loader.commit_changes() 201 | 202 | def as_dict_of_models(self) -> dict[str, BaseModel]: 203 | return self._loader.as_dict_of_models(False) 204 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/rules/rules_list_actions.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk, DISABLED 2 | 3 | from constants.resources import UI_ADD, UI_DELETE, UI_UP, UI_DOWN 4 | from constants.ui import ActionEvents, LEFT_PACK 5 | from ui.widget.common.button import ExtendedButton 6 | from util.ui import load_img 7 | 8 | 9 | class RulesListActions(ttk.Frame): 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | self._setup_btn() 13 | 14 | def _setup_btn(self): 15 | self.add = add = ExtendedButton( 16 | self, 17 | text="Add", 18 | event=ActionEvents.ADD, 19 | image=load_img(file=UI_ADD), 20 | description="**Adds** a rule before the current. \n**Hotkey:** __Ctrl+D__." 21 | ) 22 | 23 | self.delete = delete = ExtendedButton( 24 | self, 25 | text="Del", 26 | state=DISABLED, 27 | event=ActionEvents.DELETE, 28 | image=load_img(file=UI_DELETE), 29 | description="**Deletes** the selected rules. \n**Hotkey:** __Del__." 30 | ) 31 | 32 | self.move_up = move_up = ExtendedButton( 33 | self, 34 | text="Up", 35 | state=DISABLED, 36 | event=ActionEvents.UP, 37 | image=load_img(file=UI_UP), 38 | description="**Moves** the current rule __up__." 39 | ) 40 | 41 | self.move_down = move_down = ExtendedButton( 42 | self, 43 | text="Down", 44 | state=DISABLED, 45 | event=ActionEvents.DOWN, 46 | image=load_img(file=UI_DOWN), 47 | description="**Moves** the current rule __down__." 48 | ) 49 | 50 | add.pack(**LEFT_PACK) 51 | delete.pack(**LEFT_PACK) 52 | move_up.pack(**LEFT_PACK) 53 | move_down.pack(**LEFT_PACK) 54 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tabs/rules/rules_tabs.py: -------------------------------------------------------------------------------- 1 | from tkinter import PhotoImage 2 | from tkinter.ttk import Notebook 3 | 4 | from constants.resources import UI_PROCESS_RULES, UI_SERVICE_RULES 5 | from enums.rules import RuleType 6 | from ui.widget.settings.tabs.rules.base_rules_tab import BaseRulesTab 7 | from util.ui import load_img 8 | 9 | 10 | class ProcessRulesTab(BaseRulesTab): 11 | def __init__(self, master: Notebook): 12 | super().__init__(master, RuleType.PROCESS) 13 | 14 | @staticmethod 15 | def icon() -> PhotoImage: 16 | return load_img(file=UI_PROCESS_RULES) 17 | 18 | @staticmethod 19 | def title() -> str: 20 | return "Process Rules" 21 | 22 | @staticmethod 23 | def description() -> str: 24 | return "Settings for defining **priority**, **I/O priority**, and **core affinity** for __processes__." 25 | 26 | 27 | class ServiceRulesTab(BaseRulesTab): 28 | def __init__(self, master: Notebook): 29 | super().__init__(master, RuleType.SERVICE) 30 | 31 | @staticmethod 32 | def icon() -> PhotoImage: 33 | return load_img(file=UI_SERVICE_RULES) 34 | 35 | @staticmethod 36 | def title() -> str: 37 | return "Service Rules" 38 | 39 | @staticmethod 40 | def description() -> str: 41 | return "Settings for defining **priority**, **I/O priority**, and **core affinity** for __services__." 42 | -------------------------------------------------------------------------------- /src/ui/widget/settings/tooltip.py: -------------------------------------------------------------------------------- 1 | from tkinter import ttk, StringVar, LEFT, Y, BOTH 2 | 3 | from constants.resources import UI_INFO_TOOLTIP, UI_WARN_TOOLTIP 4 | from constants.ui import UI_PADDING 5 | from ui.widget.common.label import RichLabel, Image 6 | from util.ui import load_img 7 | 8 | 9 | class Tooltip(ttk.Frame): 10 | def __init__(self, master, *args, **kwargs): 11 | text = kwargs.pop("text", "") 12 | 13 | super().__init__(master, *args, **kwargs) 14 | self._text = StringVar(self, value=text) 15 | 16 | self._image = Image( 17 | self, 18 | image=load_img(file=UI_INFO_TOOLTIP) 19 | ) 20 | self._image.pack(side=LEFT, fill=Y, padx=(0, UI_PADDING)) 21 | 22 | label = RichLabel(self, height=5.25, textvariable=self._text) 23 | label.pack(expand=True, fill=BOTH) 24 | label.pack_propagate(False) 25 | 26 | def set(self, text: str, error: bool = False): 27 | if error: 28 | self._image.configure(image=load_img(file=UI_WARN_TOOLTIP)) 29 | else: 30 | self._image.configure(image=load_img(file=UI_INFO_TOOLTIP)) 31 | self._text.set(text) 32 | -------------------------------------------------------------------------------- /src/util/cpu.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Optional 3 | 4 | from psutil import cpu_count 5 | 6 | 7 | @lru_cache 8 | def parse_affinity(in_affinity: str) -> list[int]: 9 | """ 10 | Parse a CPU core affinity string and return a list of core numbers. 11 | 12 | Args: 13 | in_affinity (str): The CPU core affinity string to parse. 14 | 15 | Returns: 16 | Optional[list[int]]: A list of CPU core numbers specified in the affinity string. 17 | """ 18 | if in_affinity is None: 19 | raise ValueError("empty value") 20 | 21 | affinity = in_affinity.strip() 22 | 23 | if not affinity: 24 | raise ValueError("empty string") 25 | 26 | affinity = affinity.split(";") 27 | cores: list[int] = [] 28 | 29 | try: 30 | for el in affinity: 31 | el = list(map(str.strip, el.split('-'))) 32 | 33 | if len(el) == 2: 34 | cores.extend(range(int(el[0]), int(el[1]) + 1)) 35 | elif len(el) == 1: 36 | cores.append(int(el[0])) 37 | else: 38 | raise ValueError("incorrect format") 39 | except Exception: 40 | raise ValueError("invalid format. Use range `0-3`, specific cores `0;2;4`, or combination `1;3-5`") 41 | 42 | _check_max_cpu_index(cores) 43 | return cores 44 | 45 | 46 | def format_affinity(cores: list[int]) -> Optional[str]: 47 | """ 48 | Format a list of CPU core numbers into an affinity string. 49 | 50 | Args: 51 | cores (list[int]): A list of CPU core numbers. 52 | 53 | Returns: 54 | Optional[str]: An affinity string. 55 | """ 56 | if cores is None or not cores: 57 | return None 58 | 59 | # Sort and remove duplicates 60 | sorted_cores = sorted(set(cores)) 61 | 62 | # Group consecutive numbers 63 | groups = [] 64 | group = [sorted_cores[0]] 65 | 66 | for core in sorted_cores[1:]: 67 | if core == group[-1] + 1: 68 | group.append(core) 69 | else: 70 | groups.append(group) 71 | group = [core] 72 | groups.append(group) 73 | 74 | # Format groups into string 75 | affinity_str = [] 76 | 77 | for group in groups: 78 | if len(group) == 1: 79 | affinity_str.append(str(group[0])) 80 | else: 81 | affinity_str.append(f"{group[0]}-{group[-1]}") 82 | 83 | result = ";".join(affinity_str) 84 | 85 | _check_max_cpu_index(cores) 86 | return result 87 | 88 | 89 | def _check_max_cpu_index(cores): 90 | available_cores = cpu_count() 91 | 92 | if max(cores) >= available_cores: 93 | raise ValueError( 94 | "core count exceeds available CPU cores. " 95 | f"Maximum available core index is {available_cores - 1}" 96 | ) 97 | 98 | 99 | if __name__ == '__main__': 100 | input = "1 ; 3- 5" 101 | lst = parse_affinity("1;3-5") 102 | fmt = format_affinity(lst) 103 | 104 | print(input, lst, fmt) 105 | 106 | example_inputs = [[], [0], [1, 2, 3], [0, 2, 4], [1, 3, 4, 5], None] 107 | for example_cores in example_inputs: 108 | try: 109 | print(format_affinity(example_cores)) 110 | except Exception as e: 111 | print(e) 112 | -------------------------------------------------------------------------------- /src/util/decorators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import suppress 3 | from functools import wraps 4 | from time import time 5 | from typing import Callable, Optional, TypeVar 6 | 7 | T = TypeVar('T') 8 | 9 | 10 | def cached(timeout_in_seconds, logged=False) -> Callable[..., T]: 11 | """ 12 | Decorator that caches the results of a function for a specified timeout. 13 | 14 | Args: 15 | timeout_in_seconds (int): The cache timeout duration in seconds. 16 | logged (bool, optional): Whether to log cache initialization and hits (default is False). 17 | 18 | Returns: 19 | Callable: A decorated function with caching capabilities. 20 | """ 21 | 22 | def decorator(function: Callable[..., T]) -> Callable[..., T]: 23 | if logged: 24 | logging.info("-- Initializing cache for", function.__name__) 25 | 26 | cache = {} 27 | 28 | @wraps(function) 29 | def decorated_function(*args, **kwargs) -> T: 30 | if logged: 31 | logging.info("-- Called function", function.__name__) 32 | 33 | key = args, frozenset(kwargs.items()) 34 | result: Optional[tuple[T]] = None 35 | 36 | if key in cache: 37 | if logged: 38 | logging.info("-- Cache hit for", function.__name__, key) 39 | 40 | cache_hit, expiry = cache[key] 41 | 42 | if time() - expiry < timeout_in_seconds: 43 | result = cache_hit 44 | elif logged: 45 | logging.info("-- Cache expired for", function.__name__, key) 46 | elif logged: 47 | logging.info("-- Cache miss for", function.__name__, key) 48 | 49 | if result is None: 50 | result = (function(*args, **kwargs),) 51 | cache[key] = result, time() 52 | 53 | return result[0] 54 | 55 | return decorated_function 56 | 57 | return decorator 58 | 59 | 60 | def suppress_exception(function: Callable[..., T], exceptions: tuple = (BaseException,), 61 | default_value_function: Callable[[], T] = lambda: None) -> Callable[..., T]: 62 | """ 63 | Decorator that suppresses specified exceptions raised by a function. 64 | 65 | Args: 66 | function (Callable): The function to decorate. 67 | *exceptions (tuple, default: (BaseException,)): Variable number of exception types to suppress. 68 | default_value_function (Callable[[], T], default: lambda: None): Function that returns the default value. 69 | 70 | Returns: 71 | Callable: A decorated function that suppresses the specified exceptions. 72 | """ 73 | if getattr(function, '__suppressed__', False): 74 | return function 75 | 76 | @wraps(function) 77 | def wrapper(*args, **kwargs) -> Callable[..., T]: 78 | with suppress(*exceptions): 79 | return function(*args, **kwargs) 80 | return default_value_function() 81 | 82 | wrapper.__suppressed__ = True 83 | 84 | return wrapper 85 | -------------------------------------------------------------------------------- /src/util/files.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import os 3 | import subprocess 4 | 5 | import win32com.client 6 | 7 | from constants.files import LOG_FILE_NAME, CONFIG_FILE_NAME 8 | from util.messages import show_error 9 | 10 | 11 | def open_log_file(): 12 | return os.startfile(LOG_FILE_NAME) 13 | 14 | 15 | def open_config_file(): 16 | os.startfile(CONFIG_FILE_NAME) 17 | 18 | 19 | FILEBROWSER_PATH = os.path.join(os.getenv('WINDIR'), 'explorer.exe') 20 | SHELL32 = ctypes.WinDLL('shell32', use_last_error=True) 21 | 22 | 23 | def explore(path): 24 | path = os.path.normpath(path) 25 | 26 | if os.path.isdir(path): 27 | subprocess.run([FILEBROWSER_PATH, path]) 28 | elif os.path.isfile(path): 29 | subprocess.run([FILEBROWSER_PATH, '/select,', path]) 30 | 31 | 32 | def show_file_properties(filepath, hwnd=None): 33 | filepath = os.path.abspath(filepath) 34 | 35 | if not os.path.exists(filepath): 36 | raise FileNotFoundError(f"File '{filepath}' does not exist") 37 | 38 | SHELL32.SHObjectProperties(hwnd, 0x00000002, filepath, None) 39 | 40 | 41 | __mmc = None 42 | 43 | 44 | def show_service_properties(service_name): 45 | global __mmc 46 | 47 | try: 48 | __mmc.Document 49 | except: 50 | __mmc = None 51 | 52 | if __mmc is None: 53 | __mmc = win32com.client.Dispatch("MMC20.Application") 54 | __mmc.Load("services.msc") 55 | 56 | doc = __mmc.Document 57 | view = doc.ActiveView 58 | items = view.ListItems 59 | 60 | for item in items: 61 | if item.Name == service_name: 62 | __mmc.UserControl = True 63 | view.Select(item) 64 | view.DisplaySelectionPropertySheet() 65 | break 66 | else: 67 | __mmc.Quit() 68 | show_error(f"The properties window for the '{service_name}' service " 69 | f"could not be opened, because the service was not found.") 70 | -------------------------------------------------------------------------------- /src/util/history.py: -------------------------------------------------------------------------------- 1 | class HistoryManager: 2 | def __init__(self, getter, setter, max_depth=20): 3 | self.getter = getter 4 | self.setter = setter 5 | self.history = [] 6 | self.redo_stack = [] 7 | self.max_depth = max_depth 8 | self.commit() 9 | 10 | def commit(self): 11 | self.add(self.getter()) 12 | 13 | def undo(self): 14 | if len(self.history) > 0: 15 | self.redo_stack.append(self.getter()) 16 | self.setter(self.history.pop()) 17 | 18 | def redo(self): 19 | if self.redo_stack: 20 | self.history.append(self.getter()) 21 | self.setter(self.redo_stack.pop()) 22 | 23 | def clear(self): 24 | self.history.clear() 25 | self.redo_stack.clear() 26 | 27 | def add(self, value): 28 | if not self.history or self.history[-1] != value: 29 | if len(self.history) >= self.max_depth: 30 | self.history.pop(0) 31 | self.history.append(value) 32 | self.redo_stack.clear() 33 | -------------------------------------------------------------------------------- /src/util/lock_instance.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import psutil 5 | 6 | from constants.app_info import APP_PROCESSES 7 | from constants.files import LOCK_FILE_NAME 8 | from constants.log import LOG 9 | from util.messages import show_error 10 | 11 | 12 | def is_process_running(pid): 13 | """ 14 | Check if a process with the given PID is running. 15 | 16 | Args: 17 | pid: The process ID (PID) to check. 18 | 19 | Returns: 20 | bool: True if the process is running, False otherwise. 21 | """ 22 | try: 23 | process = psutil.Process(pid) 24 | process_name = process.name() 25 | return process.is_running() and process_name in APP_PROCESSES 26 | except psutil.NoSuchProcess: 27 | return False 28 | 29 | 30 | def create_lock_file(): 31 | """ 32 | Create a lock file to prevent multiple instances of a process from running simultaneously. 33 | 34 | If the lock file already exists, it checks if the process that created it is still running. If it is, 35 | the current process exits to avoid running multiple instances. 36 | 37 | If the lock file does not exist or the process is no longer running, it creates the lock file with the 38 | current process's PID. 39 | """ 40 | 41 | if os.path.isfile(LOCK_FILE_NAME): 42 | # Check if the process that created the lock file is still running 43 | with open(LOCK_FILE_NAME, "r") as file: 44 | pid_str = file.read().strip() 45 | if pid_str: 46 | if is_process_running(int(pid_str)): 47 | message = ("The application is already running.\n" 48 | "Please close the existing instance before starting a new one.") 49 | 50 | LOG.error(message) 51 | show_error(message) 52 | sys.exit(1) 53 | 54 | # Create the lock file with the current process's PID 55 | with open(LOCK_FILE_NAME, "w") as file: 56 | file.write(str(os.getpid())) 57 | 58 | 59 | def remove_lock_file(): 60 | """ 61 | Remove the lock file used to prevent multiple instances of the application. 62 | 63 | This function deletes the lock file created to ensure that only one instance of the application is running. 64 | """ 65 | os.remove(LOCK_FILE_NAME) 66 | -------------------------------------------------------------------------------- /src/util/messages.py: -------------------------------------------------------------------------------- 1 | from win32api import MessageBoxEx 2 | 3 | from constants.app_info import APP_NAME_WITH_VERSION, TITLE_ERROR, APP_TITLE 4 | from enums.messages import MBIcon, MBButton, MBResult 5 | 6 | 7 | def _message_box(title: str, message: str, icon: MBIcon, btn: MBButton) -> MBResult: 8 | """ 9 | Display a message box with the specified title, message, icon, and button. 10 | 11 | Args: 12 | title (str): The title of the message box. 13 | message (str): The message to be displayed in the message box. 14 | icon (MBIcon): The icon to be displayed in the message box. 15 | btn (MBButton): The button(s) to be displayed in the message box. 16 | 17 | Returns: 18 | MBResult: The result of the message box operation. 19 | """ 20 | return MessageBoxEx(None, message, title, icon | btn) 21 | 22 | 23 | def show_info(message: str, title: str = APP_TITLE): 24 | _message_box(title, message, MBIcon.INFORMATION, MBButton.OK) 25 | 26 | 27 | def yesno_info_box(message: str, title: str = APP_TITLE) -> bool: 28 | return _message_box(title, message, MBIcon.INFORMATION, MBButton.YESNO) == MBResult.YES 29 | 30 | 31 | def yesno_question_box(message: str, title: str = APP_TITLE) -> bool: 32 | return _message_box(title, message, MBIcon.QUESTION, MBButton.YESNO) == MBResult.YES 33 | 34 | 35 | def show_error(message: str, title: str = TITLE_ERROR): 36 | _message_box(title, message, MBIcon.ERROR, MBButton.OK) 37 | 38 | 39 | def yesno_error_box(message: str, title: str = TITLE_ERROR) -> bool: 40 | return _message_box(title, message, MBIcon.ERROR, MBButton.YESNO) == MBResult.YES 41 | -------------------------------------------------------------------------------- /src/util/scheduler.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | class TaskScheduler: 5 | _tasks = {} 6 | 7 | @classmethod 8 | def _execute_task(cls, key, callback, *args, **kwargs): 9 | try: 10 | callback(*args, **kwargs) 11 | finally: 12 | del cls._tasks[key] 13 | 14 | @classmethod 15 | def schedule_task(cls, key, callback, *args, delay=0, **kwargs): 16 | if key not in cls._tasks: 17 | if delay: 18 | cls._tasks[key] = threading.Timer(delay, cls._execute_task, args=(key, callback) + args, kwargs=kwargs) 19 | else: 20 | cls._tasks[key] = threading.Thread(target=cls._execute_task, args=(key, callback) + args, kwargs=kwargs) 21 | 22 | cls._tasks[key].start() 23 | 24 | @classmethod 25 | def check_task(cls, key) -> bool: 26 | return key in cls._tasks 27 | 28 | 29 | if __name__ == '__main__': 30 | def my_function(message): 31 | print(f"Function executed with message: {message}") 32 | 33 | 34 | TaskScheduler.schedule_task("task1", my_function, "Hello after 5 seconds", delay=5) 35 | TaskScheduler.schedule_task("task1", my_function, "Hello after 2 seconds", delay=2) 36 | TaskScheduler.schedule_task("task2", my_function, "Hello after 3 seconds", delay=3) 37 | TaskScheduler.schedule_task("task3", my_function, "Immediate execution", delay=0) 38 | 39 | import time 40 | 41 | time.sleep(10) 42 | -------------------------------------------------------------------------------- /src/util/startup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from constants.app_info import STARTUP_TASK_NAME 4 | from constants.resources import STARTUP_SCRIPT 5 | from util.utils import is_portable 6 | from util.windows_scheduler import WindowsTaskScheduler 7 | 8 | 9 | def is_in_startup(): 10 | """ 11 | Check if the current application is set to run during system startup. 12 | 13 | Returns: 14 | bool: True if the application is set to run during system startup, False otherwise. 15 | """ 16 | return WindowsTaskScheduler.check_task(STARTUP_TASK_NAME) 17 | 18 | 19 | def add_to_startup(): 20 | """ 21 | Add the current application to the system's startup programs. 22 | """ 23 | if is_in_startup(): 24 | return 25 | 26 | WindowsTaskScheduler.create_startup_task( 27 | STARTUP_TASK_NAME, 28 | f"'{STARTUP_SCRIPT}' '{sys.executable}'" 29 | ) 30 | 31 | 32 | def remove_from_startup(): 33 | """ 34 | Remove the current application from the system's startup programs. 35 | """ 36 | if is_in_startup(): 37 | WindowsTaskScheduler.delete_task(STARTUP_TASK_NAME) 38 | 39 | 40 | def toggle_startup(): 41 | """ 42 | Toggle the startup status of the application. 43 | """ 44 | if is_in_startup(): 45 | remove_from_startup() 46 | else: 47 | add_to_startup() 48 | 49 | 50 | def update_startup(): 51 | """ 52 | Update autostart if the app has been moved. 53 | """ 54 | if not is_portable(): 55 | return 56 | 57 | if not is_in_startup(): 58 | return 59 | 60 | remove_from_startup() 61 | add_to_startup() 62 | -------------------------------------------------------------------------------- /src/util/ui.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | import tkinter 3 | from tkinter import font, Widget, Entry, PhotoImage 4 | from tkinter.font import Font 5 | from tkinter.ttk import Treeview 6 | 7 | from constants.ui import TRIM_LENGTH_OF_ITEM_IN_CONTEXT_MENU 8 | 9 | 10 | def state(widget: Widget) -> str: 11 | return str(widget["state"]) 12 | 13 | 14 | def full_visible_bbox(tree: Treeview, row_id: str, column_id: str): 15 | bbox = tree.bbox(row_id, column_id) 16 | 17 | if bbox: 18 | x, y, width, height = bbox 19 | y_bottom = y + height 20 | tree_height = tree.winfo_height() 21 | 22 | if y_bottom <= tree_height: 23 | return bbox 24 | 25 | return None 26 | 27 | 28 | def get_parent_with_bg(widget: Widget): 29 | while widget: 30 | cfg = widget.configure() 31 | 32 | if cfg and "bg" in cfg: 33 | return widget 34 | 35 | widget = widget.master 36 | return None 37 | 38 | 39 | def get_label_font(): 40 | temp_widget = tkinter.Label() 41 | default_font = font.nametofont(temp_widget.cget("font")) 42 | temp_widget.destroy() 43 | return default_font 44 | 45 | 46 | def get_default_font(): 47 | return Font(family="TkDefaultFont") 48 | 49 | 50 | def single_font_width(): 51 | return get_default_font().measure('0') 52 | 53 | 54 | def get_button_font(): 55 | temp_widget = tkinter.Button() 56 | default_font = font.nametofont(temp_widget.cget("font")) 57 | temp_widget.destroy() 58 | return default_font 59 | 60 | 61 | def get_combobox_font(): 62 | temp_widget = tkinter.Button() 63 | default_font = font.nametofont(temp_widget.cget("font")) 64 | temp_widget.destroy() 65 | return default_font 66 | 67 | 68 | def get_entry_font(): 69 | temp_entry = Entry() 70 | default_font = temp_entry.cget("font") 71 | temp_entry.destroy() 72 | return default_font 73 | 74 | 75 | def load_img(file) -> PhotoImage: 76 | return PhotoImage(file=file) 77 | 78 | 79 | def trim_cmenu_label(text: str) -> str: 80 | return textwrap.shorten(text, width=TRIM_LENGTH_OF_ITEM_IN_CONTEXT_MENU, placeholder="...") 81 | -------------------------------------------------------------------------------- /src/util/updates.py: -------------------------------------------------------------------------------- 1 | import json 2 | import webbrowser 3 | from typing import Optional, Union 4 | from urllib import request 5 | 6 | from constants.app_info import CURRENT_TAG, APP_NAME 7 | from constants.updates import API_UPDATE_URL, UPDATE_URL 8 | from util.messages import show_error, show_info, yesno_info_box 9 | from util.utils import compare_version 10 | 11 | 12 | def check_new_version() -> Optional[Union[str, bool]]: 13 | """ 14 | Check the latest version by making a request to the update URL and comparing it with the current tag. 15 | 16 | Returns: 17 | Optional[Union[str, False]]: The latest tag if it is greater than the current tag, False otherwise. None if an exception occurs. 18 | """ 19 | try: 20 | with request.urlopen(API_UPDATE_URL) as response: 21 | data = json.loads(response.read().decode()) 22 | latest_tag = data['tag_name'] 23 | 24 | if compare_version(latest_tag, CURRENT_TAG) > 0: 25 | return latest_tag 26 | else: 27 | return False 28 | except: 29 | return None 30 | 31 | 32 | def check_updates(silent: bool = False): 33 | new_version = check_new_version() 34 | 35 | if new_version is None: 36 | if not silent: 37 | show_error("Failed to check for updates. Please check your internet connection.") 38 | elif not new_version: 39 | if not silent: 40 | show_info("You are using the latest version.") 41 | else: 42 | message = f"A new version {new_version} is available. Would you like to update {APP_NAME} now?" 43 | 44 | if yesno_info_box(message): 45 | webbrowser.open(UPDATE_URL, new=0, autoraise=True) 46 | -------------------------------------------------------------------------------- /src/util/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from enum import Enum 4 | from functools import lru_cache, cache 5 | from re import Pattern 6 | from types import NoneType 7 | from typing import get_origin, get_args, Union, Annotated, Optional 8 | 9 | import win32api 10 | import win32con 11 | import win32gui 12 | import win32ui 13 | from PIL import Image 14 | 15 | 16 | @cache 17 | def path_pattern_to_regex(pattern: str) -> Optional[Pattern]: 18 | """ 19 | Converts a glob-like path pattern to a regular expression, with partial support for glob syntax. 20 | 21 | Args: 22 | pattern (str): The path pattern to convert. 23 | 24 | Returns: 25 | re.Pattern: The compiled regular expression. 26 | 27 | Supports: 28 | - "*" matches any sequence of characters except the path separator. 29 | - "?" matches any single character except the path separator. 30 | - "**/" matches any number of nested directories. 31 | """ 32 | 33 | pattern = pattern.strip() 34 | 35 | if not pattern: 36 | return None 37 | 38 | pattern = re.escape(pattern.replace('\\', '/')) 39 | pattern = pattern.replace(r'/', '[/]') 40 | pattern = pattern.replace('\\*\\*[/]', '(.*[/])?') 41 | pattern = pattern.replace('\\?', '[^/]') 42 | pattern = pattern.replace('\\*', '[^/]*') 43 | pattern = pattern.replace('/', r'\\/') 44 | 45 | return re.compile(f"^{pattern}$", re.IGNORECASE) 46 | 47 | 48 | @lru_cache 49 | def path_match(pattern: str, value: str) -> bool: 50 | """ 51 | Checks if any of the provided values match the given pattern. 52 | 53 | Args: 54 | pattern (str): The pattern to match against, supporting wildcards. 55 | value (str): The value to test against the pattern. 56 | 57 | Returns: 58 | bool: True if any value matches the pattern, False otherwise. 59 | """ 60 | 61 | if not pattern: 62 | return False 63 | 64 | if pattern == value: 65 | return True 66 | 67 | regex = path_pattern_to_regex(pattern) 68 | 69 | if not pattern: 70 | return False 71 | 72 | return regex.match(value) is not None 73 | 74 | 75 | def is_portable(): 76 | """ 77 | Check if the script is running in a portable environment. 78 | """ 79 | return getattr(sys, 'frozen', False) 80 | 81 | 82 | def compare_version(version1, version2): 83 | """ 84 | Compare two version numbers. 85 | 86 | Parameters: 87 | version1 (str): The first version number. 88 | version2 (str): The second version number. 89 | 90 | Returns: 91 | int: 1 if version1 is greater than version2, -1 if version1 is less than version2, 0 if they are equal. 92 | """ 93 | version1 = version1.lstrip('v') 94 | version2 = version2.lstrip('v') 95 | 96 | versions1 = [int(v) for v in version1.split(".")] 97 | versions2 = [int(v) for v in version2.split(".")] 98 | 99 | for i in range(max(len(versions1), len(versions2))): 100 | v1 = versions1[i] if i < len(versions1) else 0 101 | v2 = versions2[i] if i < len(versions2) else 0 102 | 103 | if v1 > v2: 104 | return 1 105 | elif v1 < v2: 106 | return -1 107 | 108 | return 0 109 | 110 | 111 | def extract_type(annotation): 112 | origin = get_origin(annotation) 113 | 114 | if origin is None: 115 | return annotation 116 | 117 | args = get_args(annotation) 118 | 119 | if origin == Union: 120 | non_none_args = [arg for arg in args if arg != NoneType] 121 | if len(non_none_args) == 1: 122 | return extract_type(non_none_args[0]) 123 | else: 124 | return [extract_type(arg) for arg in non_none_args] 125 | 126 | elif origin == Annotated: 127 | return extract_type(args[0]) 128 | 129 | elif args: 130 | return origin[tuple(extract_type(arg) for arg in args)] 131 | 132 | return annotation 133 | 134 | 135 | def is_optional_type(annotation): 136 | if get_origin(annotation) == Union: 137 | union_args = get_args(annotation) 138 | for arg in union_args: 139 | if arg == NoneType or is_optional_type(arg): 140 | return True 141 | return False 142 | 143 | 144 | def none_int(value: str) -> Optional[int]: 145 | return int(value) if value else None 146 | 147 | 148 | def get_values_from_enum(annotation): 149 | origin_type = extract_type(annotation) 150 | values = [] 151 | 152 | if not issubclass(origin_type, Enum): 153 | return values 154 | 155 | if is_optional_type(annotation): 156 | values.append('') 157 | 158 | for e in origin_type: 159 | values.append(str(e.value)) 160 | 161 | return values 162 | 163 | 164 | @cache 165 | def get_icon_from_exe(exe_path, icon_index=0, large=False): 166 | if large: 167 | icon_size = ( 168 | win32api.GetSystemMetrics(win32con.SM_CXICON), 169 | win32api.GetSystemMetrics(win32con.SM_CYICON) 170 | ) 171 | else: 172 | icon_size = ( 173 | win32api.GetSystemMetrics(win32con.SM_CXSMICON), 174 | win32api.GetSystemMetrics(win32con.SM_CYSMICON) 175 | ) 176 | 177 | hdc = None 178 | hdc_mem = None 179 | bmp = None 180 | list_of_large_hicon = [] 181 | list_of_small_hicon = [] 182 | 183 | try: 184 | list_of_large_hicon, list_of_small_hicon = win32gui.ExtractIconEx(exe_path, icon_index) 185 | list_of_hicon = list_of_large_hicon if large else list_of_small_hicon 186 | 187 | if not list_of_hicon: 188 | return 189 | 190 | hdc = win32ui.CreateDCFromHandle(win32gui.GetDC(0)) 191 | hdc_mem = hdc.CreateCompatibleDC() 192 | 193 | bmp = win32ui.CreateBitmap() 194 | bmp.CreateCompatibleBitmap(hdc, icon_size[0], icon_size[1]) 195 | 196 | hdc_mem.SelectObject(bmp) 197 | 198 | win32gui.DrawIconEx( 199 | hdc_mem.GetSafeHdc(), 200 | 0, 201 | 0, 202 | list_of_hicon[0], 203 | icon_size[0], 204 | icon_size[1], 205 | 0, 206 | None, 207 | win32con.DI_NORMAL 208 | ) 209 | 210 | bmp_info = bmp.GetInfo() 211 | bmp_bits = bmp.GetBitmapBits(True) 212 | 213 | return Image.frombuffer( 214 | 'RGBA', 215 | (bmp_info['bmWidth'], bmp_info['bmHeight']), 216 | bmp_bits, 'raw', 'BGRA', 0, 1 217 | ) 218 | finally: 219 | if bmp: 220 | handle = bmp.GetHandle() 221 | 222 | if handle: 223 | win32gui.DeleteObject(handle) 224 | 225 | if hdc_mem: 226 | hdc_mem.DeleteDC() 227 | 228 | if hdc: 229 | hdc.DeleteDC() 230 | win32gui.ReleaseDC(0, hdc.GetSafeHdc()) 231 | 232 | for hicon in list_of_large_hicon: 233 | win32gui.DestroyIcon(hicon) 234 | 235 | for hicon in list_of_small_hicon: 236 | win32gui.DestroyIcon(hicon) 237 | -------------------------------------------------------------------------------- /src/util/windows_scheduler.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from constants.log import LOG 4 | 5 | 6 | class WindowsTaskScheduler: 7 | """ 8 | A class to manage tasks in Windows Task Scheduler using the schtasks utility. 9 | """ 10 | 11 | @staticmethod 12 | def create_startup_task(task_name, exe_path): 13 | """ 14 | Creates a startup task in the Task Scheduler. 15 | 16 | Parameters: 17 | task_name (str): The name of the task to be created. 18 | exe_path (str): The path of the executable to be run as the startup task. 19 | """ 20 | command = f"schtasks /create /tn \"{task_name}\" /tr \"{exe_path}\" /sc onlogon /rl highest" 21 | 22 | try: 23 | subprocess.run(command, check=True, shell=True) 24 | LOG.info(f"Task '{task_name}' created successfully.") 25 | except subprocess.CalledProcessError: 26 | LOG.exception(f"Error creating task '{task_name}'. Command: {command}") 27 | raise 28 | 29 | @staticmethod 30 | def check_task(task_name): 31 | """ 32 | Checks for the existence of a task in the Task Scheduler. 33 | 34 | Parameters: 35 | task_name (str): Name of the task. 36 | 37 | Returns: 38 | bool: True if the task exists, False otherwise. 39 | """ 40 | command = f"schtasks /query /tn \"{task_name}\"" 41 | 42 | try: 43 | result = subprocess.run(command, shell=True, capture_output=True, text=True) 44 | return task_name in result.stdout 45 | except subprocess.CalledProcessError: 46 | LOG.exception(f"Error checking task '{task_name}'. Command: {command}") 47 | raise 48 | 49 | @staticmethod 50 | def delete_task(task_name): 51 | """ 52 | Deletes a task from the Task Scheduler. 53 | 54 | Parameters: 55 | task_name (str): Name of the task. 56 | """ 57 | command = f"schtasks /delete /tn \"{task_name}\" /f" 58 | 59 | try: 60 | subprocess.run(command, check=True, shell=True) 61 | LOG.info(f"Task '{task_name}' deleted successfully.") 62 | except subprocess.CalledProcessError: 63 | LOG.exception(f"Error deleting task '{task_name}'. Command: {command}") 64 | raise 65 | --------------------------------------------------------------------------------