├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── debug.yml │ └── release.yml ├── .gitignore ├── .isort.cfg ├── .pylintrc ├── .vscode └── settings.json ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── build.ps1 ├── format.ps1 ├── lint.ps1 ├── screenshots ├── auto-detect.png ├── enable.gif ├── everything-search.png ├── install.gif ├── main-theme.png ├── main.png ├── manual-select.png ├── mod-info.gif └── search.gif ├── scripts ├── README.md ├── files.txt.example └── flap_lift_fix.py └── src ├── build └── settings │ └── base.json └── main ├── icons ├── Icon.ico ├── README.md └── base │ ├── 16.png │ ├── 24.png │ ├── 32.png │ ├── 48.png │ └── 64.png ├── python ├── dialogs │ ├── error_dialogs.py │ ├── information_dialogs.py │ ├── question_dialogs.py │ ├── version_check_dialog.py │ └── warning_dialogs.py ├── lib │ ├── config.py │ ├── files.py │ ├── flight_sim.py │ ├── resize.py │ ├── theme.py │ ├── thread.py │ ├── type_helper.py │ └── version.py ├── main.py └── widgets │ ├── about_widget.py │ ├── base_table.py │ ├── files_table.py │ ├── info_widget.py │ ├── main_table.py │ ├── main_widget.py │ ├── main_window.py │ ├── progress_widget.py │ └── versions_widget.py └── resources └── base ├── fs_style.qss └── icons ├── icon.ico └── icon.png /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | 4 | ## Actual Behavior 5 | 6 | 7 | ## Steps to Reproduce the Problem 8 | 9 | 1. 10 | 2. 11 | 3. 12 | 13 | ## Specifications 14 | 15 | - Application Version: `v0.0.0` 16 | - MSFS Edition: (Steam, MS Store, Boxed) 17 | 18 | ## Debug Log Contents (only if reporting a bug) 19 | 20 | This file is found at `%APPDATA%\MSFS Mod Manager\debug.log` 21 | 22 | (Everything after the last `__main__::27 - -----------------------` line) 23 | 24 | ``` 25 | debug log 26 | ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | target-branch: master 9 | - package-ecosystem: pip 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | open-pull-requests-limit: 10 14 | target-branch: master -------------------------------------------------------------------------------- /.github/workflows/debug.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | name: Create Debug Build 5 | 6 | jobs: 7 | build: 8 | runs-on: windows-latest 9 | steps: 10 | - name: Checkout Code 11 | uses: actions/checkout@v3 12 | 13 | - name: Setup Python 3.6 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.6 17 | 18 | - name: Cache Pip 19 | uses: actions/cache@v3 20 | id: cache-pip 21 | with: 22 | path: ~\AppData\Local\pip\Cache 23 | key: pip-${{ hashFiles('**/Pipfile.lock') }} 24 | 25 | - name: Install Pipenv 26 | run: python -m pip install wheel setuptools pipenv 27 | 28 | - name: Setup Dependencies 29 | run: pipenv sync 30 | env: 31 | PIPENV_VENV_IN_PROJECT: "1" 32 | 33 | - name: Build 34 | run: ./build.ps1 -installer -debug 35 | 36 | - name: Zip Build 37 | run: Compress-Archive -Path "./target/MSFS Mod Manager" -DestinationPath "./target/MSFSModManagerPortable" 38 | 39 | - name: Upload Portable 40 | uses: actions/upload-artifact@v3 41 | with: 42 | name: Portable Executable 43 | path: ./target/MSFSModManagerPortable.zip 44 | 45 | - name: Upload Installer 46 | uses: actions/upload-artifact@v3 47 | with: 48 | name: Installer 49 | path: ./target/MSFS Mod ManagerSetup.exe -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*" 5 | 6 | name: Publish Release 7 | 8 | jobs: 9 | build: 10 | runs-on: windows-latest 11 | steps: 12 | - name: Parse version 13 | id: get_version 14 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 15 | shell: bash 16 | 17 | - name: Checkout Code 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup Python 3.6 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: 3.6 24 | 25 | - name: Cache Pip 26 | uses: actions/cache@v3 27 | id: cache-pip 28 | with: 29 | path: ~\AppData\Local\pip\Cache 30 | key: pip-${{ hashFiles('**/Pipfile.lock') }} 31 | 32 | - name: Install Pipenv 33 | run: python -m pip install wheel setuptools pipenv 34 | 35 | - name: Setup Dependencies 36 | run: pipenv sync 37 | env: 38 | PIPENV_VENV_IN_PROJECT: "1" 39 | 40 | - name: Build 41 | run: ./build.ps1 -installer 42 | 43 | - name: Zip Build 44 | run: Compress-Archive -Path "./target/MSFS Mod Manager" -DestinationPath "./target/MSFSModManagerPortable" 45 | 46 | - name: Create Release 47 | id: create_release 48 | uses: actions/create-release@v1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | tag_name: ${{ github.ref }} 53 | release_name: ${{ github.ref }} 54 | body: ${{ github.event.commit_comment }} 55 | draft: false 56 | prerelease: false 57 | 58 | - name: Add Portable Executable Artifact 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ./target/MSFSModManagerPortable.zip 65 | asset_name: MSFSModManagerPortable${{ steps.get_version.outputs.VERSION }}.zip 66 | asset_content_type: application/zip 67 | 68 | - name: Add Installer Artifact 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./target/MSFS Mod ManagerSetup.exe 75 | asset_name: MSFSModManagerSetup${{ steps.get_version.outputs.VERSION }}.exe 76 | asset_content_type: application/vnd.microsoft.portable-executable 77 | 78 | # - name: Clean 79 | # run: pipenv run fbs clean 80 | 81 | # - name: Build Debug 82 | # run: ./build.ps1 -installer -debug 83 | 84 | # - name: Add Debug Installer Artifact 85 | # uses: actions/upload-release-asset@v1 86 | # env: 87 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | # with: 89 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 90 | # asset_path: ./target/MSFS Mod ManagerSetup.exe 91 | # asset_name: MSFSModManagerSetupDebug.exe 92 | # asset_content_type: application/vnd.microsoft.portable-executable 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | modCache/* 2 | .tmp/* 3 | .vscode/* 4 | build/* 5 | target/* 6 | dist/* 7 | __pycache__/* 8 | .venv/* 9 | pythonenv*/* 10 | 11 | *.pyc 12 | 13 | src/main/resources/base/base.json 14 | scripts/files.txt 15 | 16 | !.vscode/settings.json -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | src_paths=src/main/python,src/main/python/widgets,src/main/python/lib -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | extension-pkg-whitelist=PySide2.QtCore,PySide2.QtGui,PySide2.QtWidgets 3 | init-hook='import sys; sys.path.append("src/main/python")' 4 | 5 | [MESSAGES CONTROL] 6 | disable=invalid-name, 7 | too-few-public-methods, 8 | missing-module-docstring, 9 | broad-except, 10 | too-many-return-statements, 11 | no-else-raise, 12 | no-else-return, 13 | raise-missing-from, 14 | too-many-arguments, 15 | too-many-instance-attributes, 16 | missing-class-docstring, 17 | attribute-defined-outside-init, 18 | line-too-long, 19 | duplicate-code, 20 | unused-argument, 21 | blacklisted-name, 22 | cell-var-from-loop, 23 | inconsistent-return-statements, 24 | no-member, 25 | no-name-in-module -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // venv 3 | "python.terminal.activateEnvironment": false, 4 | // format/lint 5 | "python.formatting.provider": "black", 6 | "python.linting.enabled": true, 7 | "python.linting.pylintEnabled": true, 8 | "python.analysis.extraPaths": [ 9 | "src/main/python" 10 | ], 11 | "python.analysis.typeCheckingMode": "basic", 12 | // terminal 13 | "terminal.integrated.shellArgs.windows": [ 14 | "-NoExit", "-Command", "pipenv shell" 15 | ], 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | black = "*" 8 | bandit = "*" 9 | pylint = "*" 10 | autoflake = "*" 11 | isort = "*" 12 | 13 | [packages] 14 | patool = "*" 15 | pyside2 = "*" 16 | fbs = "*" 17 | loguru = "*" 18 | pywin32 = "*" 19 | pywin32-ctypes = "*" 20 | win32_setctime = "*" 21 | 22 | [requires] 23 | python_version = "3.6" 24 | 25 | [pipenv] 26 | allow_prereleases = true 27 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "617ec73ef673fcfc5cbaac9a3b2f23b256724aac80a865ef0644088d1a8a47db" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiocontextvars": { 20 | "hashes": [ 21 | "sha256:885daf8261818767d8f7cbd79f9d4482d118f024b6586ef6e67980236a27bfa3", 22 | "sha256:f027372dc48641f683c559f247bd84962becaacdc9ba711d583c3871fb5652aa" 23 | ], 24 | "markers": "python_version < '3.7'", 25 | "version": "==0.2.2" 26 | }, 27 | "altgraph": { 28 | "hashes": [ 29 | "sha256:1f05a47122542f97028caf78775a095fbe6a2699b5089de8477eb583167d69aa", 30 | "sha256:c623e5f3408ca61d4016f23a681b9adb100802ca3e3da5e718915a9e4052cebe" 31 | ], 32 | "version": "==0.17" 33 | }, 34 | "contextvars": { 35 | "hashes": [ 36 | "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e" 37 | ], 38 | "markers": "python_version < '3.7'", 39 | "version": "==2.4" 40 | }, 41 | "fbs": { 42 | "hashes": [ 43 | "sha256:ffb93f64d8d605e415859ff1efb2d2eb498529ad84cc04c4a2b77467cab28e28" 44 | ], 45 | "index": "pypi", 46 | "version": "==0.9.4" 47 | }, 48 | "future": { 49 | "hashes": [ 50 | "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" 51 | ], 52 | "version": "==0.18.2" 53 | }, 54 | "immutables": { 55 | "hashes": [ 56 | "sha256:141c2e9ea515a3a815007a429f0b47a578ebeb42c831edaec882a245a35fffca", 57 | "sha256:2283a93c151566e6830aee0e5bee55fc273455503b43aa004356b50f9182092b", 58 | "sha256:3035849accee4f4e510ed7c94366a40e0f5fef9069fbe04a35f4787b13610a4a", 59 | "sha256:3713ab1ebbb6946b7ce1387bb9d1d7f5e09c45add58c2a2ee65f963c171e746b", 60 | "sha256:3b15c08c71c59e5b7c2470ef949d49ff9f4263bb77f488422eaa157da84d6999", 61 | "sha256:6728f4392e3e8e64b593a5a0cd910a1278f07f879795517e09f308daed138631", 62 | "sha256:6f117d9206165b9dab8fd81c5129db757d1a044953f438654236ed9a7a4224ae", 63 | "sha256:8703d8abfd8687932f2a05f38e7de270c3a6ca3bd1c1efb3c938656b3f2f985a", 64 | "sha256:a0a4e4417d5ef4812d7f99470cd39347b58cb927365dd2b8da9161040d260db0", 65 | "sha256:b04fa69174e0c8f815f9c55f2a43fc9e5a68452fab459a08e904a74e8471639f", 66 | "sha256:b75ade826920c4e490b1bb14cf967ac14e61eb7c5562161c5d7337d61962c226", 67 | "sha256:b7e13c061785e34f73c4f659861f1b3e4a5fd918e4395c84b21c4e3d449ebe27", 68 | "sha256:b8ad986f9b532c026f19585289384b0769188fcb68b37c7f0bd0df9092a6ca54", 69 | "sha256:cbe8c64640637faa5535d539421b293327f119c31507c33ca880bd4f16035eb6", 70 | "sha256:f0836cd3bdc37c8a77b192bbe5f41dbcc3ce654db048ebbba89bdfe6db7a1c7a" 71 | ], 72 | "version": "==0.15" 73 | }, 74 | "loguru": { 75 | "hashes": [ 76 | "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319", 77 | "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c" 78 | ], 79 | "index": "pypi", 80 | "version": "==0.5.3" 81 | }, 82 | "macholib": { 83 | "hashes": [ 84 | "sha256:0c436bc847e7b1d9bda0560351bf76d7caf930fb585a828d13608839ef42c432", 85 | "sha256:c500f02867515e6c60a27875b408920d18332ddf96b4035ef03beddd782d4281" 86 | ], 87 | "version": "==1.14" 88 | }, 89 | "patool": { 90 | "hashes": [ 91 | "sha256:3f642549c9a78f5b8bef1af92df385b521d360520d1f34e4dba3fd1dee2a21bc", 92 | "sha256:e3180cf8bfe13bedbcf6f5628452fca0c2c84a3b5ae8c2d3f55720ea04cb1097" 93 | ], 94 | "index": "pypi", 95 | "version": "==1.12" 96 | }, 97 | "pefile": { 98 | "hashes": [ 99 | "sha256:ed79b2353daa58421459abf4d685953bde0adf9f6e188944f97ba9795f100246" 100 | ], 101 | "version": "==2021.5.24" 102 | }, 103 | "pyinstaller": { 104 | "hashes": [ 105 | "sha256:a5a6e04a66abfcf8761e89a2ebad937919c6be33a7b8963e1a961b55cb35986b" 106 | ], 107 | "version": "==3.4" 108 | }, 109 | "pyside2": { 110 | "hashes": [ 111 | "sha256:0558ced3bcd7f9da638fa8b7709dba5dae82a38728e481aac8b9058ea22fcdd9", 112 | "sha256:081d8c8a6c65fb1392856a547814c0c014e25ac04b38b987d9a3483e879e9634", 113 | "sha256:087a0b719bb967405ea85fd202757c761f1fc73d0e2397bc3a6a15376782ee75", 114 | "sha256:1316aa22dd330df096daf7b0defe9c00297a66e0b4907f057aaa3e88c53d1aff", 115 | "sha256:4f17a0161995678110447711d685fcd7b15b762810e8f00f6dc239bffb70a32e", 116 | "sha256:976cacf01ef3b397a680f9228af7d3d6273b9254457ad4204731507c1f9e6c3c" 117 | ], 118 | "index": "pypi", 119 | "version": "==5.15.2" 120 | }, 121 | "pywin32": { 122 | "hashes": [ 123 | "sha256:595d397df65f1b2e0beaca63a883ae6d8b6df1cdea85c16ae85f6d2e648133fe", 124 | "sha256:87604a4087434cd814ad8973bd47d6524bd1fa9e971ce428e76b62a5e0860fdf", 125 | "sha256:88981dd3cfb07432625b180f49bf4e179fb8cbb5704cd512e38dd63636af7a17", 126 | "sha256:8c9d33968aa7fcddf44e47750e18f3d034c3e443a707688a008a2e52bbef7e96", 127 | "sha256:93367c96e3a76dfe5003d8291ae16454ca7d84bb24d721e0b74a07610b7be4a7", 128 | "sha256:9635df6998a70282bd36e7ac2a5cef9ead1627b0a63b17c731312c7a0daebb72", 129 | "sha256:98f62a3f60aa64894a290fb7494bfa0bfa0a199e9e052e1ac293b2ad3cd2818b", 130 | "sha256:c866f04a182a8cb9b7855de065113bbd2e40524f570db73ef1ee99ff0a5cc2f0", 131 | "sha256:dafa18e95bf2a92f298fe9c582b0e205aca45c55f989937c52c454ce65b93c78", 132 | "sha256:fb3b4933e0382ba49305cc6cd3fb18525df7fd96aa434de19ce0878133bf8e4a" 133 | ], 134 | "index": "pypi", 135 | "version": "==301" 136 | }, 137 | "pywin32-ctypes": { 138 | "hashes": [ 139 | "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", 140 | "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98" 141 | ], 142 | "index": "pypi", 143 | "version": "==0.2.0" 144 | }, 145 | "shiboken2": { 146 | "hashes": [ 147 | "sha256:03f41b0693b91c7f89627f1085a4ecbe8591c03f904118a034854d935e0e766c", 148 | "sha256:14a33169cf1bd919e4c4c4408fffbcd424c919a3f702df412b8d72b694e4c1d5", 149 | "sha256:4aee1b91e339578f9831e824ce2a1ec3ba3a463f41fda8946b4547c7eb3cba86", 150 | "sha256:89c157a0e2271909330e1655892e7039249f7b79a64a443d52c512337065cde0", 151 | "sha256:ae8ca41274cfa057106268b6249674ca669c5b21009ec49b16d77665ab9619ed", 152 | "sha256:edc12a4df2b5be7ca1e762ab94e331ba9e2fbfe3932c20378d8aa3f73f90e0af" 153 | ], 154 | "version": "==5.15.2" 155 | }, 156 | "win32-setctime": { 157 | "hashes": [ 158 | "sha256:4e88556c32fdf47f64165a2180ba4552f8bb32c1103a2fafd05723a0bd42bd4b", 159 | "sha256:dc925662de0a6eb987f0b01f599c01a8236cb8c62831c22d9cada09ad958243e" 160 | ], 161 | "index": "pypi", 162 | "version": "==1.0.3" 163 | } 164 | }, 165 | "develop": { 166 | "appdirs": { 167 | "hashes": [ 168 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 169 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 170 | ], 171 | "version": "==1.4.4" 172 | }, 173 | "astroid": { 174 | "hashes": [ 175 | "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", 176 | "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" 177 | ], 178 | "version": "==2.5.6" 179 | }, 180 | "autoflake": { 181 | "hashes": [ 182 | "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea" 183 | ], 184 | "index": "pypi", 185 | "version": "==1.4" 186 | }, 187 | "bandit": { 188 | "hashes": [ 189 | "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07", 190 | "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608" 191 | ], 192 | "index": "pypi", 193 | "version": "==1.7.0" 194 | }, 195 | "black": { 196 | "hashes": [ 197 | "sha256:1fc0e0a2c8ae7d269dfcf0c60a89afa299664f3e811395d40b1922dff8f854b5", 198 | "sha256:e5cf21ebdffc7a9b29d73912b6a6a9a4df4ce70220d523c21647da2eae0751ef" 199 | ], 200 | "index": "pypi", 201 | "version": "==21.5b2" 202 | }, 203 | "click": { 204 | "hashes": [ 205 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 206 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 207 | ], 208 | "version": "==8.0.1" 209 | }, 210 | "dataclasses": { 211 | "hashes": [ 212 | "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf", 213 | "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97" 214 | ], 215 | "markers": "python_version < '3.7'", 216 | "version": "==0.8" 217 | }, 218 | "gitdb": { 219 | "hashes": [ 220 | "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0", 221 | "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005" 222 | ], 223 | "version": "==4.0.7" 224 | }, 225 | "gitpython": { 226 | "hashes": [ 227 | "sha256:29fe82050709760081f588dd50ce83504feddbebdc4da6956d02351552b1c135", 228 | "sha256:ee24bdc93dce357630764db659edaf6b8d664d4ff5447ccfeedd2dc5c253f41e" 229 | ], 230 | "version": "==3.1.17" 231 | }, 232 | "importlib-metadata": { 233 | "hashes": [ 234 | "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00", 235 | "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139" 236 | ], 237 | "markers": "python_version < '3.8'", 238 | "version": "==4.5.0" 239 | }, 240 | "isort": { 241 | "hashes": [ 242 | "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6", 243 | "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d" 244 | ], 245 | "index": "pypi", 246 | "version": "==5.8.0" 247 | }, 248 | "lazy-object-proxy": { 249 | "hashes": [ 250 | "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", 251 | "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61", 252 | "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2", 253 | "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837", 254 | "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3", 255 | "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43", 256 | "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726", 257 | "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3", 258 | "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587", 259 | "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8", 260 | "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a", 261 | "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd", 262 | "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f", 263 | "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad", 264 | "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4", 265 | "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b", 266 | "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf", 267 | "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981", 268 | "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741", 269 | "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e", 270 | "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", 271 | "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" 272 | ], 273 | "version": "==1.6.0" 274 | }, 275 | "mccabe": { 276 | "hashes": [ 277 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 278 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 279 | ], 280 | "version": "==0.6.1" 281 | }, 282 | "mypy-extensions": { 283 | "hashes": [ 284 | "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", 285 | "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" 286 | ], 287 | "version": "==0.4.3" 288 | }, 289 | "pathspec": { 290 | "hashes": [ 291 | "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", 292 | "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" 293 | ], 294 | "version": "==0.8.1" 295 | }, 296 | "pbr": { 297 | "hashes": [ 298 | "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", 299 | "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" 300 | ], 301 | "version": "==5.6.0" 302 | }, 303 | "pyflakes": { 304 | "hashes": [ 305 | "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", 306 | "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" 307 | ], 308 | "version": "==2.3.1" 309 | }, 310 | "pylint": { 311 | "hashes": [ 312 | "sha256:0a049c5d47b629d9070c3932d13bff482b12119b6a241a93bc460b0be16953c8", 313 | "sha256:792b38ff30903884e4a9eab814ee3523731abd3c463f3ba48d7b627e87013484" 314 | ], 315 | "index": "pypi", 316 | "version": "==2.8.3" 317 | }, 318 | "pyyaml": { 319 | "hashes": [ 320 | "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", 321 | "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", 322 | "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", 323 | "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", 324 | "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", 325 | "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", 326 | "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", 327 | "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", 328 | "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", 329 | "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", 330 | "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", 331 | "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", 332 | "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", 333 | "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", 334 | "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", 335 | "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", 336 | "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", 337 | "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", 338 | "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", 339 | "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", 340 | "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", 341 | "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", 342 | "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", 343 | "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", 344 | "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", 345 | "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", 346 | "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", 347 | "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", 348 | "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" 349 | ], 350 | "version": "==5.4.1" 351 | }, 352 | "regex": { 353 | "hashes": [ 354 | "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5", 355 | "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79", 356 | "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31", 357 | "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500", 358 | "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11", 359 | "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14", 360 | "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3", 361 | "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439", 362 | "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c", 363 | "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82", 364 | "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711", 365 | "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093", 366 | "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a", 367 | "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb", 368 | "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8", 369 | "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17", 370 | "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000", 371 | "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d", 372 | "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480", 373 | "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc", 374 | "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0", 375 | "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9", 376 | "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765", 377 | "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e", 378 | "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a", 379 | "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07", 380 | "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f", 381 | "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac", 382 | "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7", 383 | "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed", 384 | "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968", 385 | "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7", 386 | "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2", 387 | "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4", 388 | "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87", 389 | "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8", 390 | "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10", 391 | "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29", 392 | "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605", 393 | "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6", 394 | "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042" 395 | ], 396 | "version": "==2021.4.4" 397 | }, 398 | "six": { 399 | "hashes": [ 400 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 401 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 402 | ], 403 | "version": "==1.16.0" 404 | }, 405 | "smmap": { 406 | "hashes": [ 407 | "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182", 408 | "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2" 409 | ], 410 | "version": "==4.0.0" 411 | }, 412 | "stevedore": { 413 | "hashes": [ 414 | "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", 415 | "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" 416 | ], 417 | "version": "==3.3.0" 418 | }, 419 | "toml": { 420 | "hashes": [ 421 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 422 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 423 | ], 424 | "version": "==0.10.2" 425 | }, 426 | "typed-ast": { 427 | "hashes": [ 428 | "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace", 429 | "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff", 430 | "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266", 431 | "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528", 432 | "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6", 433 | "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808", 434 | "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4", 435 | "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363", 436 | "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341", 437 | "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04", 438 | "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41", 439 | "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e", 440 | "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3", 441 | "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899", 442 | "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805", 443 | "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c", 444 | "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c", 445 | "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39", 446 | "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a", 447 | "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3", 448 | "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7", 449 | "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f", 450 | "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075", 451 | "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0", 452 | "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40", 453 | "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428", 454 | "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927", 455 | "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3", 456 | "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f", 457 | "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65" 458 | ], 459 | "markers": "python_version < '3.8'", 460 | "version": "==1.4.3" 461 | }, 462 | "typing-extensions": { 463 | "hashes": [ 464 | "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", 465 | "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", 466 | "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" 467 | ], 468 | "markers": "python_version < '3.8'", 469 | "version": "==3.10.0.0" 470 | }, 471 | "wrapt": { 472 | "hashes": [ 473 | "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" 474 | ], 475 | "version": "==1.12.1" 476 | }, 477 | "zipp": { 478 | "hashes": [ 479 | "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", 480 | "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" 481 | ], 482 | "version": "==3.4.1" 483 | } 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
Bad Logo (I'm not good at graphic design)
2 | 3 | # MSFS Mod Manager 4 | 5 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/nathanvaughn/msfs-mod-manager)](https://github.com/NathanVaughn/msfs-mod-manager/releases/latest) 7 | ![GitHub All Releases](https://img.shields.io/github/downloads/nathanvaughn/msfs-mod-manager/total) 8 | [![GitHub stars](https://img.shields.io/github/stars/NathanVaughn/msfs-mod-manager)](https://github.com/NathanVaughn/msfs-mod-manager/stargazers) 9 | [![GitHub license](https://img.shields.io/github/license/NathanVaughn/msfs-mod-manager)](https://github.com/NathanVaughn/msfs-mod-manager/blob/master/LICENSE) 10 | 11 | This is an external mod manager for the new Microsoft Flight Simulator to help you install 12 | and manage 3rd party addons. 13 | 14 | ![Main Screen](screenshots/main.png) 15 | 16 | ## ***I do not guarantee that this program will not accidentally delete all of your mods. Please keep good backups.*** 17 | 18 | ## Features 19 | 20 | ### Automatic Installation Detection 21 | 22 | The program automatically tries to determine where the sim is installed, for you. 23 | No rooting around inside the `AppData` folder. 24 | 25 | ![Sim Directory Detection](screenshots/auto-detect.png) 26 | 27 | If the program can't automatically find the installation folder (you put it somewhere 28 | non-standard), you can manually select the location that contains the `Community` and 29 | `Official` folders. 30 | 31 | ![Manual Selection](screenshots/manual-select.png) 32 | 33 | This is normally `%USER%\AppData\Roaming\Microsoft Flight Simulator\Packages` or 34 | `%USER%\AppData\Local\Packages\Microsoft.FlightSimulator_8wekyb3d8bbwe\LocalCache\Packages` 35 | unless you manually selected a different location. 36 | 37 | ### Super Easy Mod Installs 38 | 39 | The program will extract an archive, find all mods inside, and install them 40 | inside the correct folder automatically. 41 | 42 | ![Install Demo](screenshots/install.gif) 43 | 44 | ### Enable and Disable Mods 45 | 46 | Enable and disable mods on the fly without needing to re-download them. 47 | 48 | ![Enable Demo](screenshots/enable.gif) 49 | 50 | ### Matching Theme 51 | 52 | A custom-designed theme to roughly match the MSFS UI is optionally available. 53 | 54 | ![Main Screen with Theme](screenshots/main-theme.png) 55 | 56 | ### No Need to Reinstall Anything 57 | 58 | The program parses the native game files, so you do not need to reinstall all of your 59 | mods to take advantage of the features of this mod manager. 60 | 61 | ### Mod Info 62 | 63 | View info about a mod and quickly open the directory it is located in. 64 | 65 | ![Mod Info Demo](screenshots/mod-info.gif) 66 | 67 | ### Backups 68 | 69 | Easily create backups of all of your enabled mods, in case you need to reinstall 70 | your game. 71 | 72 | ### Search 73 | 74 | Filter mods as you type to help you find what you're looking for. 75 | 76 | ![Mod Searching Demo](screenshots/search.gif) 77 | 78 | ### More To Come 79 | 80 | This is still under active development. Pull requests welcome! 81 | 82 | ## Usage 83 | 84 | Just head to the 85 | [releases page](https://github.com/NathanVaughn/msfs-mod-manager/releases) 86 | to download the latest installer. Or, if you want to live life on the edge, 87 | run the code from source, as described below. 88 | 89 | Note: If you want extract `.rar` or `.7z` files with the program, you'll need 90 | to have [7zip](https://www.7-zip.org/) installed. 91 | 92 | If Windows complains that the application is untrusted, this is because 93 | the executable is not signed. A code signing certificate is needed to fix this, 94 | but they are rather expensive, and I can't justify the cost. 95 | The program is open source however, so you could build 96 | it yourself if you wanted, and the provided pre-built binaries are all created 97 | [automatically on GitHub's infrastructure](https://github.com/NathanVaughn/msfs-mod-manager/actions?query=workflow%3A%22Create+Release%22). 98 | 99 | ## Running/Building From Source 100 | 101 | ### Dependencies 102 | 103 | First, install [Python 3.6](https://www.python.org/downloads/release/python-368/). 104 | Python 3.7 and Python 3.8 are not fully supported yet. 105 | 106 | Next, install the dependencies with `pipenv`: 107 | 108 | ```bash 109 | python -m pip install pipenv 110 | pipenv install 111 | ``` 112 | 113 | ### Running 114 | 115 | To actually run the program, use 116 | ```bash 117 | pipenv run fbs run 118 | ``` 119 | 120 | or 121 | ```bash 122 | pipenv shell 123 | fbs run 124 | ``` 125 | 126 | ### Building 127 | 128 | Building is done with the `build.ps1` script. 129 | 130 | To build a portable version of the program, run 131 | ```bash 132 | ./build.ps1 133 | ``` 134 | 135 | If you want a debug version of an `.exe`, add the `-debug` flag. 136 | 137 | To build an installer version of the program, you first need to install 138 | [NSIS](https://nsis.sourceforge.io/Main_Page). 139 | Now, run the same build script with the `-installer` flag: 140 | ```bash 141 | ./build.ps1 -installer 142 | ``` 143 | 144 | ### Creating a Release 145 | 146 | 1. Create a commit modifying the version number of `src/build/settings/base.json`. 147 | 2. In this commit, add the release notes to the commit body. 148 | 3. Tag this commit with the version number in the form of `v#.#.#`. 149 | 4. Push tag and commit to origin. 150 | 151 | ## Disclaimer 152 | 153 | This project is not affiliated with Asobo Studios or Microsoft. 154 | I am just an aerospace engineer that is working on this in my free time. 155 | I may not be very fast in bugfixes or adding new features. 156 | 157 | **Please do not redistribute without permission.** 158 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [switch]$debug = $false, 3 | [switch]$installer = $false 4 | ) 5 | 6 | Write-Output "Cleaning existing build" 7 | pipenv run fbs clean 8 | cp src\build\settings\base.json src\main\resources\base\base.json 9 | 10 | Write-Output "Building exe" 11 | if ($debug) { 12 | pipenv run fbs freeze --debug 13 | } else { 14 | pipenv run fbs freeze 15 | } 16 | 17 | if ($installer) { 18 | Write-Output "Building installer" 19 | pipenv run fbs installer 20 | } -------------------------------------------------------------------------------- /format.ps1: -------------------------------------------------------------------------------- 1 | isort src --multi-line=3 --trailing-comma --force-grid-wrap=0 --use-parentheses --line-width=88 2 | autoflake -r -i --remove-all-unused-imports --remove-unused-variables src 3 | black src -------------------------------------------------------------------------------- /lint.ps1: -------------------------------------------------------------------------------- 1 | pylint src -------------------------------------------------------------------------------- /screenshots/auto-detect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/auto-detect.png -------------------------------------------------------------------------------- /screenshots/enable.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/enable.gif -------------------------------------------------------------------------------- /screenshots/everything-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/everything-search.png -------------------------------------------------------------------------------- /screenshots/install.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/install.gif -------------------------------------------------------------------------------- /screenshots/main-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/main-theme.png -------------------------------------------------------------------------------- /screenshots/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/main.png -------------------------------------------------------------------------------- /screenshots/manual-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/manual-select.png -------------------------------------------------------------------------------- /screenshots/mod-info.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/mod-info.gif -------------------------------------------------------------------------------- /screenshots/search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/screenshots/search.gif -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # flap_lift_fix.py 2 | 3 | ***!!!! UPDATE*** 4 | 5 | Don't use this script anymore. A hotfix has been released: [https://forums.flightsimulator.com/t/hotfix-flight-dynamics-bug/372996](https://forums.flightsimulator.com/t/hotfix-flight-dynamics-bug/372996) 6 | 7 | This script will help you fix the [flight dynamics bug](https://forums.flightsimulator.com/t/flight-dynamics-bug-details/368499) 8 | introduced in World Update 3, which Asobo decided not to fix immediately. 9 | 10 | ***!!! WARNING*** Use at your own peril 11 | 12 | ## Requirements 13 | 14 | You need the following installed: 15 | 16 | - [Everything](https://voidtools.com/downloads/) 17 | - [Python 3](https://www.python.org/downloads/release/python-392/) ([Microsoft Store](https://www.microsoft.com/store/productId/9P7QFQMJRFP7)) 18 | 19 | ## Usage 20 | 21 | First, download this repository. Either with [git](https://git-scm.com/download/win): 22 | 23 | ``` 24 | git clone https://github.com/NathanVaughn/msfs-mod-manager.git 25 | ``` 26 | 27 | or by downloading a `.zip` file: 28 | 29 | [https://github.com/NathanVaughn/msfs-mod-manager/archive/master.zip](https://github.com/NathanVaughn/msfs-mod-manager/archive/master.zip) 30 | 31 | You can also just download the one script file if you wish as well: [flap_lift_fix.py](https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/master/scripts/flap_lift_fix.py) 32 | 33 | Now, open up Everything, and search the phrase `\Official flight_model.cfg`. 34 | This will find all of the flight model files for the official aircraft. Unfortunately, 35 | this will not work for premium aircraft where the files are encrypted. 36 | 37 | Select all the results and right-click and select "Copy Full Name to Clipboard". 38 | 39 | ![](../screenshots/everything-search.png) 40 | 41 | Wherever you downloaded this repository, create the file `files.txt` inside the 42 | `scripts` directory, and paste all the paths into this. Example: 43 | 44 | ``` 45 | C:\Users\Nathan Vaughn\AppData\Roaming\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-208b-grand-caravan-ex\SimObjects\Airplanes\Asobo_208B_GRAND_CARAVAN_EX\flight_model.cfg 46 | C:\Users\Nathan Vaughn\AppData\Roaming\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-a320-neo\SimObjects\AirPlanes\Asobo_A320_NEO\flight_model.cfg 47 | C:\Users\Nathan Vaughn\AppData\Roaming\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-b7478i\SimObjects\Airplanes\Asobo_B747_8i\flight_model.cfg 48 | C:\Users\Nathan Vaughn\AppData\Roaming\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-bonanza-g36\SimObjects\Airplanes\Asobo_Bonanza_G36\flight_model.cfg 49 | C:\Users\Nathan Vaughn\AppData\Roaming\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-c152\SimObjects\Airplanes\Asobo_C152\flight_model.cfg 50 | C:\Users\Nathan Vaughn\AppData\Roaming\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-c172sp-as1000\SimObjects\Airplanes\Asobo_C172sp_AS1000\flight_model.cfg 51 | ... 52 | ``` 53 | 54 | Lastly, run `flap_lift_fix.py` by double-clicking on it or running: 55 | 56 | ```bash 57 | python flap_lift_fix.py 58 | ``` 59 | 60 | from the command line. 61 | 62 | ## Note 63 | 64 | Running this program more than once won't do anything. When it runs the first time, 65 | it adds a comment not parsed by Flight Simulator to indicate that the file 66 | has already been fixed. It checks for this comment before making any changes, 67 | so running this program multiple times won't decrease your flap lift by half every time. 68 | 69 | ## Undoing 70 | 71 | If you want to undo the flap lift fix, add the `--undo` flag when running the program: 72 | 73 | ```bash 74 | python flap_lift_fix.py --undo 75 | ``` 76 | -------------------------------------------------------------------------------- /scripts/files.txt.example: -------------------------------------------------------------------------------- 1 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-208b-grand-caravan-ex\SimObjects\Airplanes\Asobo_208B_GRAND_CARAVAN_EX\flight_model.cfg 2 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-a320-neo\SimObjects\AirPlanes\Asobo_A320_NEO\flight_model.cfg 3 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-b7478i\SimObjects\Airplanes\Asobo_B747_8i\flight_model.cfg 4 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-bonanza-g36\SimObjects\Airplanes\Asobo_Bonanza_G36\flight_model.cfg 5 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-c152\SimObjects\Airplanes\Asobo_C152\flight_model.cfg 6 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-c172sp-as1000\SimObjects\Airplanes\Asobo_C172sp_AS1000\flight_model.cfg 7 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-cap10c\SimObjects\Airplanes\Asobo_Cap10C\flight_model.cfg 8 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-cj4\SimObjects\Airplanes\Asobo_CJ4\flight_model.cfg 9 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-da40-ng\SimObjects\Airplanes\Asobo_DA40_NG\flight_model.cfg 10 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-da62\SimObjects\Airplanes\Asobo_DA62\flight_model.cfg 11 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-dr400\SimObjects\Airplanes\Asobo_DR400\flight_model.cfg 12 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-e330\SimObjects\Airplanes\Asobo_E330\flight_model.cfg 13 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-flightdesignct\SimObjects\Airplanes\Asobo_FlightDesignCT\flight_model.cfg 14 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-airliner-quadengines\SimObjects\Airplanes\Asobo_Generic_Airliner_QuadEngines\flight_model.cfg 15 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-airliner-twinengines\SimObjects\Airplanes\Asobo_Generic_Airliner_TwinEngines\flight_model.cfg 16 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-piston-multiengines\SimObjects\Airplanes\Asobo_Generic_Piston_MultiEngines\flight_model.cfg 17 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-piston-singleengine\SimObjects\Airplanes\Asobo_Generic_Piston_SingleEngine\flight_model.cfg 18 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-privatejet\SimObjects\Airplanes\Asobo_Generic_PrivateJet\flight_model.cfg 19 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-turbo-multiengines\SimObjects\Airplanes\Asobo_Generic_Turbo_MultiEngines\flight_model.cfg 20 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-generic-turbo-singleengine\SimObjects\Airplanes\Asobo_Generic_Turbo_SingleEngine\flight_model.cfg 21 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-icon\SimObjects\Airplanes\Asobo_Icon\flight_model.cfg 22 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-kingair350\SimObjects\Airplanes\Asobo_KingAir350\flight_model.cfg 23 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-pitts\SimObjects\Airplanes\Asobo_Pitts\flight_model.cfg 24 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-savage-cub\SimObjects\Airplanes\Asobo_Savage_Cub\flight_model.cfg 25 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-tbm930\SimObjects\Airplanes\Asobo_TBM930\flight_model.cfg 26 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-vl3\SimObjects\Airplanes\Asobo_VL3\flight_model.cfg 27 | %APPDATA%\Microsoft Flight Simulator\Packages\Official\Steam\asobo-aircraft-xcub\SimObjects\Airplanes\Asobo_XCub\flight_model.cfg -------------------------------------------------------------------------------- /scripts/flap_lift_fix.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | TAG = ";FLAPFIX" 6 | 7 | 8 | def fix_coeff(line: str, undo: bool = False) -> str: 9 | # parse value from the config file and divide it by 2, and reassemble 10 | regex = r"(\S+\s*=\s*)(\d*\.?\d+)(.*$)" 11 | 12 | groups = list(re.findall(regex, line)[0]) 13 | coeff = float(groups[1]) 14 | new_coeff = str(coeff * 2) if undo else str(coeff / 2) 15 | groups[1] = new_coeff 16 | 17 | return "".join(groups + ["\n"]) 18 | 19 | 20 | def fix_lift_coef_flaps(file_contents: list, undo: bool = False) -> list: 21 | # fix lift_coef_flaps value 22 | for i, line in enumerate(file_contents): 23 | if line.startswith("lift_coef_flaps"): 24 | file_contents[i] = fix_coeff(line, undo=undo) 25 | 26 | return file_contents 27 | 28 | 29 | def fix_lift_scalar(file_contents: list, undo: bool = False) -> list: 30 | # fix lift_scalar values under the FLAPS sections 31 | for i, line in enumerate(file_contents): 32 | if line.startswith("lift_scalar"): 33 | file_contents[i] = fix_coeff(line, undo=undo) 34 | 35 | return file_contents 36 | 37 | 38 | def remove_tag(file_contents: list) -> list: 39 | # remove fixed tag from file line list 40 | new_contents = [] 41 | 42 | for line in file_contents: 43 | if TAG not in line: 44 | new_contents.append(line) 45 | 46 | return new_contents 47 | 48 | 49 | def add_tag(file_contents: list) -> list: 50 | # add fixed tag to file line list 51 | file_contents.insert(0, TAG + "\n") 52 | return file_contents 53 | 54 | 55 | def check_tag(file_contents: list) -> list: 56 | # check for existing fixed tag in file line list 57 | return any(TAG in line for line in file_contents) 58 | 59 | 60 | def fix_flight_model(filename: str, undo: bool = False) -> None: 61 | print("Fixing: {}".format(filename)) 62 | 63 | with open(filename, "r+") as fp: 64 | # read the data in 65 | flight_model_content = fp.readlines() 66 | 67 | # check for tag if we're fixing 68 | if check_tag(flight_model_content) and not undo: 69 | print("{} already fixed. Skipping...".format(filename)) 70 | return 71 | 72 | # check for tag if we're undoing 73 | if not check_tag(flight_model_content) and undo: 74 | print("{} already undone. Skipping...".format(filename)) 75 | return 76 | 77 | # new_flight_model_content = fix_lift_coef_flaps(flight_model_content,undo=undo) 78 | new_flight_model_content = fix_lift_scalar(flight_model_content, undo=undo) 79 | 80 | if undo: 81 | new_flight_model_content = remove_tag(new_flight_model_content) 82 | else: 83 | new_flight_model_content = add_tag(new_flight_model_content) 84 | 85 | # write it back out 86 | fp.seek(0) 87 | fp.writelines(new_flight_model_content) 88 | fp.truncate() 89 | 90 | 91 | def main() -> None: 92 | flight_models = [] 93 | 94 | undo = "--undo" in sys.argv 95 | 96 | with open("files.txt", "r") as fp: 97 | flight_models = fp.readlines() 98 | 99 | app_data = os.getenv("APPDATA") 100 | 101 | for flight_model in flight_models: 102 | # strip new line 103 | flight_model = flight_model.strip() 104 | # substitute app data 105 | flight_model = flight_model.replace("%APPDATA%", app_data) 106 | 107 | if not os.path.isfile(flight_model): 108 | print("{} is not a real file!".format(flight_model)) 109 | continue 110 | 111 | fix_flight_model(flight_model, undo=undo) 112 | 113 | 114 | if __name__ == "__main__": 115 | main() 116 | -------------------------------------------------------------------------------- /src/build/settings/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_name": "MSFS Mod Manager", 3 | "author": "Nathan Vaughn", 4 | "main_module": "src/main/python/main.py", 5 | "hidden_imports": ["patoolib.programs", "patoolib.programs.p7zip", "patoolib.programs.rar", "patoolib.programs.unrar", "patoolib.programs.zip", "patoolib.programs.unzip", "patoolib.programs.tar", "patoolib.programs.py_bz2", "patoolib.programs.py_echo", "patoolib.programs.py_gzip", "patoolib.programs.py_lzma", "patoolib.programs.py_tarfile", "patoolib.programs.py_zipfile"], 6 | "version": "1.0.3" 7 | } -------------------------------------------------------------------------------- /src/main/icons/Icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/icons/Icon.ico -------------------------------------------------------------------------------- /src/main/icons/README.md: -------------------------------------------------------------------------------- 1 | ![Sample app icon](linux/128.png) 2 | 3 | This directory contains the icons that are displayed for your app. Feel free to 4 | change them. 5 | 6 | The difference between the icons on Mac and the other platforms is that on Mac, 7 | they contain a ~5% transparent margin. This is because otherwise they look too 8 | big (eg. in the Dock or in the app switcher). 9 | 10 | You can create Icon.ico from the .png files with 11 | [an online tool](http://icoconvert.com/Multi_Image_to_one_icon/). -------------------------------------------------------------------------------- /src/main/icons/base/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/icons/base/16.png -------------------------------------------------------------------------------- /src/main/icons/base/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/icons/base/24.png -------------------------------------------------------------------------------- /src/main/icons/base/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/icons/base/32.png -------------------------------------------------------------------------------- /src/main/icons/base/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/icons/base/48.png -------------------------------------------------------------------------------- /src/main/icons/base/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/icons/base/64.png -------------------------------------------------------------------------------- /src/main/python/dialogs/error_dialogs.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from PySide2.QtWidgets import QMessageBox, QWidget 4 | 5 | TITLE = "Error" 6 | 7 | 8 | def general(parent: QWidget, typ: Any, message: str) -> None: 9 | QMessageBox().critical( 10 | parent, 11 | TITLE, 12 | "Something went terribly wrong.\n{}: {}".format(typ, message), 13 | ) 14 | 15 | 16 | def _archive(parent: QWidget, archive: str, action: str, message: str) -> None: 17 | QMessageBox().critical( 18 | parent, 19 | TITLE, 20 | "Unable to {action} archive {archive}." 21 | + " You may need to install a program which can {action} this," 22 | + " such as 7zip or WinRar.\n{message}".format( 23 | archive=archive, action=action, message=message 24 | ), 25 | ) 26 | 27 | 28 | def archive_create(parent: QWidget, archive: str, message: str) -> None: 29 | return _archive(parent, archive, "create", message) 30 | 31 | 32 | def archive_extract(parent: QWidget, archive: str, message: str) -> None: 33 | return _archive(parent, archive, "extract", message) 34 | 35 | 36 | def no_mods(parent: QWidget, original_object: str) -> None: 37 | QMessageBox().critical( 38 | parent, TITLE, "Unable to find any mods inside {}".format(original_object) 39 | ) 40 | 41 | 42 | def permission(parent: QWidget, mod: str, affected_object: str) -> None: 43 | QMessageBox().critical( 44 | parent, 45 | TITLE, 46 | "Unable to install mod {} due to a permissions issue" 47 | + " (unable to delete file/folder {}). Relaunch the program" 48 | + " as an administrator.".format(mod, affected_object), 49 | ) 50 | -------------------------------------------------------------------------------- /src/main/python/dialogs/information_dialogs.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from PySide2.QtWidgets import QMessageBox, QWidget 4 | 5 | TITLE = "Info" 6 | 7 | 8 | def sim_detected(parent: QWidget, folder: str) -> None: 9 | QMessageBox().information( 10 | parent, 11 | "Info", 12 | "Your Microsoft Flight Simulator folder path was automatically detected to {}".format( 13 | folder 14 | ), 15 | ) 16 | 17 | 18 | def mods_installed(parent: QWidget, mods: List[str]) -> None: 19 | QMessageBox().information( 20 | parent, 21 | TITLE, 22 | "{} mod(s) installed!\n{}".format( 23 | len(mods), "\n".join("- {}".format(mod) for mod in mods) 24 | ), 25 | ) 26 | 27 | 28 | def mod_install_folder(parent: QWidget) -> None: 29 | QMessageBox().information( 30 | parent, 31 | TITLE, 32 | "The mod install folder is the folder in which the mod manager" 33 | " installs mods to. This is NOT the same as the MSFS Community" 34 | " folder where the simulator expects mods from. This is handled" 35 | + " for you automatically." 36 | + "\n !!! Only change this if you know what you're doing!!!", 37 | ) 38 | 39 | 40 | def mod_install_folder_set(parent: QWidget, folder: str) -> None: 41 | QMessageBox().information( 42 | parent, 43 | TITLE, 44 | "The mod install folder has been set to {}".format(folder), 45 | ) 46 | -------------------------------------------------------------------------------- /src/main/python/dialogs/question_dialogs.py: -------------------------------------------------------------------------------- 1 | from PySide2.QtWidgets import QMessageBox, QWidget 2 | 3 | TITLE = "Question" 4 | 5 | 6 | def backup_success(parent: QWidget, archive) -> bool: 7 | result = QMessageBox().question( 8 | parent, 9 | TITLE, 10 | "Backup successfully saved to {}. Would you like to open this directory?".format( 11 | archive 12 | ), 13 | QMessageBox.Yes | QMessageBox.No, # type: ignore 14 | QMessageBox.Yes, # type: ignore 15 | ) 16 | return result == QMessageBox.Yes 17 | 18 | 19 | def mod_delete(parent: QWidget, length: int) -> bool: 20 | result = QMessageBox().information( 21 | parent, 22 | TITLE, 23 | "This will permamentaly delete {} mod(s). Are you sure you want to continue?".format( 24 | length 25 | ), 26 | QMessageBox.Yes | QMessageBox.No, # type: ignore 27 | QMessageBox.No, # type: ignore 28 | ) 29 | return result == QMessageBox.Yes 30 | 31 | 32 | def mod_install_folder_move(parent: QWidget, before: str, after: str) -> bool: 33 | result = QMessageBox().information( 34 | parent, 35 | TITLE, 36 | "This will move the mod install folder from {} to {}. Are you sure you want to do this?".format( 37 | before, after 38 | ), 39 | QMessageBox.Yes | QMessageBox.No, # type: ignore 40 | QMessageBox.No, # type: ignore 41 | ) 42 | return result == QMessageBox.Yes 43 | -------------------------------------------------------------------------------- /src/main/python/dialogs/version_check_dialog.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import PySide2.QtWidgets as QtWidgets 4 | 5 | 6 | class version_check_dialog: 7 | def __init__( 8 | self, parent: QtWidgets.QWidget = None, installed: bool = False 9 | ) -> None: 10 | """Version check widget.""" 11 | self.parent = parent 12 | 13 | self.chkbox = QtWidgets.QCheckBox("Don't ask me again") 14 | 15 | self.msgbox = QtWidgets.QMessageBox(parent) 16 | self.msgbox.setIcon(QtWidgets.QMessageBox.Information) # type: ignore 17 | if installed: 18 | self.msgbox.setText( 19 | "A new version is available. " 20 | + "Would you like to automatically install it?" 21 | ) 22 | else: 23 | self.msgbox.setText( 24 | "A new version is available. " 25 | + "Would you like to go to GitHub to download it?" 26 | ) 27 | self.msgbox.setCheckBox(self.chkbox) 28 | 29 | self.msgbox.addButton(QtWidgets.QMessageBox.Yes) # type: ignore 30 | self.msgbox.addButton(QtWidgets.QMessageBox.No) # type: ignore 31 | self.msgbox.setDefaultButton(QtWidgets.QMessageBox.Yes) # type: ignore 32 | 33 | def exec_(self) -> Tuple[bool, bool]: 34 | """Executes the widget. 35 | Returns selected button and if the remember option was selected.""" 36 | return ( 37 | self.msgbox.exec_() == QtWidgets.QMessageBox.Yes, 38 | bool(self.chkbox.checkState()), 39 | ) 40 | -------------------------------------------------------------------------------- /src/main/python/dialogs/warning_dialogs.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from PySide2.QtWidgets import QMessageBox, QWidget 4 | 5 | TITLE = "Warning" 6 | 7 | 8 | def sim_not_detected(parent: QWidget) -> None: 9 | QMessageBox().warning( 10 | parent, 11 | TITLE, 12 | "Microsoft Flight Simulator path could not be found." 13 | + " Please select the Packages folder manually" 14 | + " (which contains the Official and Community folders).", 15 | ) 16 | 17 | 18 | def sim_path_invalid(parent: QWidget) -> None: 19 | QMessageBox().warning( 20 | parent, 21 | TITLE, 22 | "Invalid Microsoft Flight Simulator path." 23 | + " Please select the Packages folder manually" 24 | + " (which contains the Official and Community folders).", 25 | ) 26 | 27 | 28 | def mod_parsing(parent: QWidget, mods: List[str]) -> None: 29 | QMessageBox().warning( 30 | parent, 31 | TITLE, 32 | "Unable to parse mod(s):\n{} \nThis is likely due to a missing or corrupt manifest.json file. See the debug log for more info.".format( 33 | "\n".join("- {}".format(mod) for mod in mods) 34 | ), 35 | ) 36 | 37 | 38 | def mod_install_folder_same(parent: QWidget) -> None: 39 | QMessageBox().warning( 40 | parent, 41 | TITLE, 42 | "The mod install folder you've selected is the same as what's currently set.", 43 | ) 44 | 45 | 46 | def mod_install_folder_in_sim_path(parent: QWidget) -> None: 47 | QMessageBox().warning( 48 | parent, 49 | TITLE, 50 | "The mod install folder you've selected contains the same path" 51 | + " as the simulator. You, more than likely, do not want this.", 52 | ) 53 | -------------------------------------------------------------------------------- /src/main/python/lib/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import functools 3 | import os 4 | from typing import Any, Tuple 5 | 6 | from loguru import logger 7 | 8 | BASE_FOLDER = os.path.abspath(os.path.join(os.getenv("APPDATA"), "MSFS Mod Manager")) # type: ignore 9 | DEBUG_LOG = os.path.join(BASE_FOLDER, "debug.log") 10 | 11 | CONFIG_FILE = os.path.join(BASE_FOLDER, "config.ini") 12 | SECTION_KEY = "settings" 13 | 14 | SIM_FOLDER_KEY = "sim_folder" 15 | # this key is kept as-is for legacy purposes 16 | MOD_INSTALL_FOLDER_KEY = "mod_cache_folder" 17 | LAST_OPEN_FOLDER_KEY = "last_open_folder" 18 | 19 | LAST_VER_CHECK_KEY = "last_version_check" 20 | NEVER_VER_CHEK_KEY = "never_version_check" 21 | 22 | THEME_KEY = "theme" 23 | 24 | 25 | @functools.lru_cache() 26 | def get_key_value( 27 | key: str, default: Any = None, path: bool = False 28 | ) -> Tuple[bool, Any]: 29 | """Attempts to load value from key in the config file. 30 | Returns a tuple of if the value was found, and if so, what the contents where.""" 31 | logger.debug( 32 | "Attempting to read key '{}' from the main config file {}".format( 33 | key, CONFIG_FILE 34 | ) 35 | ) 36 | 37 | config = configparser.ConfigParser() 38 | config.read(CONFIG_FILE) 39 | 40 | # this is tiered as such, so that one missing piece doesn't cause an error 41 | if SECTION_KEY in config: 42 | logger.debug("Section key '{}' found in config file".format(SECTION_KEY)) 43 | if key in config[SECTION_KEY]: 44 | value = config[SECTION_KEY][key] 45 | if path: 46 | value = os.path.normpath(value) 47 | 48 | logger.debug("Key '{}' found in section".format(key)) 49 | logger.debug("Key '{}' value: {}".format(key, value)) 50 | 51 | return (True, value) 52 | 53 | logger.debug("Unable to find key '{}' in config file".format(key)) 54 | return (False, default) 55 | 56 | 57 | def set_key_value(key: str, value: Any, path: bool = False) -> None: 58 | """Writes a key and value to the config file.""" 59 | value = str(value) 60 | 61 | logger.debug( 62 | "Attempting to write key '{}' and value '{}' to the main config file {}".format( 63 | key, value, CONFIG_FILE 64 | ) 65 | ) 66 | 67 | config = configparser.ConfigParser() 68 | config.read(CONFIG_FILE) 69 | 70 | if SECTION_KEY not in config: 71 | logger.debug( 72 | "Section key '{}' not found in config file, adding it".format(SECTION_KEY) 73 | ) 74 | config.add_section(SECTION_KEY) 75 | 76 | # if it's a path, normalize it 77 | if path: 78 | value = os.path.normpath(value) 79 | config[SECTION_KEY][key] = value 80 | 81 | logger.debug("Writing out config file") 82 | with open(CONFIG_FILE, "w") as f: 83 | config.write(f) 84 | 85 | # clear the cache 86 | get_key_value.cache_clear() 87 | -------------------------------------------------------------------------------- /src/main/python/lib/files.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import re 4 | import shutil 5 | import stat 6 | import subprocess 7 | import sys 8 | from typing import Callable, Union 9 | 10 | Num = Union[int, float] 11 | 12 | import patoolib 13 | from loguru import logger 14 | 15 | import lib.config as config 16 | 17 | if sys.platform == "win32": 18 | import win32file 19 | 20 | FILE_ATTRIBUTE_REPARSE_POINT = 1024 21 | 22 | ARCHIVE_VERBOSITY = -1 23 | ARCHIVE_INTERACTIVE = False 24 | HASH_FILE = "sha256.txt" 25 | 26 | TEMP_FOLDER = os.path.abspath( 27 | os.path.join(os.getenv("LOCALAPPDATA"), "Temp", "MSFS Mod Manager") # type: ignore 28 | ) 29 | 30 | if not os.path.exists(config.BASE_FOLDER): 31 | os.makedirs(config.BASE_FOLDER) 32 | 33 | 34 | class ExtractionError(Exception): 35 | """Raised when an archive cannot be extracted. 36 | Usually due to a missing appropriate extractor program.""" 37 | 38 | 39 | class AccessError(Exception): 40 | """Raised after an uncorrectable permission error.""" 41 | 42 | 43 | def exists(path: str) -> bool: 44 | """Returns if a path exists.""" 45 | # os.path.exists doesn't work for directory junctions that are broken, but 46 | # os.path.isdir does 47 | if os.path.exists(path): 48 | return True 49 | 50 | if os.path.isdir(path): 51 | return True 52 | 53 | return bool(os.path.isfile(path)) 54 | 55 | 56 | def fix_path(path: str) -> str: 57 | """Prepends magic prefix for a path name that is too long""" 58 | # https://stackoverflow.com/a/50924863 59 | # this is truly voodoo magic 60 | magic = "\\\\?\\" 61 | 62 | path = os.path.normpath(path) 63 | # some semblance of OS-compatibility for those Linux Proton folks 64 | if os.name == "nt" and not path.startswith(magic): 65 | return magic + path 66 | else: 67 | return path 68 | 69 | 70 | def fix_permissions(path: str) -> None: 71 | """Fixes the permissions of a folder or file so that it can be deleted.""" 72 | if not os.path.exists(path): 73 | logger.warning("Path {} does not exist".format(path)) 74 | return 75 | 76 | # logger.debug("Applying stat.S_IWUSR permission to {}".format(path)) 77 | # fix deletion permission https://blog.nathanv.me/posts/python-permission-issue/ 78 | os.chmod(path, stat.S_IWUSR) 79 | 80 | 81 | def fix_permissions_recursive(folder: str, update_func: Callable = None) -> None: 82 | """Recursively fixes the permissions of a folder so that it can be deleted.""" 83 | if not os.path.exists(folder): 84 | logger.warning("Folder {} does not exist".format(folder)) 85 | return 86 | 87 | if update_func: 88 | update_func("Fixing permissions for {}".format(folder)) 89 | 90 | logger.debug("Fixing permissions for {}".format(folder)) 91 | 92 | for root, dirs, files in os.walk(folder): 93 | for d in dirs: 94 | fix_permissions(os.path.join(root, d)) 95 | for f in files: 96 | fix_permissions(os.path.join(root, f)) 97 | 98 | 99 | def listdir_dirs(folder: str, full_paths: bool = False) -> list: 100 | """Returns a list of directories inside of a directory.""" 101 | # logger.debug("Listing directories of {}".format(folder)) 102 | if not os.path.isdir(folder): 103 | logger.warning("Folder {} does not exist".format(folder)) 104 | return [] 105 | 106 | result = [ 107 | item for item in os.listdir(folder) if os.path.isdir(os.path.join(folder, item)) 108 | ] 109 | 110 | if full_paths: 111 | result = [os.path.join(folder, item) for item in result] 112 | 113 | return result 114 | 115 | 116 | def human_readable_size(size: Num, decimal_places: int = 2) -> str: 117 | """Convert number of bytes into human readable value.""" 118 | # https://stackoverflow.com/a/43690506/9944427 119 | # logger.debug("Converting {} bytes to human readable format".format(size)) 120 | unit = "" 121 | for unit in ["B", "KB", "MB", "GB", "TB", "PB"]: 122 | if size < 1024.0 or unit == "PB": 123 | break 124 | size /= 1024.0 125 | return f"{size:.{decimal_places}f} {unit}" 126 | 127 | 128 | def check_same_path(path1: str, path2: str) -> bool: 129 | """Tests if two paths resolve to the same location.""" 130 | return fix_path(resolve_symlink(os.path.abspath(path1))) == fix_path( 131 | resolve_symlink(os.path.abspath(path2)) 132 | ) 133 | 134 | 135 | def check_in_path(path1: str, path2: str) -> bool: 136 | """Tests if the first path is inside the second path path.""" 137 | return fix_path(resolve_symlink(os.path.abspath(path2))).startswith( 138 | fix_path(resolve_symlink(os.path.abspath(path1))) 139 | ) 140 | 141 | 142 | def is_symlink(path: str) -> bool: 143 | """Tests if a path is a symlink.""" 144 | # https://stackoverflow.com/a/52859239 145 | # http://www.flexhex.com/docs/articles/hard-links.phtml 146 | if sys.platform != "win32" or sys.getwindowsversion()[0] < 6: 147 | return os.path.islink(path) 148 | 149 | if os.path.islink(path): 150 | return True 151 | 152 | return bool( 153 | os.path.isdir(path) 154 | and win32file.GetFileAttributes(path) & FILE_ATTRIBUTE_REPARSE_POINT 155 | == FILE_ATTRIBUTE_REPARSE_POINT 156 | ) 157 | 158 | 159 | def read_symlink(path: str) -> str: 160 | """Returns the original path of a symlink.""" 161 | if os.path.islink(path): 162 | return os.readlink(path) 163 | 164 | # Pretty slow, avoid if possible 165 | # TODO, reimplement with Win32 166 | process = subprocess.run( 167 | ["cmd", "/c", "fsutil", "reparsepoint", "query", path], 168 | check=False, 169 | stdout=subprocess.PIPE, 170 | stderr=subprocess.DEVNULL, 171 | ) 172 | output = process.stdout.decode("utf-8") 173 | # https://regex101.com/r/8hc7yq/1 174 | return re.search("Print Name:\\s+(.+)\\s+Reparse Data", output, re.MULTILINE).group( 175 | 1 176 | ) 177 | 178 | 179 | def create_symlink(src: str, dest: str, update_func: Callable = None) -> None: 180 | """Creates a symlink between two directories.""" 181 | if update_func: 182 | update_func("Creating symlink between {} and {}".format(src, dest)) 183 | 184 | logger.debug("Creating symlink between {} and {}".format(src, dest)) 185 | 186 | # os.symlink(src, dest) 187 | # TODO, reimplement with Win32 188 | 189 | # delete an existing destination 190 | if exists(dest): 191 | if is_symlink(dest): 192 | logger.debug("Symlink already exists") 193 | delete_symlink(dest) 194 | else: 195 | logger.debug("Folder already exists") 196 | delete_folder(dest) 197 | 198 | # create the link 199 | subprocess.run( 200 | ["cmd", "/c", "mklink", "/J", dest, src], 201 | check=True, 202 | stdout=subprocess.DEVNULL, 203 | stderr=subprocess.DEVNULL, 204 | ) 205 | 206 | 207 | def delete_symlink(path: str, update_func: Callable = None) -> None: 208 | """Deletes a symlink without removing the directory it is linked to.""" 209 | if update_func: 210 | update_func("Deleting symlink {} ".format(path)) 211 | 212 | logger.debug("Deleting symlink {} ".format(path)) 213 | 214 | # os.unlink(path) 215 | # TODO, reimplement with Win32 216 | 217 | # remove the link 218 | subprocess.run( 219 | ["cmd", "/c", "fsutil", "reparsepoint", "delete", path], 220 | check=True, 221 | stdout=subprocess.DEVNULL, 222 | stderr=subprocess.DEVNULL, 223 | ) 224 | 225 | # delete the empty folder 226 | delete_folder(path) 227 | 228 | 229 | def get_folder_size(folder: str) -> Num: 230 | """Return the size in bytes of a folder, recursively.""" 231 | # logger.debug("Returning size of {} recursively".format(folder)) 232 | 233 | if not os.path.isdir(folder): 234 | logger.warning("Folder {} does not exist".format(folder)) 235 | return 0 236 | 237 | return sum( 238 | os.path.getsize(os.path.join(dirpath, filename)) 239 | for dirpath, _, filenames in os.walk(folder) 240 | for filename in filenames 241 | ) 242 | 243 | 244 | def delete_file(file: str, first: bool = True, update_func: Callable = None) -> None: 245 | """Deletes a file if it exists.""" 246 | file = fix_path(file) 247 | 248 | # check if it exists 249 | if not os.path.isfile(file): 250 | logger.debug("File {} does not exist".format(file)) 251 | return 252 | 253 | try: 254 | logger.debug("Attempting to delete file {}".format(file)) 255 | # try to delete it 256 | if update_func: 257 | update_func("Deleting file {}".format(file)) 258 | os.remove(file) 259 | except PermissionError: 260 | logger.debug("File deletion failed") 261 | # if there is a permission error 262 | if not first: 263 | logger.error("Not first attempt, raising exception") 264 | # if not the first attempt, raise error 265 | raise AccessError(file) 266 | else: 267 | logger.debug("Attempting to fix permissions") 268 | # otherwise, try to fix permissions and try again 269 | fix_permissions(file) 270 | delete_file(file, first=False, update_func=update_func) 271 | except FileNotFoundError as e: 272 | logger.exception(e) 273 | # try again 274 | delete_file(file, first=False, update_func=update_func) 275 | 276 | 277 | def delete_folder( 278 | folder: str, first: bool = True, update_func: Callable = None 279 | ) -> None: 280 | """Deletes a folder if it exists.""" 281 | folder = fix_path(folder) 282 | 283 | # check if it exists 284 | if not os.path.isdir(folder): 285 | logger.debug("Folder {} does not exist".format(folder)) 286 | return 287 | 288 | try: 289 | logger.debug("Attempting to delete folder {}".format(folder)) 290 | # try to delete it 291 | if update_func: 292 | update_func("Deleting folder {}".format(folder)) 293 | shutil.rmtree(folder, ignore_errors=False) 294 | except PermissionError: 295 | logger.info("Folder deletion failed") 296 | # if there is a permission error 297 | if not first: 298 | logger.error("Not first attempt, raising exception") 299 | # if not the first attempt, raise error 300 | raise AccessError(folder) 301 | else: 302 | logger.debug("Attempting to fix permissions") 303 | # otherwise, try to fix permissions and try again 304 | fix_permissions_recursive(folder, update_func=update_func) 305 | delete_folder(folder, first=False, update_func=update_func) 306 | except FileNotFoundError as e: 307 | logger.exception(e) 308 | # try again 309 | delete_folder(folder, first=False, update_func=update_func) 310 | 311 | 312 | def copy_folder(src: str, dest: str, update_func: Callable = None) -> None: 313 | """Copies a folder if it exists.""" 314 | src = fix_path(src) 315 | dest = fix_path(dest) 316 | 317 | logger.debug("Copying folder {} to {}".format(src, dest)) 318 | # check if it exists 319 | if not os.path.isdir(src): 320 | logger.warning("Source folder {} does not exist".format(src)) 321 | return 322 | 323 | if check_same_path(src, dest): 324 | logger.warning( 325 | "Source folder {} is same as destination folder {}".format(src, dest) 326 | ) 327 | return 328 | 329 | delete_folder(dest, update_func=update_func) 330 | 331 | # copy the directory 332 | if update_func: 333 | update_func( 334 | "Copying {} to {} ({})".format( 335 | src, dest, human_readable_size(get_folder_size(src)) 336 | ) 337 | ) 338 | 339 | logger.debug("Attempting to copy folder {} to {}".format(src, dest)) 340 | shutil.copytree(src, dest, symlinks=True) 341 | 342 | 343 | def move_folder(src: str, dest: str, update_func: Callable = None) -> None: 344 | """Copies a folder and deletes the original.""" 345 | src = fix_path(src) 346 | dest = fix_path(dest) 347 | 348 | if check_same_path(src, dest): 349 | logger.warning( 350 | "Source folder {} is same as destination folder {}".format(src, dest) 351 | ) 352 | return 353 | 354 | logger.debug("Moving folder {} to {}".format(src, dest)) 355 | copy_folder(src, dest, update_func=update_func) 356 | delete_folder(src, update_func=update_func) 357 | 358 | 359 | def resolve_symlink(path: str) -> str: 360 | """Resolves symlinks in a directory path.""" 361 | 362 | if is_symlink(path): 363 | return read_symlink(path) 364 | else: 365 | return path 366 | 367 | 368 | def create_tmp_folder(update_func: Callable = None) -> None: 369 | """Deletes existing temp folder if it exists and creates a new one.""" 370 | delete_folder(TEMP_FOLDER, update_func=update_func) 371 | logger.debug("Creating temp folder {}".format(TEMP_FOLDER)) 372 | os.makedirs(TEMP_FOLDER) 373 | 374 | 375 | def get_last_open_folder() -> str: 376 | """Gets the last opened directory from the config file.""" 377 | succeeded, value = config.get_key_value(config.LAST_OPEN_FOLDER_KEY, path=True) 378 | if not succeeded or not os.path.isdir(value): 379 | # if mod install folder could not be loaded from config 380 | value = os.path.abspath(os.path.join(os.path.expanduser("~"), "Downloads")) 381 | config.set_key_value(config.LAST_OPEN_FOLDER_KEY, value, path=True) 382 | 383 | return fix_path(value) 384 | 385 | 386 | def get_mod_install_folder() -> str: 387 | """Gets the current mod install folder value from the config file.""" 388 | succeeded, value = config.get_key_value(config.MOD_INSTALL_FOLDER_KEY, path=True) 389 | if not succeeded: 390 | # if mod install folder could not be loaded from config 391 | value = os.path.abspath(os.path.join(config.BASE_FOLDER, "modCache")) 392 | config.set_key_value(config.MOD_INSTALL_FOLDER_KEY, value, path=True) 393 | 394 | mod_install_folder = fix_path(value) 395 | 396 | if not os.path.exists(mod_install_folder): 397 | logger.debug("Creating mod install folder {}".format(mod_install_folder)) 398 | os.makedirs(mod_install_folder) 399 | 400 | return mod_install_folder 401 | 402 | 403 | def extract_archive(archive: str, folder: str, update_func: Callable = None) -> str: 404 | """Extracts an archive file and returns the output path.""" 405 | if update_func: 406 | update_func( 407 | "Extracting archive {} ({})".format( 408 | archive, human_readable_size(os.path.getsize(archive)) 409 | ) 410 | ) 411 | 412 | logger.debug("Extracting archive {} to {}".format(archive, folder)) 413 | 414 | try: 415 | # rar archives will not work without this 416 | os.makedirs(folder, exist_ok=True) 417 | # run the extraction program 418 | patoolib.extract_archive( 419 | archive, 420 | outdir=folder, 421 | verbosity=ARCHIVE_VERBOSITY, 422 | interactive=ARCHIVE_INTERACTIVE, 423 | ) 424 | 425 | except patoolib.util.PatoolError as e: 426 | logger.exception("Unable to extract archive") 427 | raise ExtractionError(str(e)) 428 | 429 | return folder 430 | 431 | 432 | def create_archive(folder: str, archive: str, update_func: Callable = None) -> str: 433 | """Creates an archive file and returns the new path.""" 434 | uncomp_size = human_readable_size(get_folder_size(folder)) 435 | 436 | if update_func: 437 | update_func( 438 | "Creating archive {} ({} uncompressed).\n This will almost certainly take a while.".format( 439 | archive, uncomp_size 440 | ) 441 | ) 442 | 443 | # delete the archive if it already exists, 444 | # as patoolib will refuse to overwrite an existing archive 445 | delete_file(archive, update_func=update_func) 446 | 447 | logger.debug("Creating archive {}".format(archive)) 448 | # create the archive 449 | try: 450 | # this expects files/folders in a list 451 | patoolib.create_archive( 452 | archive, 453 | (folder,), 454 | verbosity=ARCHIVE_VERBOSITY, 455 | interactive=ARCHIVE_INTERACTIVE, 456 | ) 457 | except patoolib.util.PatoolError as e: 458 | logger.exception("Unable to create archive") 459 | raise ExtractionError(str(e)) 460 | 461 | return archive 462 | 463 | 464 | def hash_file(filename: str, update_func: Callable = None) -> str: 465 | """Returns the hash of a file.""" 466 | # https://stackoverflow.com/questions/22058048/hashing-a-file-in-python 467 | logger.debug("Hashing {}".format(filename)) 468 | 469 | if update_func: 470 | update_func("Hashing {}".format(filename)) 471 | 472 | h = hashlib.sha256() 473 | with open(filename, "rb", buffering=0) as f: 474 | b = bytearray(128 * 1024) 475 | mv = memoryview(b) 476 | for n in iter(lambda: f.readinto(mv), 0): 477 | h.update(mv[:n]) 478 | return h.hexdigest() 479 | 480 | 481 | def write_hash(folder: str, h: str) -> None: 482 | """Writes the hash of a file to the given folder.""" 483 | filename = os.path.join(folder, HASH_FILE) 484 | with open(filename, "w") as f: 485 | f.write(h) 486 | 487 | 488 | def read_hash(folder: str) -> Union[None, str]: 489 | """Reads the hash of the given folder.""" 490 | filename = os.path.join(folder, HASH_FILE) 491 | if not os.path.isfile(filename): 492 | logger.debug("No hash found") 493 | return None 494 | 495 | with open(filename, "r") as f: 496 | return f.read() 497 | -------------------------------------------------------------------------------- /src/main/python/lib/flight_sim.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import functools 3 | import json 4 | import os 5 | from typing import Callable, List, Tuple, Union 6 | 7 | from loguru import logger 8 | 9 | import lib.config as config 10 | import lib.files as files 11 | import lib.thread as thread 12 | 13 | 14 | class LayoutError(Exception): 15 | """Raised when a layout.json file cannot be parsed for a mod.""" 16 | 17 | 18 | class NoLayoutError(Exception): 19 | """Raised when a layout.json file cannot be found for a mod.""" 20 | 21 | 22 | class ManifestError(Exception): 23 | """Raised when a manifest.json file cannot be parsed for a mod.""" 24 | 25 | 26 | class NoManifestError(Exception): 27 | """Raised when a manifest.json file cannot be found for a mod.""" 28 | 29 | 30 | class NoModsError(Exception): 31 | """Raised when no mods are found in an archive.""" 32 | 33 | 34 | class flight_sim: 35 | def __init__(self) -> None: 36 | self.sim_packages_folder = "" 37 | 38 | def parse_user_cfg(self, sim_folder: str = None, filename: str = None) -> str: 39 | """Parses the given UserCfg.opt file. 40 | This finds the installed packages path and returns the path as a string.""" 41 | 42 | logger.debug("Parsing UserCfg.opt file") 43 | 44 | if sim_folder: 45 | filename = os.path.join(sim_folder, "UserCfg.opt") 46 | 47 | installed_packages_path = "" 48 | 49 | with open(filename, "r", encoding="utf8") as fp: # type: ignore 50 | for line in fp: 51 | if line.startswith("InstalledPackagesPath"): 52 | logger.debug("Found InstalledPackagesPath line: {}".format(line)) 53 | installed_packages_path = line 54 | 55 | # splits the line once, and takes the second instance 56 | installed_packages_path = installed_packages_path.split(" ", 1)[1].strip() 57 | # normalize the string 58 | installed_packages_path = installed_packages_path.strip('"').strip("'") 59 | # evaluate the path 60 | installed_packages_path = os.path.realpath(installed_packages_path) # type: ignore 61 | 62 | logger.debug("Path parsed: {}".format(installed_packages_path)) 63 | 64 | return installed_packages_path 65 | 66 | def is_sim_folder(self, folder: str) -> bool: 67 | """Returns if FlightSimulator.CFG exists inside the given directory. 68 | Not a perfect test, but a solid guess.""" 69 | logger.debug("Testing if {} is main MSFS folder".format(folder)) 70 | try: 71 | status = os.path.isfile(os.path.join(folder, "FlightSimulator.CFG")) 72 | logger.debug("Folder {} is main MSFS folder: {}".format(folder, status)) 73 | return status 74 | except Exception: 75 | logger.exception("Checking sim folder status failed") 76 | return False 77 | 78 | def is_sim_packages_folder(self, folder: str) -> bool: 79 | """Returns whether the given folder is the FS2020 packages folder. 80 | Not a perfect test, but a decent guess.""" 81 | # test if the folder above it contains both 'Community' and 'Official' 82 | logger.debug("Testing if {} is MSFS sim packages folder".format(folder)) 83 | try: 84 | packages_folders = files.listdir_dirs(folder) 85 | status = "Official" in packages_folders and "Community" in packages_folders 86 | logger.debug( 87 | "Folder {} is MSFS sim packages folder: {}".format(folder, status) 88 | ) 89 | return status 90 | except Exception: 91 | logger.exception("Checking sim packages folder status failed") 92 | return False 93 | 94 | def find_sim_packages_folder(self) -> Tuple[bool, Union[str, None]]: 95 | """Attempts to automatically locate the install location of FS Packages. 96 | Returns if reading from config file was successful, and 97 | returns absolute sim folder path. Otherwise, returns None if it fails.""" 98 | logger.debug("Attempting to automatically locate simulator path") 99 | 100 | # first try to read from the config file 101 | logger.debug("Trying to find simulator path from config file") 102 | succeed, value = config.get_key_value(config.SIM_FOLDER_KEY, path=True) 103 | if succeed and self.is_sim_packages_folder(value): 104 | logger.debug("Config file sim path found and valid") 105 | return (True, value) 106 | 107 | # steam detection 108 | logger.debug("Trying to find simulator path from default Steam install") 109 | steam_folder = os.path.join(os.getenv("APPDATA"), "Microsoft Flight Simulator") # type: ignore 110 | if self.is_sim_folder(steam_folder): 111 | steam_packages_folder = os.path.join( 112 | self.parse_user_cfg(sim_folder=steam_folder) 113 | ) 114 | if self.is_sim_packages_folder(steam_packages_folder): 115 | logger.debug("Steam sim path found and valid") 116 | return (False, steam_packages_folder) 117 | 118 | # ms store detection 119 | logger.debug("Trying to find simulator path from default MS Store install") 120 | ms_store_folder = os.path.join( 121 | os.getenv("LOCALAPPDATA"), 122 | "Packages", 123 | "Microsoft.FlightSimulator_8wekyb3d8bbwe", 124 | "LocalCache", 125 | ) # type: ignore 126 | if self.is_sim_folder(ms_store_folder): 127 | ms_store_packages_folder = os.path.join( 128 | self.parse_user_cfg(sim_folder=ms_store_folder) 129 | ) 130 | if self.is_sim_packages_folder(ms_store_packages_folder): 131 | logger.debug("MS Store sim path found and valid") 132 | return (False, ms_store_packages_folder) 133 | 134 | # boxed edition detection 135 | logger.debug("Trying to find simulator path from default boxed edition install") 136 | boxed_packages_folder = os.path.join(os.getenv("LOCALAPPDATA"), "MSFSPackages") # type: ignore 137 | if self.is_sim_packages_folder(boxed_packages_folder): 138 | logger.debug("Boxed edition sim path found and valid") 139 | return (False, boxed_packages_folder) 140 | 141 | # last ditch steam detection #1 142 | logger.debug("Trying to find simulator path from last-ditch Steam install #1") 143 | steam_folder = os.path.join( 144 | os.getenv("PROGRAMFILES(x86)"), 145 | "Steam", 146 | "steamapps", 147 | "common", 148 | "MicrosoftFlightSimulator", 149 | ) # type: ignore 150 | if self.is_sim_folder(steam_folder): 151 | steam_packages_folder = os.path.join( 152 | self.parse_user_cfg(sim_folder=steam_folder) 153 | ) 154 | if self.is_sim_packages_folder(steam_packages_folder): 155 | logger.debug("Last-ditch #1 Steam sim path found and valid") 156 | return (False, steam_packages_folder) 157 | 158 | # last ditch steam detection #2 159 | logger.debug("Trying to find simulator path from last-ditch Steam install #2") 160 | steam_folder = os.path.join( 161 | os.getenv("PROGRAMFILES(x86)"), 162 | "Steam", 163 | "steamapps", 164 | "common", 165 | "Chucky", 166 | ) # type: ignore 167 | if self.is_sim_folder(steam_folder): 168 | steam_packages_folder = os.path.join( 169 | self.parse_user_cfg(sim_folder=steam_folder) 170 | ) 171 | if self.is_sim_packages_folder(steam_packages_folder): 172 | logger.debug("Last-ditch #2 Steam sim path found and valid") 173 | return (False, steam_packages_folder) 174 | 175 | # fail 176 | logger.warning("Simulator path could not be automatically determined") 177 | return (False, None) 178 | 179 | def clear_mod_cache(self) -> None: 180 | """Clears the cache of the mod parsing functions.""" 181 | self.parse_mod_layout.cache_clear() 182 | self.parse_mod_files.cache_clear() 183 | self.parse_mod_manifest.cache_clear() 184 | self.get_mod_folder.cache_clear() 185 | 186 | @functools.lru_cache() 187 | def get_sim_mod_folder(self) -> str: 188 | """Returns the path to the community packages folder inside Flight Simulator. 189 | Tries to resolve symlinks in every step of the path.""" 190 | # logger.debug("Determining path for sim community packages folder") 191 | 192 | return files.fix_path( 193 | files.resolve_symlink(os.path.join(self.sim_packages_folder, "Community")) 194 | ) 195 | 196 | @functools.lru_cache() 197 | def get_sim_official_folder(self) -> str: 198 | """Returns the path to the official packages folder inside Flight Simulator. 199 | Tries to resolve symlinks in every step of the path.""" 200 | # logger.debug("Determining path for sim official packages folder") 201 | 202 | # path to official packages folder 203 | official_packages = files.resolve_symlink( 204 | os.path.join(self.sim_packages_folder, "Official") 205 | ) 206 | # choose folder inside 207 | store = files.listdir_dirs(official_packages)[0] 208 | 209 | return files.fix_path( 210 | files.resolve_symlink(os.path.join(official_packages, store)) 211 | ) 212 | 213 | @functools.lru_cache() 214 | def get_mod_folder(self, folder: str, enabled: bool) -> str: 215 | """Returns path to mod folder given folder name and enabled status.""" 216 | # logger.debug("Determining path for mod {}, enabled: {}".format(folder, enabled)) 217 | 218 | if enabled: 219 | mod_folder = os.path.join(self.get_sim_mod_folder(), folder) 220 | else: 221 | mod_folder = os.path.join(files.get_mod_install_folder(), folder) 222 | 223 | # logger.debug("Final mod path: {}".format(mod_folder)) 224 | 225 | return files.fix_path(mod_folder) 226 | 227 | @functools.lru_cache() 228 | def parse_mod_layout(self, mod_folder: str) -> dict: 229 | """Builds the mod files info as a dictionary. Parsed from the layout.json.""" 230 | logger.debug("Parsing layout for {}".format(mod_folder)) 231 | 232 | layout_path = files.resolve_symlink(os.path.join(mod_folder, "layout.json")) 233 | 234 | if not os.path.isfile(layout_path): 235 | logger.error("No layout.json found") 236 | raise NoLayoutError(mod_folder) 237 | 238 | try: 239 | with open(layout_path, "r", encoding="utf8") as f: 240 | data = json.load(f) 241 | except Exception as e: 242 | if hasattr(e, "winerror"): 243 | logger.exception("WinError: {}".format(e.winerror)) # type: ignore 244 | logger.exception("layout.json could not be parsed") 245 | raise LayoutError(e) 246 | 247 | return data["content"] 248 | 249 | @functools.lru_cache() 250 | def parse_mod_files(self, mod_folder: str) -> List[dict]: 251 | """Builds the mod files info as a dictionary. Parsed from the fielsystem.""" 252 | logger.debug("Parsing all mod files for {}".format(mod_folder)) 253 | 254 | data = [] 255 | for root, _, files_ in os.walk(mod_folder): 256 | for file in files_: 257 | data.append( 258 | { 259 | "path": os.path.join(os.path.relpath(root, mod_folder), file), 260 | "size": os.path.getsize(os.path.join(root, file)), 261 | } 262 | ) 263 | 264 | return data 265 | 266 | @functools.lru_cache() 267 | def parse_mod_manifest(self, mod_folder: str, enabled: bool = True) -> dict: 268 | """Builds the mod metadata as a dictionary. Parsed from the manifest.json.""" 269 | logger.debug("Parsing manifest for {}".format(mod_folder)) 270 | 271 | mod_data = {"folder_name": os.path.basename(mod_folder)} 272 | manifest_path = files.resolve_symlink(os.path.join(mod_folder, "manifest.json")) 273 | 274 | if not os.path.isfile(manifest_path): 275 | logger.error("No manifest.json found") 276 | raise NoManifestError(mod_folder) 277 | 278 | try: 279 | with open(manifest_path, "r", encoding="utf8") as f: 280 | data = json.load(f) 281 | except Exception as e: 282 | if hasattr(e, "winerror"): 283 | logger.exception("WinError: {}".format(e.winerror)) # type: ignore 284 | logger.exception("manifest.json could not be opened/parsed") 285 | raise ManifestError(e) 286 | 287 | # manifest data 288 | mod_data["content_type"] = data.get("content_type", "") 289 | mod_data["title"] = data.get("title", "") 290 | mod_data["manufacturer"] = data.get("manufacturer", "") 291 | mod_data["creator"] = data.get("creator", "") 292 | mod_data["version"] = data.get("package_version", "") 293 | mod_data["minimum_game_version"] = data.get("minimum_game_version", "") 294 | 295 | # manifest metadata 296 | # Windows considering moving/copying a file 'creating' it again, 297 | # and not modifying contents 298 | mod_data["time_mod"] = datetime.datetime.fromtimestamp( 299 | os.path.getctime(manifest_path) 300 | ).strftime("%Y-%m-%d %H:%M:%S") 301 | 302 | # convience, often helps to just have this included in the returned result 303 | # and its easier to to do here 304 | mod_data["enabled"] = enabled # type: ignore 305 | mod_data["full_path"] = os.path.abspath(mod_folder) 306 | 307 | return mod_data 308 | 309 | def get_game_version(self) -> str: 310 | """Attempts to guess the game's version. 311 | This is based on the fs-base package and the minimum game version listed.""" 312 | logger.debug("Attempting to determine game version") 313 | version = "???" 314 | # build path to fs-base manifest 315 | fs_base = files.resolve_symlink( 316 | os.path.join(self.get_sim_official_folder(), "fs-base") 317 | ) 318 | # parse it if we guessed correct 319 | if os.path.isdir(fs_base): 320 | data = self.parse_mod_manifest(fs_base) 321 | version = data["minimum_game_version"] 322 | 323 | logger.debug("Game version: {}".format(version)) 324 | return version 325 | 326 | def get_mods( 327 | self, 328 | folders: list, 329 | enabled: bool, 330 | progress_func: Callable = None, 331 | start: int = 0, 332 | ) -> Tuple[list, list]: 333 | """Returns a list of mod folders, and errors encountered.""" 334 | 335 | mods = [] 336 | errors = [] 337 | 338 | for i, folder in enumerate(folders): 339 | if progress_func: 340 | progress_func( 341 | "Loading mods: {}".format(folder), 342 | start + i, 343 | start + len(folders) - 1, 344 | ) 345 | 346 | try: 347 | if not os.listdir(folder): 348 | # if the mod folder is completely empty, just delete it 349 | logger.debug("Deleting empty mod folder") 350 | files.delete_folder(folder) 351 | continue 352 | except FileNotFoundError: 353 | # in the case of a broken symlink, this will trigger an error 354 | # unfortuantely, a os.path.exists or isdir will return true 355 | logger.debug("Deleting broken symlink") 356 | files.delete_symlink(folder) 357 | continue 358 | 359 | # parse each mod 360 | try: 361 | mods.append(self.parse_mod_manifest(folder, enabled=enabled)) 362 | except (NoManifestError, ManifestError): 363 | errors.append(folder) 364 | 365 | return mods, errors 366 | 367 | def get_all_mods(self, progress_func: Callable = None) -> Tuple[list, list]: 368 | """Returns data and errors for all mods.""" 369 | 370 | enabled_mod_folders = files.listdir_dirs( 371 | self.get_sim_mod_folder(), full_paths=True 372 | ) 373 | disabled_mod_folders = files.listdir_dirs( 374 | files.get_mod_install_folder(), full_paths=True 375 | ) 376 | 377 | for folder in enabled_mod_folders: 378 | # remove duplicate folders from disabled list if there is a symlink for them 379 | if files.is_symlink(folder): 380 | install_folder = os.path.join( 381 | files.get_mod_install_folder(), os.path.basename(folder) 382 | ) 383 | if install_folder in disabled_mod_folders: 384 | disabled_mod_folders.remove(install_folder) 385 | 386 | enabled_mod_data, enabled_mod_errors = self.get_mods( 387 | enabled_mod_folders, enabled=True, progress_func=progress_func 388 | ) 389 | disabled_mod_data, disabled_mod_errors = self.get_mods( 390 | disabled_mod_folders, 391 | enabled=False, 392 | progress_func=progress_func, 393 | start=len(enabled_mod_data) - 1, 394 | ) 395 | 396 | return ( 397 | enabled_mod_data + disabled_mod_data, 398 | enabled_mod_errors + disabled_mod_errors, 399 | ) 400 | 401 | def extract_mod_archive(self, archive: str, update_func: Callable = None) -> str: 402 | """Extracts an archive file into a temp directory and returns the new path.""" 403 | # determine the base name of the archive 404 | basefilename = os.path.splitext(os.path.basename(archive))[0] 405 | 406 | # build the name of the extracted folder 407 | extracted_archive = os.path.join(files.TEMP_FOLDER, basefilename) 408 | 409 | # hash the archive 410 | # archive_hash = files.hash_file(archive, update_func=update_func) 411 | 412 | # check hash of archive versus a possible existing extracted copy 413 | # if archive_hash == files.read_hash(extracted_archive): 414 | # logger.debug("Hashes match, using already extracted copy") 415 | # return extracted_archive 416 | 417 | # logger.debug("Hash mismatch, extracting") 418 | 419 | # create a temp directory if it does not exist 420 | files.create_tmp_folder(update_func=update_func) 421 | 422 | # extract archive 423 | files.extract_archive(archive, extracted_archive, update_func=update_func) 424 | 425 | # write the hash 426 | # write_hash(extracted_archive, archive_hash) 427 | 428 | # return 429 | return extracted_archive 430 | 431 | def determine_mod_folders(self, folder: str, update_func: Callable = None) -> list: 432 | """Walks a directory to find the folder(s) with a manifest.json file in them.""" 433 | logger.debug("Locating mod folders inside {}".format(folder)) 434 | mod_folders = [] 435 | 436 | if update_func: 437 | update_func("Locating mods inside {}".format(folder)) 438 | 439 | # check the root folder for a manifest 440 | if os.path.isfile(os.path.join(folder, "manifest.json")): 441 | logger.debug("Mod found {}".format(os.path.join(folder))) 442 | mod_folders.append(os.path.join(folder)) 443 | 444 | for root, dirs, _ in os.walk(folder): 445 | # go through each directory and check for the manifest 446 | for d in dirs: 447 | if os.path.isfile(os.path.join(root, d, "manifest.json")): 448 | logger.debug("Mod found {}".format(os.path.join(root, d))) 449 | mod_folders.append(os.path.join(root, d)) 450 | 451 | if not mod_folders: 452 | logger.error("No mods found") 453 | raise NoModsError(folder) 454 | 455 | return mod_folders 456 | 457 | def install_mods( 458 | self, 459 | folder: str, 460 | update_func: Callable = None, 461 | delete: bool = False, 462 | percent_func: Callable = None, 463 | ) -> list: 464 | """Extracts and installs a new mod.""" 465 | logger.debug("Installing mod {}".format(folder)) 466 | 467 | # determine the mods inside the extracted archive 468 | mod_folders = self.determine_mod_folders(folder, update_func=update_func) 469 | 470 | installed_mods = [] 471 | 472 | for i, mod_folder in enumerate(mod_folders): 473 | # get the base folder name 474 | base_mod_folder = os.path.basename(mod_folder) 475 | install_folder = os.path.join( 476 | files.get_mod_install_folder(), base_mod_folder 477 | ) 478 | dest_folder = os.path.join(self.get_sim_mod_folder(), base_mod_folder) 479 | 480 | # copy mod to install dir 481 | if delete: 482 | files.move_folder(mod_folder, install_folder, update_func=update_func) 483 | else: 484 | files.copy_folder(mod_folder, install_folder, update_func=update_func) 485 | 486 | # create the symlink to the sim 487 | files.create_symlink(install_folder, dest_folder) 488 | 489 | if percent_func: 490 | percent_func((i, len(mod_folders))) 491 | 492 | installed_mods.append(base_mod_folder) 493 | 494 | # clear the cache of the mod function 495 | self.clear_mod_cache() 496 | # return installed mods list 497 | return installed_mods 498 | 499 | def install_mod_archive( 500 | self, 501 | mod_archive: str, 502 | update_func: Callable = None, 503 | percent_func: Callable = None, 504 | ) -> list: 505 | """Extracts and installs a new mod.""" 506 | logger.debug("Installing mod {}".format(mod_archive)) 507 | # extract the archive 508 | extracted_archive = self.extract_mod_archive( 509 | mod_archive, update_func=update_func 510 | ) 511 | 512 | return self.install_mods( 513 | extracted_archive, 514 | update_func=update_func, 515 | delete=False, 516 | percent_func=percent_func, 517 | ) 518 | 519 | def uninstall_mod(self, folder: str, update_func: Callable = None) -> bool: 520 | """Uninstalls a mod.""" 521 | logger.debug("Uninstalling mod {}".format(folder)) 522 | # delete folder 523 | files.delete_folder(folder, update_func=update_func) 524 | return True 525 | 526 | def enable_mod(self, folder: str, update_func: Callable = None) -> bool: 527 | """Creates symlink to flight sim install.""" 528 | logger.debug("Enabling mod {}".format(folder)) 529 | src_folder = self.get_mod_folder(folder, enabled=False) 530 | dest_folder = self.get_mod_folder(folder, enabled=True) 531 | 532 | # create symlink to sim 533 | files.create_symlink(src_folder, dest_folder, update_func=update_func) 534 | return True 535 | 536 | def disable_mod(self, folder: str, update_func: Callable = None) -> bool: 537 | """Deletes symlink/copies mod folder into mod install location.""" 538 | logger.debug("Disabling mod {}".format(folder)) 539 | src_folder = self.get_mod_folder(folder, enabled=True) 540 | dest_folder = self.get_mod_folder(folder, enabled=False) 541 | 542 | if files.is_symlink(src_folder): 543 | # delete symlink 544 | files.delete_symlink(src_folder, update_func=update_func) 545 | else: 546 | # move mod to mod install location 547 | files.move_folder(src_folder, dest_folder, update_func=update_func) 548 | 549 | return True 550 | 551 | def create_backup(self, archive: str, update_func: Callable = None) -> str: 552 | """Creates a backup of all enabled mods.""" 553 | return files.create_archive( 554 | self.get_sim_mod_folder(), archive, update_func=update_func 555 | ) 556 | 557 | def move_mod_install_folder( 558 | self, src: str, dest: str, update_func: Callable = None 559 | ) -> None: 560 | """Moves the mod install folder.""" 561 | logger.debug("Moving mod install folder from {} to {}".format(src, dest)) 562 | # first, build a list of the currently enabled mods 563 | enabled_mod_folders = files.listdir_dirs(self.get_sim_mod_folder()) 564 | 565 | # move the install folder 566 | files.move_folder(src, dest, update_func=update_func) 567 | 568 | # set new config value 569 | config.set_key_value(config.MOD_INSTALL_FOLDER_KEY, dest, path=True) 570 | # clear the cache 571 | self.clear_mod_cache() 572 | config.get_key_value.cache_clear() 573 | 574 | # now, go through mods in the install folder and re-enable them 575 | # if they were enabled before. 576 | moved_mod_folders = files.listdir_dirs(dest) 577 | 578 | for mod_folder in moved_mod_folders: 579 | if mod_folder in enabled_mod_folders: 580 | self.enable_mod(mod_folder, update_func=update_func) 581 | 582 | 583 | class install_mods_thread(thread.base_thread): 584 | """Setup a thread to install mods with and not block the main thread.""" 585 | 586 | def __init__(self, flight_sim_handle: flight_sim, extracted_archive: str) -> None: 587 | """Initialize the mod installer thread.""" 588 | logger.debug("Initialzing mod installer thread") 589 | function = lambda: flight_sim_handle.install_mods( 590 | extracted_archive, 591 | update_func=self.activity_update.emit, # type: ignore 592 | ) 593 | thread.base_thread.__init__(self, function) 594 | 595 | 596 | class install_mod_archive_thread(thread.base_thread): 597 | """Setup a thread to install mod archive with and not block the main thread.""" 598 | 599 | def __init__(self, flight_sim_handle: flight_sim, mod_archive: str) -> None: 600 | """Initialize the mod archive installer thread.""" 601 | logger.debug("Initialzing mod archive installer thread") 602 | function = lambda: flight_sim_handle.install_mod_archive( 603 | mod_archive, 604 | update_func=self.activity_update.emit, # type: ignore 605 | percent_func=self.percent_update.emit, # type: ignore 606 | ) 607 | thread.base_thread.__init__(self, function) 608 | 609 | 610 | class uninstall_mod_thread(thread.base_thread): 611 | """Setup a thread to uninstall mods with and not block the main thread.""" 612 | 613 | def __init__( 614 | self, 615 | flight_sim_handle: flight_sim, 616 | folder: str, 617 | ) -> None: 618 | """Initialize the mod uninstaller thread.""" 619 | logger.debug("Initialzing mod uninstaller thread") 620 | function = lambda: flight_sim_handle.uninstall_mod( 621 | folder, 622 | update_func=self.activity_update.emit, # type: ignore 623 | ) 624 | thread.base_thread.__init__(self, function) 625 | 626 | 627 | class enable_mod_thread(thread.base_thread): 628 | """Setup a thread to enable mods with and not block the main thread.""" 629 | 630 | def __init__(self, flight_sim_handle: flight_sim, folder: str) -> None: 631 | """Initialize the mod enabler thread.""" 632 | logger.debug("Initialzing mod enabler thread") 633 | function = lambda: flight_sim_handle.enable_mod( 634 | folder, update_func=self.activity_update.emit # type: ignore 635 | ) 636 | thread.base_thread.__init__(self, function) 637 | 638 | 639 | class disable_mod_thread(thread.base_thread): 640 | """Setup a thread to disable mods with and not block the main thread.""" 641 | 642 | def __init__(self, flight_sim_handle: flight_sim, archive: str) -> None: 643 | """Initialize the mod disabler thread.""" 644 | logger.debug("Initialzing mod disabler thread") 645 | function = lambda: flight_sim_handle.disable_mod( 646 | archive, update_func=self.activity_update.emit # type: ignore 647 | ) 648 | thread.base_thread.__init__(self, function) 649 | 650 | 651 | class create_backup_thread(thread.base_thread): 652 | """Setup a thread to create backup with and not block the main thread.""" 653 | 654 | def __init__(self, flight_sim_handle: flight_sim, archive: str) -> None: 655 | """Initialize the backup creator thread.""" 656 | logger.debug("Initialzing backup creator thread") 657 | function = lambda: flight_sim_handle.create_backup( 658 | archive, update_func=self.activity_update.emit # type: ignore 659 | ) 660 | thread.base_thread.__init__(self, function) 661 | 662 | 663 | class move_mod_install_folder_thread(thread.base_thread): 664 | """Setup a thread to move the mod install folder and not block the main thread.""" 665 | 666 | def __init__(self, flight_sim_handle: flight_sim, src: str, dest: str): 667 | """Initialize the mod install folder mover thread.""" 668 | logger.debug("Initialzing mod install folder mover thread") 669 | function = lambda: flight_sim_handle.move_mod_install_folder(src, dest, update_func=self.activity_update.emit) # type: ignore 670 | thread.base_thread.__init__(self, function) 671 | -------------------------------------------------------------------------------- /src/main/python/lib/resize.py: -------------------------------------------------------------------------------- 1 | from PySide2.QtCore import QSize 2 | from PySide2.QtWidgets import QWidget 3 | 4 | QWIDGETSIZE_MAX = (1 << 24) - 1 5 | 6 | 7 | def max_resize(widget: QWidget, size: QSize) -> None: 8 | """Resizes a widget to the given size while setting limits. 9 | Takes a widget class, and a QSize.""" 10 | # limit max programmatic resize 11 | widget.setMaximumHeight(700) 12 | widget.setMaximumWidth(2000) 13 | 14 | # resize 15 | # pylance doesn't connect to the correct function definition 16 | widget.resize(size) # type: ignore 17 | 18 | # reset max height to default 19 | widget.setMaximumHeight(QWIDGETSIZE_MAX) 20 | widget.setMaximumWidth(QWIDGETSIZE_MAX) 21 | -------------------------------------------------------------------------------- /src/main/python/lib/theme.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime.application_context.PySide2 import ApplicationContext 2 | from loguru import logger 3 | 4 | import lib.config as config 5 | 6 | FS_THEME = "fs" 7 | 8 | 9 | def get_theme() -> bool: 10 | """Returns True if FS theme is selected, otherwise returns False.""" 11 | logger.debug("Getting application theme from config file") 12 | succeed, value = config.get_key_value(config.THEME_KEY) 13 | status = succeed and value == FS_THEME 14 | logger.debug("FS theme is selected: {}".format(status)) 15 | return status 16 | 17 | 18 | def set_theme(appctxt: ApplicationContext, fs_theme: bool) -> None: 19 | """Writes theme selection to config file and sets the app stylesheet.""" 20 | logger.debug("Writing theme selection to config file") 21 | if fs_theme: 22 | config.set_key_value(config.THEME_KEY, FS_THEME) 23 | 24 | # apply stylesheet 25 | logger.debug( 26 | "Applying application stylesheet {}".format( 27 | appctxt.get_resource("fs_style.qss") 28 | ) 29 | ) 30 | stylesheet = appctxt.get_resource("fs_style.qss") 31 | appctxt.app.setStyleSheet(open(stylesheet, "r").read()) 32 | else: 33 | config.set_key_value(config.THEME_KEY, "None") 34 | 35 | logger.debug("Clearing application stylesheet") 36 | appctxt.app.setStyleSheet("") 37 | -------------------------------------------------------------------------------- /src/main/python/lib/thread.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Callable 3 | 4 | import PySide2.QtCore as QtCore 5 | from loguru import logger 6 | 7 | 8 | class base_thread(QtCore.QThread): 9 | """Base thread class.""" 10 | 11 | activity_update = QtCore.Signal(object) 12 | percent_update = QtCore.Signal(object) 13 | finished = QtCore.Signal(object) 14 | failed = QtCore.Signal(Exception) 15 | 16 | def __init__(self, function: Callable) -> None: 17 | """Initialize the thread.""" 18 | self.function = function 19 | QtCore.QThread.__init__(self) 20 | 21 | def run(self) -> None: 22 | """Start thread.""" 23 | logger.debug("Running thread") 24 | try: 25 | output = self.function() 26 | self.finished.emit(output) # type: ignore 27 | except Exception as e: 28 | self.failed.emit(e) # type: ignore 29 | logger.debug("Thread completed") 30 | 31 | 32 | @contextmanager 33 | def thread_wait( 34 | finished_signal: QtCore.Signal, 35 | timeout: int = 600000, 36 | finish_func: Callable = None, 37 | failed_signal: QtCore.Signal = None, 38 | failed_func: Callable = None, 39 | update_signal: QtCore.Signal = None, 40 | ): 41 | """Prevent the primary event loop from progressing without blocking GUI events. 42 | This progresses until the given signal is emitted or the timeout reached.""" 43 | # https://www.jdreaver.com/posts/2014-07-03-waiting-for-signals-pyside-pyqt.html 44 | # create a new event loop 45 | loop = QtCore.QEventLoop() 46 | 47 | # create a finished quit function 48 | def finished_quit() -> None: 49 | loop.quit() 50 | # stop timer 51 | if timer: 52 | timer.stop() 53 | 54 | # connect the finished signal to loop quit 55 | finished_signal.connect(finished_quit) # type: ignore 56 | 57 | # if an optional finish function is provided, also connect that signal to it 58 | if finish_func: 59 | finished_signal.connect(finish_func) # type: ignore 60 | 61 | timer = None 62 | 63 | # create a timeout quit function 64 | def timeout_quit() -> None: 65 | loop.exit(1) 66 | logger.error("Timeout reached") 67 | 68 | if timeout is not None: 69 | # setup a timeout quit 70 | timer = QtCore.QTimer() 71 | timer.timeout.connect(timeout_quit) # type: ignore 72 | timer.setSingleShot(True) 73 | timer.start(timeout) 74 | 75 | if update_signal: 76 | update_signal.connect(lambda: timer.start(timeout)) # type: ignore 77 | 78 | # create a failed quit function 79 | def failed_quit(err) -> None: 80 | # exit loop and 81 | loop.exit(1) 82 | # stop timer 83 | if timer: 84 | timer.stop() 85 | # call provided failure function 86 | failed_func(err) 87 | 88 | # if an optional failure function is provided, also connect that signal to it 89 | if failed_signal and failed_func: 90 | failed_signal.connect(failed_quit) # type: ignore 91 | 92 | # do 93 | yield 94 | 95 | # execute the new event loop 96 | loop.exec_() 97 | -------------------------------------------------------------------------------- /src/main/python/lib/type_helper.py: -------------------------------------------------------------------------------- 1 | def is_int(str_: str) -> bool: 2 | """Return if string is an integer""" 3 | try: 4 | int(str_) 5 | return True 6 | except (ValueError, TypeError): 7 | return False 8 | 9 | 10 | def is_bool(str_: str) -> bool: 11 | """Return if string is a boolean""" 12 | try: 13 | str2bool(str_) 14 | return True 15 | except (ValueError, TypeError): 16 | return False 17 | 18 | 19 | def str2bool(str_: str) -> bool: 20 | """Converts string to boolean""" 21 | if not isinstance(str_, str): 22 | raise TypeError("Type {} is not a string".format(type(str_))) 23 | 24 | if str_.lower().startswith("t"): 25 | return True 26 | elif str_.lower().startswith("f"): 27 | return False 28 | else: 29 | raise ValueError("Cannot convert {} to boolean".format(str_)) 30 | -------------------------------------------------------------------------------- /src/main/python/lib/version.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import datetime 3 | import json 4 | import os 5 | import sys 6 | import urllib.request 7 | from typing import Callable, Union 8 | 9 | from fbs_runtime.application_context.PySide2 import ApplicationContext 10 | from loguru import logger 11 | 12 | import lib.config as config 13 | import lib.files as files 14 | import lib.thread as thread 15 | import lib.type_helper as type_helpers 16 | 17 | INSTALLER = "MSFSModManagerSetup.exe" 18 | 19 | 20 | class download_new_version_thread(thread.base_thread): 21 | """Setup a thread to download the new version and not block the main thread.""" 22 | 23 | def __init__(self, asset_url: str) -> None: 24 | """Initialize the version downloader thread.""" 25 | logger.debug("Initialzing version downloader thread") 26 | function = lambda: download_new_version( 27 | asset_url, percent_func=self.percent_update.emit # type: ignore 28 | ) 29 | thread.base_thread.__init__(self, function) 30 | 31 | 32 | def get_version(appctxt: ApplicationContext) -> str: 33 | """Returns the version of the application.""" 34 | logger.debug("Attemping to determine current application version") 35 | try: 36 | logger.debug("Parsing {}".format(appctxt.get_resource("base.json"))) 37 | with open(appctxt.get_resource("base.json"), "r") as fp: 38 | data = json.load(fp) 39 | version = "v{}".format(data["version"]) 40 | logger.debug("Version found: {}".format(version)) 41 | return version 42 | except Exception: 43 | logger.exception("Determining application version failed") 44 | return "v??" 45 | 46 | 47 | def is_installed() -> bool: 48 | """Returns if application is installed version""" 49 | return os.path.isfile(os.path.join(os.getcwd(), "uninstall.exe")) 50 | 51 | 52 | def check_version_config(time_format: str) -> bool: 53 | """Checks config file to see if update check should proceed.""" 54 | # first try to check if updates are supressed 55 | logger.debug("Trying to read never version check from config file") 56 | succeed, value = config.get_key_value(config.NEVER_VER_CHEK_KEY) 57 | if succeed and type_helpers.str2bool(value): 58 | return False 59 | 60 | # first try to read from the config file 61 | logger.debug("Trying to read last version check from config file") 62 | succeed, value = config.get_key_value(config.LAST_VER_CHECK_KEY) 63 | if not succeed: 64 | logger.debug("Unable to read last version check from config file") 65 | return True 66 | 67 | try: 68 | # check if last successful version check was less than a day ago. 69 | # If so, skip 70 | logger.debug("Trying to parse value {} to datetime".format(value)) 71 | last_check = datetime.datetime.strptime(value, time_format) 72 | now = datetime.datetime.now() 73 | 74 | if last_check > (now - datetime.timedelta(days=1)): 75 | logger.debug( 76 | "Current time {} is less than one day from last check {}".format( 77 | now, value 78 | ) 79 | ) 80 | return False 81 | else: 82 | logger.debug( 83 | "Current time {} is more than one day from last check {}".format( 84 | now, value 85 | ) 86 | ) 87 | except ValueError: 88 | logger.exception("Parsing {} to datetime failed".format(value)) 89 | 90 | return True 91 | 92 | 93 | def check_version( 94 | appctxt: ApplicationContext, installed: bool = False 95 | ) -> Union[bool, str]: 96 | """Returns the release URL if a new version is installed. 97 | Otherwise, returns False.""" 98 | logger.debug("Checking if a new version is available") 99 | time_format = "%Y-%m-%d %H:%M:%S" 100 | 101 | if not check_version_config(time_format): 102 | return False 103 | 104 | # open the remote url 105 | url = "https://api.github.com/repos/NathanVaughn/msfs-mod-manager/releases/latest" 106 | 107 | try: 108 | logger.debug("Attempting to open url {}".format(url)) 109 | # always will be opening the above hard-coded URL 110 | page = urllib.request.urlopen(url) # nosec 111 | except Exception: 112 | logger.exception("Opening url {} failed".format(url)) 113 | return False 114 | 115 | # read page contents 116 | logger.debug("Reading page contents") 117 | data = page.read() 118 | data = data.decode("utf-8") 119 | 120 | # parse the json 121 | try: 122 | logger.debug("Attemping to parse page contents") 123 | parsed_data = json.loads(data) 124 | remote_version = parsed_data["tag_name"] 125 | 126 | if parsed_data["prerelease"]: 127 | return False 128 | except Exception: 129 | logger.exception("Parsing page contents failed") 130 | return False 131 | 132 | logger.debug("Remote version found is: {}".format(remote_version)) 133 | 134 | # write the config file back out 135 | logger.debug("Writing out last version check time to config file") 136 | config.set_key_value( 137 | config.LAST_VER_CHECK_KEY, 138 | datetime.datetime.strftime(datetime.datetime.now(), time_format), 139 | ) 140 | 141 | # check if remote version is newer than local version 142 | if not (remote_version > get_version(appctxt)): 143 | logger.debug("Remote version is not newer than local version") 144 | return False 145 | 146 | # if so, return release url 147 | logger.debug("Remote version is newer than local version") 148 | download_url = False 149 | 150 | if installed: 151 | # return setup.exe url 152 | for asset in parsed_data["assets"]: 153 | if asset["name"].endswith(".exe"): 154 | download_url = asset["browser_download_url"] 155 | break 156 | else: 157 | # return release url 158 | download_url = parsed_data["html_url"] 159 | 160 | logger.debug("New release url: {}".format(download_url)) 161 | return download_url 162 | 163 | 164 | def download_new_version( 165 | asset_url: str, percent_func: Callable = None 166 | ) -> Union[str, bool]: 167 | """Downloads new installer version. Returns False if download failed.""" 168 | download_path = os.path.join(os.path.expanduser("~"), "Downloads", INSTALLER) 169 | 170 | # delete existing installer if it exists 171 | files.delete_file(download_path) 172 | 173 | def request_hook(block_num: int, block_size: int, total_size: int) -> None: 174 | if total_size > 0: 175 | readsofar = block_num * block_size 176 | percent = readsofar * 100 / total_size 177 | percent_func(percent) 178 | 179 | # download file 180 | try: 181 | logger.debug("Attempting to download url {}".format(asset_url)) 182 | if percent_func: 183 | # this update function is for a percentage 184 | urllib.request.urlretrieve(asset_url, download_path, request_hook) # nosec 185 | else: 186 | urllib.request.urlretrieve(asset_url, download_path) # nosec 187 | except Exception: 188 | logger.exception("Downloading url {} failed".format(asset_url)) 189 | return False 190 | 191 | logger.debug("Download succeeded") 192 | return download_path 193 | 194 | 195 | def install_new_version(installer_path: str) -> None: 196 | """Runs the new installer and causes a UAC prompt.""" 197 | # https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shellexecutew 198 | logger.debug("ShellExecuteW {}".format(installer_path)) 199 | ctypes.windll.shell32.ShellExecuteW(None, "runas", installer_path, "", None, 1) 200 | 201 | # https://stackoverflow.com/questions/2129935/pyinstaller-exes-not-dying-after-sys-exit 202 | logger.debug("Exiting") 203 | sys.exit() 204 | 205 | # insurance if above fails 206 | logger.debug("REALLY exiting") 207 | os._exit(0) 208 | -------------------------------------------------------------------------------- /src/main/python/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from fbs_runtime.application_context.PySide2 import ApplicationContext 4 | from loguru import logger 5 | 6 | from lib.config import DEBUG_LOG 7 | from lib.resize import max_resize 8 | from widgets.main_window import main_window 9 | 10 | 11 | def main() -> None: 12 | # args = sys.argv 13 | 14 | # start app 15 | appctxt = ApplicationContext() 16 | app = appctxt.app 17 | 18 | # prepare the logger 19 | logger.add( 20 | DEBUG_LOG, 21 | rotation="1 MB", 22 | retention="1 week", 23 | backtrace=True, 24 | diagnose=True, 25 | enqueue=True, 26 | ) 27 | logger.info("-----------------------") 28 | logger.info("Launching application") 29 | 30 | try: 31 | # create instance of main window 32 | app_main_window = main_window(app, appctxt) 33 | # build the main window 34 | app_main_window.build() 35 | app_main_window.set_theme() 36 | 37 | # load data 38 | app_main_window.main_widget.find_sim() 39 | app_main_window.main_widget.check_version() 40 | app_main_window.main_widget.refresh(first=True) 41 | 42 | # resize and show 43 | max_resize(app_main_window, app_main_window.sizeHint()) 44 | app_main_window.show() 45 | 46 | # execute the application 47 | sys.exit(app.exec_()) 48 | 49 | except Exception as e: 50 | if isinstance(e, SystemExit): 51 | logger.info("System exit requested") 52 | else: 53 | logger.exception("Uncaught exception") 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /src/main/python/widgets/about_widget.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import PySide2.QtCore as QtCore 4 | import PySide2.QtGui as QtGui 5 | import PySide2.QtWidgets as QtWidgets 6 | from fbs_runtime.application_context.PySide2 import ApplicationContext 7 | 8 | 9 | class about_widget(QtWidgets.QDialog): 10 | def __init__( 11 | self, 12 | parent: QtWidgets.QWidget = None, 13 | appctxt: ApplicationContext = None, 14 | ) -> None: 15 | """Application about widget.""" 16 | QtWidgets.QDialog.__init__(self) 17 | self.parent = parent # type: ignore 18 | self.appctxt = appctxt 19 | 20 | self.setWindowTitle("About MSFS Mod Manager") 21 | self.setWindowFlags( 22 | QtCore.Qt.WindowSystemMenuHint # type: ignore 23 | | QtCore.Qt.WindowTitleHint # type: ignore 24 | | QtCore.Qt.WindowCloseButtonHint 25 | ) 26 | self.setWindowModality(QtCore.Qt.ApplicationModal) # type: ignore 27 | 28 | self.layout = QtWidgets.QVBoxLayout() # type: ignore 29 | self.big_font = QtGui.QFont("Arial", 16) # type: ignore 30 | self.small_font = QtGui.QFont("Arial", 10) # type: ignore 31 | 32 | self.icon = QtWidgets.QLabel(parent=self) # type: ignore 33 | self.icon.setPixmap( 34 | QtGui.QPixmap(self.appctxt.get_resource(os.path.join("icons", "icon.png"))) 35 | ) 36 | self.layout.addWidget(self.icon) 37 | 38 | self.name = QtWidgets.QLabel("Microsoft Flight Simulator Mod Manager", self) 39 | self.name.setFont(self.big_font) 40 | self.name.setAlignment(QtCore.Qt.AlignCenter) # type: ignore 41 | self.layout.addWidget(self.name) 42 | 43 | self.author = QtWidgets.QLabel( 44 | "Developed by Nathan Vaughn", 45 | self, 46 | ) 47 | self.author.setFont(self.big_font) 48 | self.author.setOpenExternalLinks(True) 49 | self.author.setAlignment(QtCore.Qt.AlignCenter) # type: ignore 50 | self.layout.addWidget(self.author) 51 | 52 | self.license = QtWidgets.QLabel( 53 | "Copyright 2021 - Licensed under the GPLv3 License", self 54 | ) 55 | self.license.setFont(self.small_font) 56 | self.license.setAlignment(QtCore.Qt.AlignCenter) # type: ignore 57 | self.layout.addWidget(self.license) 58 | 59 | self.no_redistrib = QtWidgets.QLabel( 60 | "Please do not redistribute without permission", self 61 | ) 62 | self.no_redistrib.setFont(self.small_font) 63 | self.no_redistrib.setAlignment(QtCore.Qt.AlignCenter) # type: ignore 64 | self.layout.addWidget(self.no_redistrib) 65 | 66 | self.setLayout(self.layout) 67 | 68 | self.show() 69 | self.setFixedSize(self.width(), self.height()) 70 | -------------------------------------------------------------------------------- /src/main/python/widgets/base_table.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import PySide2.QtCore as QtCore 4 | import PySide2.QtGui as QtGui 5 | import PySide2.QtWidgets as QtWidgets 6 | 7 | 8 | class base_table(QtWidgets.QTableView): 9 | """Base table widget.""" 10 | 11 | def __init__(self, parent: QtWidgets.QWidget = None) -> None: 12 | """Initialize table widget.""" 13 | super().__init__(parent) 14 | self.parent = None # type: ignore 15 | # needs to be set by inherited class 16 | # self.headers = [] 17 | # self.LOOKUP = {} 18 | 19 | self.setSortingEnabled(True) 20 | self.setAlternatingRowColors(True) 21 | self.setWordWrap(False) 22 | self.setEditTriggers( 23 | QtWidgets.QAbstractItemView.NoEditTriggers # type: ignore 24 | ) # disable editing 25 | 26 | # set the correct size adjust policy to get the proper size hint 27 | self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) # type: ignore 28 | self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) # type: ignore 29 | self.horizontalHeader().setStretchLastSection(True) 30 | 31 | # create data model 32 | self.base_model = QtGui.QStandardItemModel(0, len(self.LOOKUP)) # type: ignore 33 | # set model headers 34 | for i, header in enumerate(self.headers): # type: ignore 35 | self.base_model.setHeaderData(i, QtCore.Qt.Horizontal, header) # type: ignore 36 | 37 | # proxy model 38 | # self.proxy_model = MyProxy() 39 | self.proxy_model = QtCore.QSortFilterProxyModel() 40 | self.proxy_model.setSourceModel(self.base_model) 41 | self.proxy_model.setDynamicSortFilter(True) 42 | self.proxy_model.setFilterKeyColumn(-1) # all columns 43 | # proxy model sort settings 44 | self.proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) # type: ignore 45 | self.proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) # type: ignore 46 | 47 | # set table model 48 | self.setModel(self.proxy_model) 49 | 50 | def set_row(self, row_data: dict) -> None: 51 | """Set a row's data.""" 52 | self.base_model.insertRow(0) 53 | 54 | for col, item in row_data.items(): 55 | # skip items not in lookup list 56 | if col not in self.LOOKUP: # type: ignore 57 | continue 58 | 59 | # if it's a boolean, convert to string so capitalization 60 | # is preserved, which oddly Qt does not do 61 | if isinstance(item, bool): 62 | item = str(item) 63 | 64 | self.base_model.setData(self.base_model.index(0, self.LOOKUP[col]), item) # type: ignore 65 | 66 | def set_data(self, data: List[dict], first: bool = False) -> None: 67 | """Set the table data.""" 68 | # clear 69 | self.clear() 70 | 71 | # set data 72 | for row in data: 73 | self.set_row(row) 74 | 75 | # finish 76 | if first: 77 | self.sortByColumn(0, QtCore.Qt.AscendingOrder) # type: ignore 78 | 79 | self.resize() 80 | 81 | def clear(self) -> None: 82 | """Clears the source table model.""" 83 | self.base_model.removeRows(0, self.base_model.rowCount()) 84 | 85 | def get_item(self, r: int, c: int) -> QtGui.QStandardItem: 86 | """Convience function to get table item.""" 87 | return self.base_model.item(r, c) 88 | 89 | def rowCount(self) -> int: 90 | """Convience proxy function for rowCount like QTableWidget.""" 91 | return self.base_model.rowCount() 92 | 93 | def columnCount(self) -> int: 94 | """Convience proxy function for columnCount like QTableWidget.""" 95 | return self.base_model.columnCount() 96 | 97 | def sizeHint(self) -> QtCore.QSize: 98 | """Reimplements sizeHint function to increase the width.""" 99 | # I have no idea why by default the width size hint is too small, but it is 100 | old_size = super().sizeHint() 101 | # add a magic 25 pixels to eliminate the scroll bar by default 102 | return QtCore.QSize(old_size.width() + 0, old_size.height()) 103 | 104 | def resize(self) -> None: 105 | """Resize the rows and columns.""" 106 | # resize rows and columns 107 | self.resizeColumnsToContents() 108 | # this HAS to come second for some reason 109 | self.resizeRowsToContents() 110 | 111 | def get_selected_rows(self) -> list: 112 | """Returns a list of selected row indexes.""" 113 | # this gets the list of model indexes from the table, then maps them 114 | # to the source data via the proxy model, and returns the row elements 115 | return [ 116 | y.row() 117 | for y in [ 118 | self.proxy_model.mapToSource(x) 119 | for x in self.selectionModel().selectedRows() 120 | ] 121 | ] 122 | 123 | def search(self, term: str) -> None: 124 | """Filters the proxy model with wildcard expression.""" 125 | self.proxy_model.setFilterWildcard(term) 126 | self.resizeRowsToContents() 127 | -------------------------------------------------------------------------------- /src/main/python/widgets/files_table.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import PySide2.QtGui as QtGui 4 | import PySide2.QtWidgets as QtWidgets 5 | 6 | from widgets.base_table import base_table 7 | 8 | 9 | class files_table(base_table): 10 | """Table widget for displaying mod files.""" 11 | 12 | def __init__(self, parent: QtWidgets.QWidget = None) -> None: 13 | """Initialize table widget.""" 14 | self.headers = [ 15 | "Path", 16 | "Size (Bytes)", 17 | ] 18 | 19 | self.LOOKUP = { 20 | "path": 0, 21 | "size": 1, 22 | } 23 | 24 | super().__init__(parent) 25 | self.parent = parent # type: ignore 26 | 27 | def get_basic_info(self, row_id: int) -> str: 28 | """Returns path of a given row index.""" 29 | return self.get_item(row_id, self.LOOKUP["path"]).text() 30 | 31 | def contextMenuEvent(self, event: Any) -> None: 32 | """Override default context menu event to provide right-click menu.""" 33 | right_click_menu = QtWidgets.QMenu(self) # type: ignore 34 | 35 | open_folder_action = QtWidgets.QAction("Open In Folder", self) 36 | open_folder_action.triggered.connect(self.parent.open_file_folder) # type: ignore 37 | right_click_menu.addAction(open_folder_action) # type: ignore 38 | 39 | # popup at cursor position 40 | right_click_menu.popup(QtGui.QCursor.pos()) # type: ignore 41 | -------------------------------------------------------------------------------- /src/main/python/widgets/info_widget.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | import PySide2.QtCore as QtCore 5 | import PySide2.QtWidgets as QtWidgets 6 | from fbs_runtime.application_context.PySide2 import ApplicationContext 7 | 8 | import lib.files as files 9 | import lib.resize as resize 10 | from lib.flight_sim import flight_sim 11 | from widgets.files_table import files_table 12 | 13 | 14 | class info_widget(QtWidgets.QWidget): 15 | def __init__( 16 | self, 17 | flight_sim_handle: flight_sim, 18 | parent: QtWidgets.QWidget = None, 19 | appctxt: ApplicationContext = None, 20 | ) -> None: 21 | """Info widget/dialog for displaying mod info.""" 22 | QtWidgets.QWidget.__init__(self) 23 | self.flight_sim = flight_sim_handle 24 | self.parent = parent # type: ignore 25 | self.appctxt = appctxt 26 | 27 | # self.setWindowTitle("Info") 28 | self.setWindowFlags( 29 | QtCore.Qt.WindowSystemMenuHint # type: ignore 30 | | QtCore.Qt.WindowTitleHint # type: ignore 31 | | QtCore.Qt.WindowMinimizeButtonHint 32 | | QtCore.Qt.WindowMaximizeButtonHint 33 | | QtCore.Qt.WindowCloseButtonHint 34 | ) 35 | # self.setWindowModality(QtCore.Qt.ApplicationModal) 36 | 37 | self.layout = QtWidgets.QVBoxLayout() # type: ignore 38 | 39 | self.top_group = QtWidgets.QGroupBox() # type: ignore 40 | self.top_layout = QtWidgets.QFormLayout() 41 | 42 | self.content_type_field = QtWidgets.QLineEdit(self) 43 | self.content_type_field.setReadOnly(True) 44 | self.top_layout.addRow("Content Type", self.content_type_field) # type: ignore 45 | 46 | self.title_field = QtWidgets.QLineEdit(self) 47 | self.title_field.setReadOnly(True) 48 | self.top_layout.addRow("Title", self.title_field) # type: ignore 49 | 50 | self.manufacturer_field = QtWidgets.QLineEdit(self) 51 | self.manufacturer_field.setReadOnly(True) 52 | self.top_layout.addRow("Manufacturer", self.manufacturer_field) # type: ignore 53 | 54 | self.creator_field = QtWidgets.QLineEdit(self) 55 | self.creator_field.setReadOnly(True) 56 | self.top_layout.addRow("Creator", self.creator_field) # type: ignore 57 | 58 | self.package_version_field = QtWidgets.QLineEdit(self) 59 | self.package_version_field.setReadOnly(True) 60 | self.top_layout.addRow("Package Version", self.package_version_field) # type: ignore 61 | 62 | self.minimum_game_version_field = QtWidgets.QLineEdit(self) 63 | self.minimum_game_version_field.setReadOnly(True) 64 | self.top_layout.addRow("Minimum Game Version", self.minimum_game_version_field) # type: ignore 65 | 66 | self.total_size_field = QtWidgets.QLineEdit(self) 67 | self.total_size_field.setReadOnly(True) 68 | self.top_layout.addRow("Total Size", self.total_size_field) # type: ignore 69 | 70 | self.top_group.setLayout(self.top_layout) 71 | self.layout.addWidget(self.top_group) 72 | 73 | self.open_folder_button = QtWidgets.QPushButton("Open Folder", self) 74 | self.layout.addWidget(self.open_folder_button) 75 | 76 | self.files_table = files_table(self) 77 | self.files_table.setAccessibleName("info_files") 78 | self.layout.addWidget(self.files_table) 79 | 80 | self.setLayout(self.layout) 81 | 82 | self.open_folder_button.clicked.connect(self.open_folder) # type: ignore 83 | 84 | def set_data(self, mod_data: dict, files_data: List[dict]) -> None: 85 | """Loads all the data for the widget.""" 86 | self.setWindowTitle("{} - Info".format(mod_data["folder_name"])) 87 | 88 | # form data 89 | self.content_type_field.setText(mod_data["content_type"]) 90 | self.title_field.setText(mod_data["title"]) 91 | self.manufacturer_field.setText(mod_data["manufacturer"]) 92 | self.creator_field.setText(mod_data["creator"]) 93 | self.package_version_field.setText(mod_data["version"]) 94 | self.minimum_game_version_field.setText(mod_data["minimum_game_version"]) 95 | 96 | # file data 97 | self.files_table.set_data(files_data, first=True) 98 | 99 | # resize 100 | resize.max_resize( 101 | self, QtCore.QSize(self.sizeHint().width() + 32, self.sizeHint().height()) 102 | ) 103 | 104 | # misc data to hold onto 105 | self.mod_path = mod_data["full_path"] 106 | 107 | self.total_size_field.setText( 108 | files.human_readable_size(files.get_folder_size(self.mod_path)) 109 | ) 110 | 111 | def open_folder(self) -> None: 112 | """Opens the folder for the mod.""" 113 | # this will always be opening a folder and therefore is safe 114 | os.startfile(self.mod_path) # nosec 115 | 116 | def open_file_folder(self) -> None: 117 | """Opens the folder for a selected file.""" 118 | selected = self.files_table.get_selected_rows() 119 | 120 | if selected: 121 | file_path = self.files_table.get_basic_info(selected[0]) 122 | full_path = os.path.join( 123 | self.mod_path, 124 | file_path, 125 | ) 126 | # this takes off the filename 127 | # this will always be opening a folder and therefore is safe 128 | os.startfile(os.path.dirname(full_path)) # nosec 129 | -------------------------------------------------------------------------------- /src/main/python/widgets/main_table.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple 2 | 3 | import PySide2.QtGui as QtGui 4 | import PySide2.QtWidgets as QtWidgets 5 | 6 | import lib.type_helper as type_helper 7 | from widgets.base_table import base_table 8 | 9 | 10 | class main_table(base_table): 11 | """Primary application table widget for mod summary.""" 12 | 13 | def __init__(self, parent: QtWidgets.QWidget = None) -> None: 14 | """Initialize table widget.""" 15 | self.headers = [ 16 | "Title", 17 | "Folder Name", 18 | "Type", 19 | "Creator", 20 | "Version", 21 | "Minimum Game Version", 22 | "Enabled", 23 | "Last Modified", 24 | ] 25 | 26 | self.LOOKUP = { 27 | "title": 0, 28 | "folder_name": 1, 29 | "content_type": 2, 30 | "creator": 3, 31 | "version": 4, 32 | "minimum_game_version": 5, 33 | "enabled": 6, 34 | "time_mod": 7, 35 | } 36 | 37 | super().__init__(parent) 38 | self.parent = parent # type: ignore 39 | 40 | def set_colors(self, dark: bool) -> None: 41 | """Set the colors for the rows, based on being a dark theme or not.""" 42 | for r in range(self.rowCount()): 43 | _, enabled = self.get_basic_info(r) 44 | if not enabled: 45 | # light, disabled 46 | color = QtGui.QColor(150, 150, 150) # type: ignore 47 | elif dark: 48 | # dark, enabled 49 | color = QtGui.QColor(255, 255, 255) # type: ignore 50 | else: 51 | # light, enabled 52 | color = QtGui.QColor(0, 0, 0) # type: ignore 53 | 54 | for c in range(self.columnCount()): 55 | self.get_item(r, c).setForeground(color) # type: ignore 56 | 57 | def get_basic_info(self, row_id: int) -> Tuple[str, bool]: 58 | """Returns folder name and enabled status of a given row index.""" 59 | name = self.get_item(row_id, self.LOOKUP["folder_name"]).text() 60 | enabled = type_helper.str2bool( 61 | self.get_item(row_id, self.LOOKUP["enabled"]).text() 62 | ) 63 | 64 | return (name, enabled) 65 | 66 | def contextMenuEvent(self, event: Any) -> None: 67 | """Override default context menu event to provide right-click menu.""" 68 | right_click_menu = QtWidgets.QMenu(self) # type: ignore 69 | 70 | info_action = QtWidgets.QAction("Info", self) 71 | info_action.triggered.connect(self.parent.info) # type: ignore 72 | right_click_menu.addAction(info_action) # type: ignore 73 | 74 | right_click_menu.addSeparator() 75 | 76 | enable_action = QtWidgets.QAction("Enable", self) 77 | enable_action.triggered.connect(self.parent.enable) # type: ignore 78 | right_click_menu.addAction(enable_action) # type: ignore 79 | 80 | disable_action = QtWidgets.QAction("Disable", self) 81 | disable_action.triggered.connect(self.parent.disable) # type: ignore 82 | right_click_menu.addAction(disable_action) # type: ignore 83 | 84 | right_click_menu.addSeparator() 85 | 86 | uninstall_action = QtWidgets.QAction("Uninstall", self) 87 | uninstall_action.triggered.connect(self.parent.uninstall) # type: ignore 88 | right_click_menu.addAction(uninstall_action) # type: ignore 89 | 90 | # popup at cursor position 91 | right_click_menu.popup(QtGui.QCursor.pos()) # type: ignore 92 | -------------------------------------------------------------------------------- /src/main/python/widgets/main_widget.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import sys 4 | import webbrowser 5 | from typing import Any, Callable 6 | 7 | import PySide2.QtCore as QtCore 8 | import PySide2.QtGui as QtGui 9 | import PySide2.QtWidgets as QtWidgets 10 | from fbs_runtime.application_context.PySide2 import ApplicationContext 11 | from loguru import logger 12 | 13 | import dialogs.error_dialogs as error_dialogs 14 | import dialogs.information_dialogs as information_dialogs 15 | import dialogs.question_dialogs as question_dialogs 16 | import dialogs.warning_dialogs as warning_dialogs 17 | import lib.config as config 18 | import lib.files as files 19 | import lib.flight_sim as flight_sim 20 | import lib.thread as thread 21 | import lib.version as version 22 | from dialogs.version_check_dialog import version_check_dialog 23 | from widgets.about_widget import about_widget 24 | from widgets.info_widget import info_widget 25 | from widgets.main_table import main_table 26 | from widgets.progress_widget import progress_widget 27 | from widgets.versions_widget import versions_widget 28 | 29 | ARCHIVE_FILTER = "Archives (*.zip *.rar *.tar *.bz2 *.7z)" 30 | 31 | 32 | class main_widget(QtWidgets.QWidget): 33 | def __init__( 34 | self, parent: QtWidgets.QWidget = None, appctxt: ApplicationContext = None 35 | ) -> None: 36 | """Main application widget.""" 37 | QtWidgets.QWidget.__init__(self) 38 | self.parent = parent # type: ignore 39 | self.appctxt = appctxt 40 | 41 | def build(self) -> None: 42 | """Build layout.""" 43 | self.layout = QtWidgets.QGridLayout() # type: ignore 44 | 45 | self.install_button = QtWidgets.QPushButton("Install", self) 46 | self.layout.addWidget(self.install_button, 0, 0) # type: ignore 47 | 48 | self.uninstall_button = QtWidgets.QPushButton("Uninstall", self) 49 | self.layout.addWidget(self.uninstall_button, 0, 1) # type: ignore 50 | 51 | self.enable_button = QtWidgets.QPushButton("Enable", self) 52 | self.layout.addWidget(self.enable_button, 0, 4) # type: ignore 53 | 54 | self.disable_button = QtWidgets.QPushButton("Disable", self) 55 | self.layout.addWidget(self.disable_button, 0, 5) # type: ignore 56 | 57 | self.info_button = QtWidgets.QPushButton("Info", self) 58 | self.layout.addWidget(self.info_button, 0, 8) # type: ignore 59 | 60 | self.refresh_button = QtWidgets.QPushButton("Refresh", self) 61 | self.layout.addWidget(self.refresh_button, 0, 9) # type: ignore 62 | 63 | self.sublayout = QtWidgets.QHBoxLayout() # type: ignore 64 | 65 | self.search_label = QtWidgets.QLabel("Search:", self) 66 | self.sublayout.addWidget(self.search_label) 67 | 68 | self.search_field = QtWidgets.QLineEdit(self) 69 | self.sublayout.addWidget(self.search_field) 70 | 71 | self.clear_button = QtWidgets.QPushButton("Clear", self) 72 | self.sublayout.addWidget(self.clear_button) 73 | 74 | self.layout.addLayout(self.sublayout, 1, 6, 1, 4) 75 | 76 | self.main_table = main_table(self) 77 | self.layout.addWidget(self.main_table, 2, 0, 1, 10) # type: ignore 78 | 79 | self.setLayout(self.layout) 80 | 81 | # buttons 82 | self.install_button.clicked.connect(self.install_archive) # type: ignore 83 | self.uninstall_button.clicked.connect(self.uninstall) # type: ignore 84 | self.enable_button.clicked.connect(self.enable) # type: ignore 85 | self.disable_button.clicked.connect(self.disable) # type: ignore 86 | self.refresh_button.clicked.connect(self.refresh) # type: ignore 87 | self.info_button.clicked.connect(self.info) # type: ignore 88 | self.main_table.doubleClicked.connect(self.info) # type: ignore 89 | 90 | self.clear_button.clicked.connect(self.clear_search) # type: ignore 91 | self.search_field.textChanged.connect(self.search) # type: ignore 92 | 93 | # shortcuts 94 | self.shortcut_delete = QtWidgets.QShortcut( 95 | QtGui.QKeySequence(QtCore.Qt.Key_Delete), self # type: ignore 96 | ) 97 | self.shortcut_delete.activated.connect(self.uninstall) # type: ignore 98 | 99 | # handle to data 100 | self.flight_sim = flight_sim.flight_sim() 101 | 102 | # ====================== 103 | # Sim Functions 104 | # ====================== 105 | 106 | def find_sim(self) -> None: 107 | """Sets the path to the simulator root folder.""" 108 | 109 | def user_selection() -> None: 110 | """Function to keep user in a loop until they select correct folder.""" 111 | # prompt user to select 112 | self.flight_sim.sim_packages_folder = ( 113 | QtWidgets.QFileDialog.getExistingDirectory( 114 | parent=self, 115 | caption="Select the root Microsoft Flight Simulator directory", 116 | dir=os.getenv("APPDATA"), # type: ignore 117 | ) 118 | ) 119 | 120 | if not self.flight_sim.sim_packages_folder.strip(): 121 | sys.exit() 122 | 123 | elif self.flight_sim.is_sim_packages_folder( 124 | self.flight_sim.sim_packages_folder 125 | ): 126 | # save the config file 127 | config.set_key_value( 128 | config.SIM_FOLDER_KEY, 129 | self.flight_sim.sim_packages_folder, 130 | path=True, 131 | ) 132 | 133 | elif self.flight_sim.is_sim_packages_folder( 134 | os.path.join(self.flight_sim.sim_packages_folder, "Packages") 135 | ): 136 | # save the config file 137 | config.set_key_value( 138 | config.SIM_FOLDER_KEY, 139 | os.path.join(self.flight_sim.sim_packages_folder, "Packages"), 140 | path=True, 141 | ) 142 | 143 | else: 144 | # show error 145 | warning_dialogs.sim_path_invalid(self) 146 | # send them through again 147 | user_selection() 148 | 149 | # try to automatically find the sim 150 | ( 151 | success, 152 | self.flight_sim.sim_packages_folder, # type: ignore 153 | ) = self.flight_sim.find_sim_packages_folder() 154 | 155 | if not self.flight_sim.sim_packages_folder: 156 | # show error 157 | warning_dialogs.sim_not_detected(self) 158 | # let user select folder 159 | user_selection() 160 | 161 | elif not success: 162 | # save the config file 163 | config.set_key_value( 164 | config.SIM_FOLDER_KEY, self.flight_sim.sim_packages_folder, path=True 165 | ) 166 | # notify user 167 | information_dialogs.sim_detected(self, self.flight_sim.sim_packages_folder) 168 | 169 | def select_mod_install(self) -> None: 170 | """Allow user to select new mod install folder.""" 171 | old_install = files.get_mod_install_folder() 172 | 173 | information_dialogs.mod_install_folder(self) 174 | 175 | new_install = QtWidgets.QFileDialog.getExistingDirectory( 176 | parent=self, 177 | caption="Select mod install folder", 178 | dir=os.path.dirname(old_install), 179 | ) 180 | 181 | def core(progress: Callable) -> None: 182 | # setup mover thread 183 | mover = flight_sim.move_mod_install_folder_thread( 184 | self.flight_sim, old_install, new_install 185 | ) 186 | mover.activity_update.connect(progress.set_activity) # type: ignore 187 | 188 | def failed(err: Exception) -> None: 189 | typ = type(err) 190 | message = str(err) 191 | 192 | logger.exception("Failed to move mod install folder") 193 | error_dialogs.general(self, typ, message) 194 | 195 | # start the thread 196 | with thread.thread_wait( 197 | mover.finished, 198 | failed_signal=mover.failed, 199 | failed_func=failed, 200 | update_signal=mover.activity_update, 201 | ): 202 | mover.start() 203 | 204 | # done 205 | information_dialogs.mod_install_folder_set(self, new_install) 206 | 207 | if not new_install: 208 | # cancel if no folder selected 209 | return 210 | 211 | if files.check_same_path(old_install, new_install): 212 | # cancel if new folder is same as old folder 213 | warning_dialogs.mod_install_folder_same(self) 214 | return 215 | 216 | if files.check_in_path(new_install, self.flight_sim.get_sim_mod_folder()): 217 | # cancel if new folder is in sim packages folder 218 | warning_dialogs.mod_install_folder_in_sim_path(self) 219 | return 220 | 221 | if not question_dialogs.mod_install_folder_move(self, old_install, new_install): 222 | # last sanity check 223 | return 224 | 225 | self.base_action(core) 226 | 227 | # ====================== 228 | # Inherited Functions 229 | # ====================== 230 | 231 | def base_fail(self, error: Exception, mapping: dict, fallback_text: str) -> None: 232 | """Base thread failure function.""" 233 | typ = type(error) 234 | if typ not in mapping: 235 | logger.error(fallback_text) 236 | message = str(error) 237 | 238 | logger.error("{}: {}", typ, message) 239 | error_dialogs.general(self, typ, message) 240 | else: 241 | func = mapping[typ] 242 | func() 243 | 244 | def base_action( 245 | self, 246 | core_func: Callable, 247 | button: QtWidgets.QPushButton = None, 248 | sanity_dialog: Callable = None, 249 | empty_check: bool = False, 250 | empty_val: Any = None, 251 | refresh: bool = True, 252 | ): 253 | """Base function for GUI actions.""" 254 | if empty_check and not empty_val: 255 | return 256 | 257 | if button: 258 | button.setEnabled(False) 259 | 260 | # sanity check 261 | if sanity_dialog: 262 | question = sanity_dialog() 263 | if not question: 264 | # cancel 265 | button.setEnabled(True) 266 | return 267 | 268 | # build progress widget 269 | progress = progress_widget(self, self.appctxt) 270 | progress.set_mode(progress.INFINITE) 271 | 272 | # execute the core function 273 | core_func(progress) 274 | progress.close() 275 | 276 | # refresh the data 277 | if refresh: 278 | self.refresh(automated=True) 279 | 280 | # cleanup 281 | if button: 282 | button.setEnabled(True) 283 | 284 | # ====================== 285 | # Version Check 286 | # ====================== 287 | 288 | def check_version(self) -> None: 289 | """Checks the application version and allows user to open browser to update.""" 290 | installed = version.is_installed() 291 | return_url = version.check_version(self.appctxt, installed) # type: ignore 292 | 293 | def core(progress: Callable) -> None: 294 | progress.set_mode(progress.PERCENT) 295 | progress.set_activity("Downloading latest version ({})".format(return_url)) 296 | 297 | # setup downloader thread 298 | downloader = version.download_new_version_thread(return_url) # type: ignore 299 | downloader.percent_update.connect(progress.set_percent) # type: ignore 300 | 301 | def failed(err: Exception) -> None: 302 | typ = type(err) 303 | message = str(err) 304 | 305 | logger.exception("Failed to download new version") 306 | error_dialogs.general(self, typ, message) 307 | 308 | # start the thread 309 | with thread.thread_wait( 310 | downloader.finished, 311 | finish_func=version.install_new_version, 312 | failed_signal=downloader.failed, 313 | failed_func=failed, 314 | update_signal=downloader.percent_update, 315 | ): 316 | downloader.start() 317 | 318 | if not return_url: 319 | return 320 | 321 | result, remember = version_check_dialog(self, installed).exec_() 322 | if result: 323 | if installed: 324 | self.base_action(core, refresh=False) 325 | else: 326 | webbrowser.open(return_url) # type: ignore 327 | elif remember: 328 | config.set_key_value(config.NEVER_VER_CHEK_KEY, True) 329 | 330 | # ====================== 331 | # Data Operations 332 | # ====================== 333 | 334 | def install_archive(self) -> None: 335 | """Installs selected mod archives.""" 336 | 337 | # first, let user select multiple archives 338 | mod_archives = QtWidgets.QFileDialog.getOpenFileNames( 339 | parent=self, 340 | caption="Select mod archive(s)", 341 | dir=files.get_last_open_folder(), 342 | filter=ARCHIVE_FILTER, 343 | )[0] 344 | 345 | succeeded = [] 346 | 347 | def core(progress: Callable) -> None: 348 | # for each archive, try to install it 349 | for mod_archive in mod_archives: 350 | 351 | def finish(result: list) -> None: 352 | # this function is required as the results will be a list, 353 | # which is not a hashable type 354 | succeeded.extend(result) 355 | 356 | def failed(err: Exception) -> None: 357 | message = str(err) 358 | 359 | mapping = { 360 | files.ExtractionError: lambda: error_dialogs.archive_extract( 361 | self, mod_archive, message 362 | ), 363 | flight_sim.NoManifestError: lambda: warning_dialogs.mod_parsing( 364 | self, [mod_archive] 365 | ), 366 | files.AccessError: lambda: error_dialogs.permission( 367 | self, mod_archive, message 368 | ), 369 | flight_sim.NoModsError: lambda: error_dialogs.no_mods( 370 | self, mod_archive 371 | ), 372 | } 373 | 374 | self.base_fail( 375 | err, 376 | mapping, 377 | "Failed to install mod archive", 378 | ) 379 | 380 | # setup installer thread 381 | installer = flight_sim.install_mod_archive_thread( 382 | self.flight_sim, mod_archive 383 | ) 384 | installer.activity_update.connect(progress.set_activity) # type: ignore 385 | installer.percent_update.connect(progress.set_percent) # type: ignore 386 | 387 | # start the thread 388 | with thread.thread_wait( 389 | installer.finished, 390 | finish_func=finish, 391 | failed_signal=installer.failed, 392 | failed_func=failed, 393 | update_signal=installer.activity_update, 394 | timeout=1200000, 395 | ): 396 | installer.start() 397 | 398 | self.base_action( 399 | core, 400 | button=self.install_button, 401 | empty_check=True, 402 | empty_val=mod_archives, 403 | ) 404 | 405 | if succeeded: 406 | config.set_key_value( 407 | config.LAST_OPEN_FOLDER_KEY, os.path.dirname(mod_archives[0]), path=True 408 | ) 409 | information_dialogs.mods_installed(self, succeeded) 410 | 411 | def install_folder(self) -> None: 412 | """Installs selected mod folders.""" 413 | 414 | # first, let user select a folder 415 | mod_folder = QtWidgets.QFileDialog.getExistingDirectory( 416 | parent=self, 417 | caption="Select mod folder", 418 | dir=files.get_last_open_folder(), 419 | ) 420 | 421 | succeeded = [] 422 | 423 | def core(progress: Callable) -> None: 424 | def finish(result: list) -> None: 425 | # this function is required as the results will be a list, 426 | # which is not a hashable type 427 | succeeded.extend(result) 428 | 429 | def failed(err: Exception) -> None: 430 | message = str(err) 431 | 432 | mapping = { 433 | flight_sim.NoManifestError: lambda: warning_dialogs.mod_parsing( 434 | self, [mod_folder] 435 | ), 436 | files.AccessError: lambda: error_dialogs.permission( 437 | self, mod_folder, message 438 | ), 439 | flight_sim.NoModsError: lambda: error_dialogs.no_mods( 440 | self, mod_folder 441 | ), 442 | } 443 | 444 | self.base_fail( 445 | err, 446 | mapping, 447 | "Failed to install mod folder", 448 | ) 449 | 450 | # setup installer thread 451 | installer = flight_sim.install_mods_thread(self.flight_sim, mod_folder) 452 | installer.activity_update.connect(progress.set_activity) # type: ignore 453 | 454 | # start the thread 455 | with thread.thread_wait( 456 | installer.finished, 457 | finish_func=finish, 458 | failed_signal=installer.failed, 459 | failed_func=failed, 460 | update_signal=installer.activity_update, 461 | ): 462 | installer.start() 463 | 464 | self.base_action( 465 | core, 466 | button=self.install_button, 467 | empty_check=True, 468 | empty_val=mod_folder, 469 | ) 470 | 471 | if succeeded: 472 | config.set_key_value( 473 | config.LAST_OPEN_FOLDER_KEY, os.path.dirname(mod_folder), path=True 474 | ) 475 | information_dialogs.mods_installed(self, succeeded) 476 | 477 | def uninstall(self) -> None: 478 | """Uninstalls selected mods.""" 479 | selected = self.main_table.get_selected_rows() 480 | 481 | def core(progress: Callable) -> None: 482 | for i, _id in enumerate(selected): 483 | # first, get the mod name and enabled status 484 | (folder, enabled) = self.main_table.get_basic_info(_id) 485 | mod_folder = self.flight_sim.get_mod_folder(folder, enabled) 486 | 487 | # setup uninstaller thread 488 | uninstaller = flight_sim.uninstall_mod_thread( 489 | self.flight_sim, mod_folder 490 | ) 491 | uninstaller.activity_update.connect(progress.set_activity) # type: ignore 492 | 493 | def failed(err: Exception) -> None: 494 | self.base_fail(err, {}, "Failed to uninstall mod") 495 | 496 | # start the thread 497 | with thread.thread_wait( 498 | uninstaller.finished, 499 | failed_signal=uninstaller.failed, 500 | failed_func=failed, 501 | update_signal=uninstaller.activity_update, 502 | ): 503 | uninstaller.start() 504 | 505 | progress.set_percent(i, total=len(selected) - 1) 506 | 507 | self.base_action( 508 | core, 509 | button=self.uninstall_button, 510 | sanity_dialog=lambda: question_dialogs.mod_delete(self, len(selected)), 511 | empty_check=True, 512 | empty_val=selected, 513 | ) 514 | 515 | def enable(self) -> None: 516 | """Enables selected mods.""" 517 | selected = self.main_table.get_selected_rows() 518 | 519 | def core(progress: Callable) -> None: 520 | for i, _id in enumerate(selected): 521 | # first, get the mod name and enabled status 522 | (folder, enabled) = self.main_table.get_basic_info(_id) 523 | 524 | if enabled: 525 | continue 526 | 527 | # setup enabler thread 528 | enabler = flight_sim.enable_mod_thread(self.flight_sim, folder) 529 | enabler.activity_update.connect(progress.set_activity) # type: ignore 530 | 531 | def failed(err: Exception) -> None: 532 | self.base_fail(err, {}, "Failed to enable mod") 533 | 534 | # start the thread 535 | with thread.thread_wait( 536 | enabler.finished, 537 | failed_signal=enabler.failed, 538 | failed_func=failed, 539 | update_signal=enabler.activity_update, 540 | ): 541 | enabler.start() 542 | 543 | progress.set_percent(i, total=len(selected) - 1) 544 | 545 | self.base_action( 546 | core, button=self.enable_button, empty_check=True, empty_val=selected 547 | ) 548 | 549 | def disable(self) -> None: 550 | """Disables selected mods.""" 551 | selected = self.main_table.get_selected_rows() 552 | 553 | def core(progress: Callable) -> None: 554 | for i, _id in enumerate(selected): 555 | # first, get the mod name and disable status 556 | (folder, enabled) = self.main_table.get_basic_info(_id) 557 | 558 | if not enabled: 559 | continue 560 | 561 | # setup disabler thread 562 | disabler = flight_sim.disable_mod_thread(self.flight_sim, folder) 563 | disabler.activity_update.connect(progress.set_activity) # type: ignore 564 | 565 | def failed(err: Exception) -> None: 566 | self.base_fail(err, {}, "Failed to disable mod") 567 | 568 | # start the thread 569 | with thread.thread_wait( 570 | disabler.finished, 571 | failed_signal=disabler.failed, 572 | failed_func=failed, 573 | update_signal=disabler.activity_update, 574 | ): 575 | disabler.start() 576 | 577 | progress.set_percent(i, total=len(selected) - 1) 578 | 579 | self.base_action( 580 | core, button=self.disable_button, empty_check=True, empty_val=selected 581 | ) 582 | 583 | def create_backup(self) -> None: 584 | """Creates a backup of all enabled mods.""" 585 | 586 | archive = QtWidgets.QFileDialog.getSaveFileName( 587 | parent=self, 588 | caption="Save Backup As", 589 | dir=os.path.join( 590 | config.BASE_FOLDER, 591 | "msfs-mod-backup-{}.zip".format( 592 | datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 593 | ), 594 | ), 595 | filter=ARCHIVE_FILTER, 596 | )[0] 597 | 598 | succeeded = [] 599 | 600 | def core(progress: Callable) -> None: 601 | # setup backuper thread 602 | backuper = flight_sim.create_backup_thread(self.flight_sim, archive) 603 | backuper.activity_update.connect(progress.set_activity) # type: ignore 604 | 605 | def finish(result: list) -> None: 606 | # this function is required as the results will be a list, 607 | # which is not a hashable type 608 | succeeded.extend(result) 609 | 610 | def failed(err: Exception) -> None: 611 | message = str(err) 612 | 613 | mapping = { 614 | files.ExtractionError: lambda: error_dialogs.archive_create( 615 | self, archive, message 616 | ) 617 | } 618 | 619 | self.base_fail( 620 | err, 621 | mapping, 622 | "Failed to create backup", 623 | ) 624 | 625 | # start the thread, with no timeout 626 | with thread.thread_wait( 627 | backuper.finished, 628 | timeout=None, 629 | finish_func=finish, 630 | failed_signal=backuper.failed, 631 | failed_func=failed, 632 | update_signal=backuper.activity_update, 633 | ): 634 | backuper.start() 635 | 636 | self.base_action( 637 | core, 638 | empty_check=True, 639 | empty_val=archive, 640 | ) 641 | 642 | if succeeded: 643 | # open resulting directory 644 | question = question_dialogs.backup_success(self, archive) 645 | 646 | if question: 647 | # this will always be opening a folder and therefore is safe 648 | os.startfile(os.path.dirname(archive)) # nosec 649 | 650 | def refresh(self, first: bool = False, automated: bool = False) -> None: 651 | """Refreshes all mod data.""" 652 | 653 | """This is not a separate thread, as the time it takes to parse each manifest 654 | is so low, that the GUI will easily stay responsive, even running in 655 | the foreground thread. I believe Windows gives applications around 656 | 4 seconds to respond to incoming events before being marked as unresponsive, 657 | which should never happen in the process of parsing manifest.json files""" 658 | 659 | def core(progress: Callable) -> None: 660 | # temporarily clear search so that header resizing doesn't get borked 661 | self.search(override="") 662 | 663 | progress.set_mode(progress.PERCENT) 664 | 665 | def update(message: str, percent: int, total: int) -> None: 666 | progress.set_activity(message) 667 | progress.set_percent(percent, total) 668 | # make sure the progress bar gets updated. 669 | self.appctxt.app.processEvents() 670 | 671 | # clear mod cache if a human clicked the button 672 | if not automated: 673 | self.flight_sim.clear_mod_cache() 674 | 675 | # build list of mods 676 | all_mods_data, all_mods_errors = self.flight_sim.get_all_mods( 677 | progress_func=update 678 | ) 679 | 680 | # set data 681 | self.main_table.set_data(all_mods_data, first=first) 682 | self.main_table.set_colors(self.parent.theme_menu_action.isChecked()) 683 | 684 | # display errors 685 | if all_mods_errors: 686 | warning_dialogs.mod_parsing(self, all_mods_errors) 687 | 688 | # put the search back to how it was 689 | self.search() 690 | 691 | self.base_action( 692 | core, 693 | button=self.refresh_button, 694 | refresh=False, 695 | ) 696 | 697 | # ====================== 698 | # Child Widgets 699 | # ====================== 700 | 701 | def info(self) -> None: 702 | """Open dialog to view mod info.""" 703 | # self.info_button.setEnabled(False) 704 | 705 | selected = self.main_table.get_selected_rows() 706 | 707 | if not selected: 708 | return 709 | 710 | (folder, enabled) = self.main_table.get_basic_info(selected[0]) 711 | mod_folder = self.flight_sim.get_mod_folder(folder, enabled) 712 | 713 | wid = info_widget(self.flight_sim, self, self.appctxt) 714 | wid.set_data( 715 | self.flight_sim.parse_mod_manifest(mod_folder), 716 | self.flight_sim.parse_mod_files(mod_folder), 717 | ) 718 | wid.show() 719 | 720 | # self.info_button.setEnabled(True) 721 | 722 | def about(self) -> None: 723 | """Launch the about widget.""" 724 | about_widget(self, self.appctxt).exec_() 725 | 726 | def versions(self) -> None: 727 | """Launch the versions widget.""" 728 | versions_widget(self.flight_sim, self, self.appctxt).exec_() 729 | 730 | # ====================== 731 | # Search 732 | # ====================== 733 | 734 | def search(self, override: str = None) -> None: 735 | """Filter rows to match search term.""" 736 | # strip 737 | term = self.search_field.text().strip() 738 | # override 739 | if override is not None: 740 | term = override 741 | 742 | # search 743 | self.main_table.search(term) 744 | 745 | def clear_search(self) -> None: 746 | """Clear the search field.""" 747 | self.search_field.clear() 748 | -------------------------------------------------------------------------------- /src/main/python/widgets/main_window.py: -------------------------------------------------------------------------------- 1 | import os 2 | import webbrowser 3 | 4 | import PySide2.QtGui as QtGui 5 | import PySide2.QtWidgets as QtWidgets 6 | from fbs_runtime.application_context.PySide2 import ApplicationContext 7 | 8 | import lib.files as files 9 | from lib.config import CONFIG_FILE, DEBUG_LOG 10 | from lib.theme import get_theme, set_theme 11 | from lib.version import get_version 12 | from widgets.main_widget import main_widget 13 | 14 | 15 | class main_window(QtWidgets.QMainWindow): 16 | def __init__( 17 | self, parent: QtWidgets.QWidget = None, appctxt: ApplicationContext = None 18 | ) -> None: 19 | """Main application window.""" 20 | QtWidgets.QMainWindow.__init__(self) 21 | self.parent = parent # type: ignore 22 | self.appctxt = appctxt 23 | 24 | def build(self) -> None: 25 | """Build window.""" 26 | self.setWindowTitle("MSFS Mod Manager - {}".format(get_version(self.appctxt))) # type: ignore 27 | self.setWindowIcon( 28 | QtGui.QIcon(self.appctxt.get_resource(os.path.join("icons", "icon.png"))) 29 | ) 30 | 31 | self.main_widget = main_widget(self, self.appctxt) 32 | self.main_widget.build() 33 | 34 | self.setCentralWidget(self.main_widget) 35 | 36 | main_menu = self.menuBar() 37 | file_menu = main_menu.addMenu("File") 38 | 39 | self.theme_menu_action = QtWidgets.QAction("FS Theme", self, checkable=True) # type: ignore 40 | self.theme_menu_action.setChecked(get_theme()) 41 | self.theme_menu_action.triggered.connect(self.set_theme) # type: ignore 42 | file_menu.addAction(self.theme_menu_action) # type: ignore 43 | 44 | file_menu.addSeparator() 45 | 46 | menu_action = QtWidgets.QAction("Install Mod(s) from Archive", self) 47 | menu_action.triggered.connect(self.main_widget.install_archive) # type: ignore 48 | file_menu.addAction(menu_action) # type: ignore 49 | 50 | menu_action = QtWidgets.QAction("Install Mod from Folder", self) 51 | menu_action.triggered.connect(self.main_widget.install_folder) # type: ignore 52 | file_menu.addAction(menu_action) # type: ignore 53 | 54 | menu_action = QtWidgets.QAction("Uninstall Mods", self) 55 | menu_action.triggered.connect(self.main_widget.uninstall) # type: ignore 56 | file_menu.addAction(menu_action) # type: ignore 57 | 58 | file_menu.addSeparator() 59 | 60 | menu_action = QtWidgets.QAction("Create Backup", self) 61 | menu_action.triggered.connect(self.main_widget.create_backup) # type: ignore 62 | file_menu.addAction(menu_action) # type: ignore 63 | 64 | file_menu.addSeparator() 65 | 66 | menu_action = QtWidgets.QAction("Exit", self) 67 | menu_action.triggered.connect(self.parent.quit) # type: ignore 68 | file_menu.addAction(menu_action) # type: ignore 69 | 70 | edit_menu = main_menu.addMenu("Edit") 71 | 72 | menu_action = QtWidgets.QAction("Enable Selected Mods", self) 73 | menu_action.triggered.connect(self.main_widget.enable) # type: ignore 74 | edit_menu.addAction(menu_action) # type: ignore 75 | 76 | menu_action = QtWidgets.QAction("Disable Selected Mods", self) 77 | menu_action.triggered.connect(self.main_widget.disable) # type: ignore 78 | edit_menu.addAction(menu_action) # type: ignore 79 | 80 | edit_menu.addSeparator() 81 | 82 | menu_action = QtWidgets.QAction("Change Mod Install Folder", self) 83 | menu_action.triggered.connect(self.main_widget.select_mod_install) # type: ignore 84 | edit_menu.addAction(menu_action) # type: ignore 85 | 86 | info_menu = main_menu.addMenu("Info") 87 | 88 | menu_action = QtWidgets.QAction("Refresh Mods", self) 89 | menu_action.triggered.connect(self.main_widget.refresh) # type: ignore 90 | info_menu.addAction(menu_action) # type: ignore 91 | 92 | menu_action = QtWidgets.QAction("Mod Info", self) 93 | menu_action.triggered.connect(self.main_widget.info) # type: ignore 94 | info_menu.addAction(menu_action) # type: ignore 95 | 96 | help_menu = main_menu.addMenu("Help") 97 | 98 | menu_action = QtWidgets.QAction("About", self) 99 | menu_action.triggered.connect(self.main_widget.about) # type: ignore 100 | help_menu.addAction(menu_action) # type: ignore 101 | 102 | menu_action = QtWidgets.QAction("Versions", self) 103 | menu_action.triggered.connect(self.main_widget.versions) # type: ignore 104 | help_menu.addAction(menu_action) # type: ignore 105 | 106 | help_menu.addSeparator() 107 | 108 | menu_action = QtWidgets.QAction("Open Official Website", self) 109 | menu_action.triggered.connect( # type: ignore 110 | lambda: webbrowser.open("https://github.com/NathanVaughn/msfs-mod-manager/") 111 | ) 112 | help_menu.addAction(menu_action) # type: ignore 113 | 114 | menu_action = QtWidgets.QAction("Open Issues/Suggestions", self) 115 | menu_action.triggered.connect( # type: ignore 116 | lambda: webbrowser.open( 117 | "https://github.com/NathanVaughn/msfs-mod-manager/issues/" 118 | ) 119 | ) 120 | help_menu.addAction(menu_action) # type: ignore 121 | 122 | help_menu.addSeparator() 123 | 124 | menu_action = QtWidgets.QAction("Open Debug Log", self) 125 | menu_action.triggered.connect(lambda: os.startfile(DEBUG_LOG)) # type: ignore 126 | help_menu.addAction(menu_action) # type: ignore 127 | 128 | menu_action = QtWidgets.QAction("Open Config File", self) 129 | menu_action.triggered.connect(lambda: os.startfile(CONFIG_FILE)) # type: ignore 130 | help_menu.addAction(menu_action) # type: ignore 131 | 132 | help_menu.addSeparator() 133 | 134 | menu_action = QtWidgets.QAction("Open Community Folder", self) 135 | menu_action.triggered.connect( # type: ignore 136 | lambda: os.startfile(self.main_widget.flight_sim.get_sim_mod_folder()) 137 | ) 138 | help_menu.addAction(menu_action) # type: ignore 139 | 140 | menu_action = QtWidgets.QAction("Open Mod Install Folder", self) 141 | menu_action.triggered.connect( # type: ignore 142 | lambda: os.startfile(files.get_mod_install_folder()) 143 | ) 144 | help_menu.addAction(menu_action) # type: ignore 145 | 146 | def set_theme(self) -> None: 147 | """Apply theme to the window.""" 148 | # apply theme 149 | set_theme(self.appctxt, self.theme_menu_action.isChecked()) # type: ignore 150 | # reformat table 151 | self.main_widget.main_table.set_colors(self.theme_menu_action.isChecked()) 152 | self.main_widget.main_table.resize() 153 | -------------------------------------------------------------------------------- /src/main/python/widgets/progress_widget.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple, Union 2 | 3 | import PySide2.QtCore as QtCore 4 | import PySide2.QtWidgets as QtWidgets 5 | from fbs_runtime.application_context.PySide2 import ApplicationContext 6 | 7 | 8 | class progress_widget(QtWidgets.QDialog): 9 | def __init__( 10 | self, parent: QtWidgets.QWidget = None, appctxt: ApplicationContext = None 11 | ) -> None: 12 | """Progress bar dialog.""" 13 | QtWidgets.QDialog.__init__(self) 14 | self.parent = parent # type: ignore 15 | self.appctxt = appctxt 16 | 17 | # enum types 18 | self.INFINITE = 0 19 | self.PERCENT = 1 20 | 21 | self.mode = self.INFINITE 22 | 23 | self.setWindowTitle("Progress") 24 | self.setWindowFlags( 25 | QtCore.Qt.WindowSystemMenuHint # type: ignore 26 | | QtCore.Qt.WindowTitleHint # type: ignore 27 | # | QtCore.Qt.WindowCloseButtonHint 28 | ) 29 | self.setWindowModality(QtCore.Qt.ApplicationModal) # type: ignore 30 | 31 | self.layout = QtWidgets.QVBoxLayout() # type: ignore 32 | 33 | self.activity = QtWidgets.QLabel(parent=self) # type: ignore 34 | self.activity.setWordWrap(True) 35 | self.layout.addWidget(self.activity) 36 | 37 | self.bar = QtWidgets.QProgressBar(self) 38 | self.layout.addWidget(self.bar) 39 | 40 | self.setLayout(self.layout) 41 | 42 | self.show() 43 | self.raise_() 44 | 45 | self.setFixedSize(500, 100) 46 | 47 | def set_mode(self, mode: Any) -> None: 48 | """Sets the mode of the progress bar.""" 49 | 50 | if mode == self.INFINITE: 51 | # Set the progress bar to be infinite 52 | self.bar.setMaximum(0) 53 | self.bar.setMinimum(0) 54 | self.bar.setValue(0) 55 | self.mode = self.INFINITE 56 | elif mode == self.PERCENT: 57 | # Set the progress bar to be percentage 58 | self.bar.setMaximum(100) 59 | self.bar.setMinimum(0) 60 | self.bar.setValue(0) 61 | self.mode = self.PERCENT 62 | 63 | def set_activity(self, message: str) -> None: 64 | """Update the displayed message.""" 65 | self.activity.setText(message) 66 | 67 | def set_percent(self, percent: Union[Tuple[int, int], int], total=None) -> None: 68 | """Update the progress percent.""" 69 | if self.mode != self.PERCENT: 70 | self.set_mode(self.PERCENT) 71 | 72 | if total: 73 | self.bar.setMaximum(total) 74 | 75 | if isinstance(percent, tuple): 76 | self.bar.setMaximum(percent[1]) 77 | self.bar.setValue(percent[0]) 78 | else: 79 | self.bar.setValue(percent) 80 | -------------------------------------------------------------------------------- /src/main/python/widgets/versions_widget.py: -------------------------------------------------------------------------------- 1 | import PySide2.QtCore as QtCore 2 | import PySide2.QtWidgets as QtWidgets 3 | from fbs_runtime.application_context.PySide2 import ApplicationContext 4 | 5 | from lib.flight_sim import flight_sim 6 | from lib.version import get_version 7 | 8 | 9 | class versions_widget(QtWidgets.QDialog): 10 | def __init__( 11 | self, 12 | flight_sim_handle: flight_sim, 13 | parent: QtWidgets.QWidget = None, 14 | appctxt: ApplicationContext = None, 15 | ) -> None: 16 | """Game and application versions widget.""" 17 | QtWidgets.QDialog.__init__(self) 18 | self.flight_sim = flight_sim_handle 19 | self.parent = parent # type: ignore 20 | self.appctxt = appctxt 21 | 22 | self.setWindowTitle("Versions") 23 | self.setWindowFlags( 24 | QtCore.Qt.WindowSystemMenuHint # type: ignore 25 | | QtCore.Qt.WindowTitleHint # type: ignore 26 | | QtCore.Qt.WindowCloseButtonHint 27 | ) 28 | self.setWindowModality(QtCore.Qt.ApplicationModal) # type: ignore 29 | 30 | self.layout = QtWidgets.QFormLayout() # type: ignore 31 | 32 | self.app_version_field = QtWidgets.QLineEdit(self) 33 | self.app_version_field.setReadOnly(True) 34 | self.layout.addRow("Application Version:", self.app_version_field) # type: ignore 35 | 36 | self.game_version_field = QtWidgets.QLineEdit(self) 37 | self.game_version_field.setReadOnly(True) 38 | self.layout.addRow("Game Version:", self.game_version_field) # type: ignore 39 | 40 | self.setLayout(self.layout) 41 | self.get_versions() 42 | 43 | self.show() 44 | self.setFixedSize(self.width(), self.height()) 45 | 46 | def get_versions(self) -> None: 47 | self.app_version_field.setText(get_version(self.appctxt)) # type: ignore 48 | self.game_version_field.setText(self.flight_sim.get_game_version()) 49 | -------------------------------------------------------------------------------- /src/main/resources/base/fs_style.qss: -------------------------------------------------------------------------------- 1 | /* Application */ 2 | 3 | QWidget { 4 | text-transform: uppercase; 5 | font-family: Roboto, Arial; 6 | 7 | background-color: #303133; 8 | } 9 | 10 | /* QMainWindow */ 11 | 12 | QMainWindow { 13 | background-color: #303133; 14 | } 15 | 16 | /* QMenuBar */ 17 | 18 | QMenuBar { 19 | color: #FFFFFF; 20 | border: 1px solid transparent; 21 | border-bottom-color: #626463; 22 | font-weight: bold; 23 | text-align: left; 24 | } 25 | 26 | QMenuBar::item { 27 | color: #FFFFFF; 28 | padding: 8px; 29 | padding-top: 2px; 30 | padding-bottom: 2px; 31 | margin-right: 5px; 32 | border: 5px solid transparent; 33 | border-bottom-color: #00B4FF; 34 | } 35 | 36 | QMenuBar::item:selected { 37 | color: #00B4FF; 38 | text-align: left; 39 | background-color: #FFFFFF; 40 | border-bottom-color: transparent; 41 | } 42 | 43 | /* QMenu */ 44 | 45 | QMenu { 46 | background-color: #191A1B; 47 | color: #FFFFFF; 48 | } 49 | 50 | QMenu::item:selected { 51 | background-color: #FFFFFF; 52 | color: #000000; 53 | } 54 | 55 | /* QPushbutton */ 56 | 57 | QPushButton { 58 | border: 0px; 59 | text-align: left; 60 | padding: 6px; 61 | min-width: 40px; 62 | font-weight: bold; 63 | 64 | background-color: #00B4FF; 65 | color: #FFFFFF; 66 | } 67 | 68 | QPushButton:hover { 69 | background-color: #FFFFFF; 70 | color: #000000; 71 | } 72 | 73 | /* QScrollBar */ 74 | 75 | QScrollBar { 76 | background-color: #191A1B; 77 | } 78 | 79 | QScrollBar:vertical { 80 | width: 10px; 81 | } 82 | 83 | QScrollBar:horizontal { 84 | height: 10px; 85 | } 86 | 87 | QScrollBar::handle { 88 | background-color: #00B4FF; 89 | margin: 1px; 90 | } 91 | 92 | QScrollBar::sub-page:horizontal, 93 | QScrollBar::sub-page:vertical, 94 | QScrollBar::add-page:horizontal, 95 | QScrollBar::add-page:vertical, 96 | QScrollBar::sub-line:horizontal, 97 | QScrollBar::sub-line:vertical, 98 | QScrollBar::add-line:horizontal, 99 | QScrollBar::add-line:vertlical { 100 | background-color: transparent; 101 | } 102 | 103 | /* QTableWidget */ 104 | 105 | QTableView { 106 | background-color: #191A1B; 107 | alternate-background-color: #27282B; 108 | gridline-color: #626463; 109 | border: 0px; 110 | } 111 | 112 | QTableView::item { 113 | /*color: #FFFFFF;*/ 114 | border: 1px solid #626463; 115 | } 116 | 117 | QTableView[accessibleName="info_files"]::item { 118 | color: #FFFFFF; 119 | } 120 | 121 | QTableView::item:hover { 122 | color: #000000; 123 | background-color: #FFFFFF; 124 | font-weight: bold; 125 | } 126 | 127 | QTableView::item:selected { 128 | color: #00B4FF; 129 | background-color: #FFFFFF; 130 | font-weight: bold; 131 | } 132 | 133 | QTableView QTableCornerButton::section { 134 | background-color: #4B4C4C; 135 | border: 1px solid #27282B; 136 | } 137 | 138 | /* QHeaderView */ 139 | 140 | QHeaderView { 141 | background-color: #191A1B; 142 | } 143 | 144 | QHeaderView::section { 145 | background-color: #626463; 146 | border: 1px solid #27282B; 147 | padding-left: 3px; 148 | } 149 | 150 | /* QMessageBox */ 151 | 152 | QMessageBox QLabel { 153 | color: #FFFFFF; 154 | } 155 | 156 | QDialog QLabel { 157 | color: #FFFFFF; 158 | } 159 | 160 | /* QLineEdit */ 161 | QLineEdit { 162 | background-color: #191A1B; 163 | color: #FFFFFF; 164 | border: 1px solid #626463; 165 | } 166 | 167 | /* QGroupBox */ 168 | QGroupBox { 169 | border: 1px solid #626463; 170 | } 171 | 172 | /* QLabel */ 173 | QLabel { 174 | color: #FFFFFF; 175 | } -------------------------------------------------------------------------------- /src/main/resources/base/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/resources/base/icons/icon.ico -------------------------------------------------------------------------------- /src/main/resources/base/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NathanVaughn/msfs-mod-manager/febc38c25641fa0fd134da71b25fa90fcc3198ed/src/main/resources/base/icons/icon.png --------------------------------------------------------------------------------