├── .github └── issue_template.md ├── .gitignore ├── LICENSE ├── NOTICE ├── README.md ├── fbs ├── __init__.py ├── __main__.py ├── _aws.py ├── _defaults │ ├── requirements │ │ ├── arch.txt │ │ ├── base.txt │ │ ├── fedora.txt │ │ ├── linux.txt │ │ └── ubuntu.txt │ └── src │ │ ├── build │ │ ├── docker │ │ │ ├── arch │ │ │ │ ├── .bashrc │ │ │ │ ├── Dockerfile │ │ │ │ ├── gpg-agent.conf │ │ │ │ └── motd │ │ │ ├── fedora │ │ │ │ ├── .bashrc │ │ │ │ ├── .rpmmacros │ │ │ │ ├── Dockerfile │ │ │ │ ├── gpg-agent.conf │ │ │ │ └── motd │ │ │ └── ubuntu │ │ │ │ ├── .bashrc │ │ │ │ ├── Dockerfile │ │ │ │ ├── gpg-agent.conf │ │ │ │ ├── gpg.conf │ │ │ │ └── motd │ │ └── settings │ │ │ ├── arch.json │ │ │ ├── base.json │ │ │ ├── fedora.json │ │ │ ├── linux.json │ │ │ ├── mac.json │ │ │ ├── release.json │ │ │ ├── ubuntu.json │ │ │ └── windows.json │ │ ├── freeze │ │ ├── mac │ │ │ └── Contents │ │ │ │ └── Info.plist │ │ └── windows │ │ │ └── version_info.py │ │ ├── installer │ │ ├── linux │ │ │ └── usr │ │ │ │ └── share │ │ │ │ └── applications │ │ │ │ └── AppName.desktop │ │ └── windows │ │ │ └── Installer.nsi │ │ └── repo │ │ ├── fedora │ │ └── AppName.repo │ │ └── ubuntu │ │ └── distributions ├── _gpg.py ├── _server.py ├── _state.py ├── builtin_commands │ ├── __init__.py │ ├── _account.py │ ├── _docker.py │ ├── _gpg │ │ ├── Dockerfile │ │ ├── __init__.py │ │ ├── genkey.sh │ │ └── gpg-agent.conf │ ├── _licensing.py │ ├── _util.py │ └── project_template │ │ └── src │ │ ├── build │ │ └── settings │ │ │ ├── base.json │ │ │ ├── linux.json │ │ │ └── mac.json │ │ └── main │ │ ├── icons │ │ ├── Icon.ico │ │ ├── README.md │ │ ├── base │ │ │ ├── 16.png │ │ │ ├── 24.png │ │ │ ├── 32.png │ │ │ ├── 48.png │ │ │ └── 64.png │ │ ├── linux │ │ │ ├── 1024.png │ │ │ ├── 128.png │ │ │ ├── 256.png │ │ │ └── 512.png │ │ └── mac │ │ │ ├── 1024.png │ │ │ ├── 128.png │ │ │ ├── 256.png │ │ │ └── 512.png │ │ └── python │ │ └── main.py ├── cmdline.py ├── freeze │ ├── __init__.py │ ├── arch.py │ ├── fedora.py │ ├── hooks │ │ ├── __init__.py │ │ ├── hook-PySide2.py │ │ └── hook-shiboken2.py │ ├── linux.py │ ├── mac.py │ ├── ubuntu.py │ └── windows.py ├── installer │ ├── __init__.py │ ├── arch.py │ ├── fedora.py │ ├── linux.py │ ├── mac │ │ ├── __init__.py │ │ └── create-dmg │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── builder │ │ │ └── create-dmg.builder │ │ │ ├── create-dmg │ │ │ ├── sample │ │ │ └── support │ │ │ ├── brew-me.sh │ │ │ ├── dmg-license.py │ │ │ └── template.applescript │ ├── ubuntu.py │ └── windows.py ├── repo │ ├── __init__.py │ ├── arch.py │ ├── fedora.py │ └── ubuntu.py ├── resources.py ├── sign │ ├── __init__.py │ └── windows.py ├── sign_installer │ ├── __init__.py │ ├── arch.py │ ├── fedora.py │ └── windows.py └── upload.py ├── fbs_runtime ├── __init__.py ├── _fbs.py ├── _frozen.py ├── _resources.py ├── _settings.py ├── _signal.py ├── _source.py ├── _state.py ├── application_context │ ├── PyQt5.py │ ├── PySide2.py │ └── __init__.py ├── excepthook │ ├── __init__.py │ ├── _util.py │ └── sentry.py ├── licensing.py └── platform.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_fbs ├── __init__.py ├── builtin_commands │ ├── __init__.py │ ├── test___init__.py │ └── test__util.py ├── test_freeze.py └── test_settings.py └── test_fbs_runtime ├── __init__.py ├── excepthook ├── __init__.py └── test__util.py ├── test__settings.py └── test_licensing.py /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | Welcome to fbs's issue tracker! 2 | 3 | Have you already purchased a license for fbs, or are you planning to do so in the near future? If yes, feel free to ask anything you want here. Just delete this text and type away. I will be happy to help. 4 | 5 | Otherwise, please understand that I am essentially helping you in my very limited spare time. While I'm happy to help, I have little patience for people who do not take the time to make it easy for me to help them. 6 | 7 | Do you want to request an improvement to fbs? If yes, please delete this text and explain your suggestion clearly and why it would be useful for other fbs users. 8 | 9 | Are you getting an error? Maybe `fbs run` works, but the app created with `fbs freeze` won't start? If yes, please follow the steps at https://build-system.fman.io/troubleshooting. Do not just skim over them. Read and follow them line by line. Hopefully this will fix your problem. If not, read on. 10 | 11 | Is it a problem with one of your dependencies? If the dependency is PyQt or PySide and you are using a version supported by fbs (see the troubleshooting link above), then please jump to the next paragraph and let me know. Otherwise, if it's another library, try googling "PyInstaller [your dependency problem]". If that doesn't help, please go to PyInstaller's issue tracker and request a fix there. fbs uses PyInstaller for dependency management and so any dependency-related problems should be fixed there. 12 | 13 | Okay. At this point, all easy solutions to your problem have been ruled out. Are you here to get help with your app? Then please go to StackOverflow.com and ask there. This issue tracker is only for changes / improvements to fbs. Please only continue to the next paragraph if your goal of writing here is to improve fbs, not just to get help with your app. I frequently close issues here that violate this point. 14 | 15 | If you have read all of the above, and only then, please delete this text and let me know the following: 16 | 17 | * Your operating system(s) 18 | * Your Python version 19 | * Your fbs version 20 | * Your PyInstaller version 21 | * Your PyQt / PySide version 22 | * A copy of any error messages you are getting. Use three backticks ```...``` before and after to format them. 23 | * A (minimal!) script that reproduces the problem you are experiencing. 24 | 25 | Please don't just paste the output of `pip freeze` here. Also, I repeat, do not post your entire application's code. Create a _minimal_, self-contained script that reproduces the problem. It should not have any dependencies other than fbs, PyInstaller and PyQt or PySide. If you post too much code, then nobody will read it. 26 | 27 | Thanks, 28 | 29 | Michael 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | .venv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | .idea/ 103 | 104 | /src/ 105 | .DS_Store 106 | /cache/ -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | fman build system (fbs) 2 | ======================= 3 | Copyright (c) 2016 - 2021 Michael Herrmann 4 | 5 | fbs depends on several open source libraries and tools. This file describes 6 | which open source software is used, and under which terms. To see precisely how 7 | a library is used, simply search fbs's source code for the library name. 8 | 9 | Some open source licenses such as the (L)GPL require that that the source code 10 | for the software used under the respective license be made available. If you 11 | would like to receive a copy of such source code, please send an email to 12 | michael at herrmann (double r, double n) dot io. 13 | 14 | PyQt5 15 | ===== 16 | URL: https://riverbankcomputing.com/software/pyqt/intro 17 | Version: 5.9.2 18 | 19 | Imported by some of fbs's Python files. 20 | 21 | Used under the terms of the GNU General Public License version 3. The license 22 | text can be found in the LICENSE file. 23 | 24 | PySide2 25 | ======= 26 | URL: https://wiki.qt.io/Qt_for_Python 27 | Version: 5.12.0 28 | 29 | Imported by some of fbs's Python files. 30 | 31 | Used under the terms of the GNU Lesser General Public License version 3. 32 | A copy of the license is available at http://www.gnu.org/. 33 | 34 | Qt 35 | == 36 | URL: https://www.qt.io 37 | Version: 5 38 | 39 | Used indirectly, through PyQt5 or PySide2, from fbs's Python code. 40 | 41 | Used under the terms of the GNU Lesser General Public License version 3. 42 | A copy of the license is available at http://www.gnu.org/. 43 | 44 | PyInstaller 45 | =========== 46 | URL: https://www.pyinstaller.org 47 | Version: 3.4 48 | 49 | fbs invokes PyInstaller via its command line interface. PyInstaller is 50 | distributed under the GPL (v2 or later), with an exception that allows usage of 51 | PyInstaller to build and distribute non-free programs. Because fbs does not link 52 | to PyInstaller, and because of this exception, PyInstaller's license does not 53 | impose any restrictions on fbs. PyInstaller is merely mentioned here for 54 | completeness. 55 | 56 | boto3 57 | ===== 58 | URL: https://aws.amazon.com/sdk-for-python/ 59 | 60 | Imported by fbs's Python code. 61 | 62 | Copyright 2013-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 63 | 64 | Used under the terms of the Apache License, Version 2.0. Please see below. 65 | 66 | rsa 67 | ===== 68 | URL: https://stuvel.eu/rsa 69 | Version: 3.4.2 70 | 71 | Imported by fbs's Python code. 72 | 73 | Copyright 2011 Sybren A. Stüvel 74 | 75 | Used under the terms of the Apache License, Version 2.0. Please see below. 76 | 77 | yoursway-create-dmg 78 | =================== 79 | URL: https://github.com/andreyvit/create-dmg 80 | Copyright (c) 2008-2014 Andrey Tarantsov 81 | 82 | Distributed as part of fbs. 83 | 84 | The MIT License (MIT) 85 | 86 | Permission is hereby granted, free of charge, to any person obtaining a copy 87 | of this software and associated documentation files (the "Software"), to deal 88 | in the Software without restriction, including without limitation the rights 89 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 90 | copies of the Software, and to permit persons to whom the Software is 91 | furnished to do so, subject to the following conditions: 92 | 93 | The above copyright notice and this permission notice shall be included in all 94 | copies or substantial portions of the Software. 95 | 96 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 97 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 98 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 99 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 100 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 101 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 102 | SOFTWARE. 103 | 104 | [ Apache License ] 105 | ================== 106 | Version 2.0, January 2004 107 | 108 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 109 | 110 | 1. Definitions. 111 | 112 | “License” shall mean the terms and conditions for use, reproduction, and 113 | distribution as defined by Sections 1 through 9 of this document. 114 | 115 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 116 | owner that is granting the License. 117 | 118 | “Legal Entity” shall mean the union of the acting entity and all other entities 119 | that control, are controlled by, or are under common control with that entity. 120 | For the purposes of this definition, “control” means (i) the power, direct or 121 | indirect, to cause the direction or management of such entity, whether by 122 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 123 | outstanding shares, or (iii) beneficial ownership of such entity. 124 | 125 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 126 | permissions granted by this License. 127 | 128 | “Source” form shall mean the preferred form for making modifications, including 129 | but not limited to software source code, documentation source, and configuration 130 | files. 131 | 132 | “Object” form shall mean any form resulting from mechanical transformation or 133 | translation of a Source form, including but not limited to compiled object code, 134 | generated documentation, and conversions to other media types. 135 | 136 | “Work” shall mean the work of authorship, whether in Source or Object form, made 137 | available under the License, as indicated by a copyright notice that is included 138 | in or attached to the work (an example is provided in the Appendix below). 139 | 140 | “Derivative Works” shall mean any work, whether in Source or Object form, that 141 | is based on (or derived from) the Work and for which the editorial revisions, 142 | annotations, elaborations, or other modifications represent, as a whole, an 143 | original work of authorship. For the purposes of this License, Derivative Works 144 | shall not include works that remain separable from, or merely link (or bind by 145 | name) to the interfaces of, the Work and Derivative Works thereof. 146 | 147 | “Contribution” shall mean any work of authorship, including the original version 148 | of the Work and any modifications or additions to that Work or Derivative Works 149 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 150 | by the copyright owner or by an individual or Legal Entity authorized to submit 151 | on behalf of the copyright owner. For the purposes of this definition, 152 | “submitted” means any form of electronic, verbal, or written communication sent 153 | to the Licensor or its representatives, including but not limited to 154 | communication on electronic mailing lists, source code control systems, and 155 | issue tracking systems that are managed by, or on behalf of, the Licensor for 156 | the purpose of discussing and improving the Work, but excluding communication 157 | that is conspicuously marked or otherwise designated in writing by the copyright 158 | owner as “Not a Contribution.” 159 | 160 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 161 | of whom a Contribution has been received by Licensor and subsequently 162 | incorporated within the Work. 163 | 164 | 2. Grant of Copyright License. Subject to the terms and conditions of this 165 | License, each Contributor hereby grants to You a perpetual, worldwide, 166 | non-exclusive, no-charge, royalty-free, irrevocable copyright license to 167 | reproduce, prepare Derivative Works of, publicly display, publicly perform, 168 | sublicense, and distribute the Work and such Derivative Works in Source or 169 | Object form. 170 | 171 | 3. Grant of Patent License. Subject to the terms and conditions of this License, 172 | each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, 173 | no-charge, royalty-free, irrevocable (except as stated in this section) patent 174 | license to make, have made, use, offer to sell, sell, import, and otherwise 175 | transfer the Work, where such license applies only to those patent claims 176 | licensable by such Contributor that are necessarily infringed by their 177 | Contribution(s) alone or by combination of their Contribution(s) with the Work 178 | to which such Contribution(s) was submitted. If You institute patent litigation 179 | against any entity (including a cross-claim or counterclaim in a lawsuit) 180 | alleging that the Work or a Contribution incorporated within the Work 181 | constitutes direct or contributory patent infringement, then any patent licenses 182 | granted to You under this License for that Work shall terminate as of the date 183 | such litigation is filed. 184 | 185 | 4. Redistribution. You may reproduce and distribute copies of the Work or 186 | Derivative Works thereof in any medium, with or without modifications, and in 187 | Source or Object form, provided that You meet the following conditions: 188 | 189 | You must give any other recipients of the Work or Derivative Works a copy of 190 | this License; and 191 | You must cause any modified files to carry prominent notices stating that 192 | You changed the files; and 193 | You must retain, in the Source form of any Derivative Works that You 194 | distribute, all copyright, patent, trademark, and attribution notices from 195 | the Source form of the Work, excluding those notices that do not pertain to 196 | any part of the Derivative Works; and 197 | If the Work includes a “NOTICE” text file as part of its distribution, then 198 | any Derivative Works that You distribute must include a readable copy of the 199 | attribution notices contained within such NOTICE file, excluding those 200 | notices that do not pertain to any part of the Derivative Works, in at least 201 | one of the following places: within a NOTICE text file distributed as part 202 | of the Derivative Works; within the Source form or documentation, if 203 | provided along with the Derivative Works; or, within a display generated by 204 | the Derivative Works, if and wherever such third-party notices normally 205 | appear. The contents of the NOTICE file are for informational purposes only 206 | and do not modify the License. You may add Your own attribution notices 207 | within Derivative Works that You distribute, alongside or as an addendum to 208 | the NOTICE text from the Work, provided that such additional attribution 209 | notices cannot be construed as modifying the License. 210 | 211 | You may add Your own copyright statement to Your modifications and may provide 212 | additional or different license terms and conditions for use, reproduction, or 213 | distribution of Your modifications, or for any such Derivative Works as a whole, 214 | provided Your use, reproduction, and distribution of the Work otherwise complies 215 | with the conditions stated in this License. 216 | 217 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 218 | Contribution intentionally submitted for inclusion in the Work by You to the 219 | Licensor shall be under the terms and conditions of this License, without any 220 | additional terms or conditions. Notwithstanding the above, nothing herein shall 221 | supersede or modify the terms of any separate license agreement you may have 222 | executed with Licensor regarding such Contributions. 223 | 224 | 6. Trademarks. This License does not grant permission to use the trade names, 225 | trademarks, service marks, or product names of the Licensor, except as required 226 | for reasonable and customary use in describing the origin of the Work and 227 | reproducing the content of the NOTICE file. 228 | 229 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in 230 | writing, Licensor provides the Work (and each Contributor provides its 231 | Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 232 | KIND, either express or implied, including, without limitation, any warranties 233 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 234 | PARTICULAR PURPOSE. You are solely responsible for determining the 235 | appropriateness of using or redistributing the Work and assume any risks 236 | associated with Your exercise of permissions under this License. 237 | 238 | 8. Limitation of Liability. In no event and under no legal theory, whether in 239 | tort (including negligence), contract, or otherwise, unless required by 240 | applicable law (such as deliberate and grossly negligent acts) or agreed to in 241 | writing, shall any Contributor be liable to You for damages, including any 242 | direct, indirect, special, incidental, or consequential damages of any character 243 | arising as a result of this License or out of the use or inability to use the 244 | Work (including but not limited to damages for loss of goodwill, work stoppage, 245 | computer failure or malfunction, or any and all other commercial damages or 246 | losses), even if such Contributor has been advised of the possibility of such 247 | damages. 248 | 249 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or 250 | Derivative Works thereof, You may choose to offer, and charge a fee for, 251 | acceptance of support, warranty, indemnity, or other liability obligations 252 | and/or rights consistent with this License. However, in accepting such 253 | obligations, You may act only on Your own behalf and on Your sole 254 | responsibility, not on behalf of any other Contributor, and only if You agree to 255 | indemnify, defend, and hold each Contributor harmless for any liability incurred 256 | by, or claims asserted against, such Contributor by reason of your accepting any 257 | such warranty or additional liability. 258 | 259 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository hosts the source code and [issue tracker](../../issues) of fman's build system. For more information, please see the home page: https://build-system.fman.io. 2 | -------------------------------------------------------------------------------- /fbs/__init__.py: -------------------------------------------------------------------------------- 1 | from fbs import _state 2 | from fbs._state import LOADED_PROFILES 3 | from fbs_runtime import FbsError, _source 4 | from fbs_runtime._fbs import get_core_settings, get_default_profiles 5 | from fbs_runtime._settings import load_settings, expand_placeholders 6 | from fbs_runtime._source import get_settings_paths 7 | from os.path import abspath 8 | 9 | import sys 10 | 11 | """ 12 | fbs populates SETTINGS with the current build settings. A typical example is 13 | SETTINGS['app_name'], which you define in src/build/settings/base.json. 14 | """ 15 | SETTINGS = _state.SETTINGS 16 | 17 | def init(project_dir): 18 | """ 19 | Call this if you are invoking neither `fbs` on the command line nor 20 | fbs.cmdline.main() from Python. 21 | """ 22 | if sys.version_info[0] != 3 or sys.version_info[1] not in (5, 6): 23 | raise FbsError( 24 | 'The free version of fbs only supports Python 3.5 and 3.6.\n' 25 | 'Please obtain fbs Pro from https://build-system.fman.io/pro.' 26 | ) 27 | SETTINGS.update(get_core_settings(abspath(project_dir))) 28 | for profile in get_default_profiles(): 29 | activate_profile(profile) 30 | 31 | def activate_profile(profile_name): 32 | """ 33 | By default, fbs only loads some settings. For instance, 34 | src/build/settings/base.json and .../`os`.json where `os` is one of "mac", 35 | "linux" or "windows". This function lets you load other settings on the fly. 36 | A common example would be during a release, where release.json contains the 37 | production server URL instead of a staging server. 38 | """ 39 | LOADED_PROFILES.append(profile_name) 40 | project_dir = SETTINGS['project_dir'] 41 | json_paths = get_settings_paths(project_dir, LOADED_PROFILES) 42 | core_settings = get_core_settings(project_dir) 43 | SETTINGS.update(load_settings(json_paths, core_settings)) 44 | 45 | def path(path_str): 46 | """ 47 | Return the absolute path of the given file in the project directory. For 48 | instance: path('src/main/python'). The `path_str` argument should always use 49 | forward slashes `/`, even on Windows. You can use placeholders to refer to 50 | settings. For example: path('${freeze_dir}/foo'). 51 | """ 52 | path_str = expand_placeholders(path_str, SETTINGS) 53 | try: 54 | project_dir = SETTINGS['project_dir'] 55 | except KeyError: 56 | error_message = "Cannot call path(...) until fbs.init(...) has been " \ 57 | "called." 58 | raise FbsError(error_message) from None 59 | return _source.path(project_dir, path_str) -------------------------------------------------------------------------------- /fbs/__main__.py: -------------------------------------------------------------------------------- 1 | from logging import StreamHandler 2 | from textwrap import wrap 3 | 4 | import fbs.cmdline 5 | import logging 6 | import sys 7 | 8 | def _main(): 9 | """ 10 | Main entry point for the `fbs` command line script. 11 | 12 | We init logging here instead of in fbs.cmdline.main(...) because the latter 13 | can be called by projects using fbs, and it's bad practice for libraries to 14 | configure logging. See eg. https://stackoverflow.com/a/26087972/1839209. 15 | """ 16 | _init_logging() 17 | fbs.cmdline.main() 18 | 19 | def _init_logging(): 20 | # Redirect INFO or lower to stdout, WARNING or higher to stderr: 21 | stdout = _WrappingStreamHandler(sys.stdout) 22 | stdout.setLevel(logging.DEBUG) 23 | stdout.addFilter(lambda record: record.levelno <= logging.INFO) 24 | # Don't wrap stderr because it may contain stack traces: 25 | stderr = logging.StreamHandler(sys.stderr) 26 | stderr.setLevel(logging.WARNING) 27 | logging.basicConfig( 28 | level=logging.INFO, format='%(message)s', handlers=(stdout, stderr) 29 | ) 30 | 31 | class _WrappingStreamHandler(StreamHandler): 32 | def __init__(self, stream=None, line_length=70): 33 | super().__init__(stream) 34 | self._line_length = line_length 35 | def format(self, record): 36 | result = super().format(record) 37 | if not getattr(record, 'wrap', True): 38 | # Make it possible to prevent wrapping. Eg.: 39 | # _LOG.info('Message', extra={'wrap': False}) 40 | return result 41 | lines = result.split(self.terminator) 42 | new_lines = [] 43 | for line in lines: 44 | new_lines.extend( 45 | wrap(line, self._line_length, replace_whitespace=False) or [''] 46 | ) 47 | return self.terminator.join(new_lines) 48 | 49 | if __name__ == '__main__': 50 | _main() -------------------------------------------------------------------------------- /fbs/_aws.py: -------------------------------------------------------------------------------- 1 | from os.path import relpath, join 2 | 3 | import os 4 | 5 | def upload_folder_contents(dir_path, dest_path, bucket, key, secret): 6 | result = [] 7 | for file_path in _iter_files_recursive(dir_path): 8 | file_relpath = relpath(file_path, dir_path) 9 | # Replace backslashes on Windows by forward slashes: 10 | file_relpath = file_relpath.replace(os.sep, '/') 11 | file_dest = dest_path + '/' + file_relpath 12 | upload_file(file_path, file_dest, bucket, key, secret) 13 | result.append(file_dest) 14 | return result 15 | 16 | def upload_file(file_path, dest_path, bucket, key, secret): 17 | # Import late to not make boto3 a dependency of fbs only when the upload 18 | # functionality is actually used. 19 | import boto3 20 | s3 = boto3.resource( 21 | 's3', aws_access_key_id=key, aws_secret_access_key=secret 22 | ) 23 | s3.Bucket(bucket).upload_file(file_path, dest_path) 24 | 25 | def _iter_files_recursive(dir_path): 26 | for subdir_path, dir_names, file_names in os.walk(dir_path): 27 | for file_name in file_names: 28 | yield join(subdir_path, file_name) -------------------------------------------------------------------------------- /fbs/_defaults/requirements/arch.txt: -------------------------------------------------------------------------------- 1 | -r linux.txt 2 | # PyQt5==5.11 currently gives an error when we deploy and run the frozen app: 3 | # No module named 'PyQt5.sip' 4 | PyQt5<5.11 -------------------------------------------------------------------------------- /fbs/_defaults/requirements/base.txt: -------------------------------------------------------------------------------- 1 | fbs==1.2.7 -------------------------------------------------------------------------------- /fbs/_defaults/requirements/fedora.txt: -------------------------------------------------------------------------------- 1 | # Because Fedora is also based on Gnome, we can typically use the same 2 | # requirements as Ubuntu: 3 | -r ubuntu.txt -------------------------------------------------------------------------------- /fbs/_defaults/requirements/linux.txt: -------------------------------------------------------------------------------- 1 | -r base.txt -------------------------------------------------------------------------------- /fbs/_defaults/requirements/ubuntu.txt: -------------------------------------------------------------------------------- 1 | -r linux.txt 2 | PyQt5==5.9.2 -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/arch/.bashrc: -------------------------------------------------------------------------------- 1 | # Place fpm on the PATH: 2 | export PATH=$PATH:$(ruby -e "puts Gem.user_dir")/bin 3 | PS1='arch:\W$ ' 4 | 5 | # Display welcome message: 6 | [ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/arch/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build on :latest because Arch is a rolling release distribution: 2 | FROM archlinux:latest 3 | 4 | ARG requirements 5 | 6 | # Python 3.7 is the earliest version that won't crash in Arch as of July 2021. 7 | ARG python_version=3.7.11 8 | # List from https://github.com/pyenv/pyenv/wiki#suggested-build-environment: 9 | ARG python_build_deps="base-devel openssl zlib xz git" 10 | 11 | RUN echo 'Server=https://mirror.rackspace.com/archlinux/$repo/os/$arch' > /etc/pacman.d/mirrorlist && \ 12 | pacman -Syy 13 | 14 | # Install pyenv: 15 | RUN pacman -S --noconfirm curl git 16 | ENV PYENV_ROOT /root/.pyenv 17 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 18 | RUN curl https://pyenv.run | bash 19 | RUN pyenv update 20 | 21 | # Install Python: 22 | RUN echo $python_build_deps | xargs pacman -S --noconfirm 23 | RUN CONFIGURE_OPTS=--enable-shared pyenv install $python_version && \ 24 | pyenv global $python_version && \ 25 | pyenv rehash 26 | 27 | # Install fpm: 28 | RUN pacman -S --noconfirm ruby ruby-rdoc && \ 29 | export PATH=$PATH:$(ruby -e "puts Gem.user_dir")/bin && \ 30 | gem update && \ 31 | gem install --no-document fpm 32 | 33 | WORKDIR /root/${app_name} 34 | 35 | # Install Python dependencies: 36 | ADD *.txt /tmp/requirements/ 37 | RUN pip install --upgrade pip && \ 38 | pip install -r "/tmp/requirements/${requirements}" 39 | RUN rm -rf /tmp/requirements/ 40 | 41 | # Welcome message, displayed by ~/.bashrc: 42 | ADD motd /etc/motd 43 | 44 | ADD .bashrc /root/.bashrc 45 | 46 | # Import GPG key for code signing the installer: 47 | ADD private-key.gpg public-key.gpg /tmp/ 48 | RUN gpg -q --batch --yes --passphrase ${gpg_pass} --import /tmp/private-key.gpg /tmp/public-key.gpg && \ 49 | rm /tmp/private-key.gpg /tmp/public-key.gpg 50 | 51 | ADD gpg-agent.conf /root/.gnupg/gpg-agent.conf 52 | RUN gpgconf --kill gpg-agent -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/arch/gpg-agent.conf: -------------------------------------------------------------------------------- 1 | # Allows us to preload the GPG pass phrase for a key via gpg-preset-passphrase: 2 | allow-preset-passphrase -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/arch/motd: -------------------------------------------------------------------------------- 1 | You are now in a Docker container running Arch. To build your app 2 | for this platform, use the normal commands `fbs freeze` etc. 3 | 4 | Note that you can't launch GUIs here. So eg. `fbs run` won't work. 5 | 6 | Another caveat is that target/ here is special: It symlinks to your 7 | usual target/arch/. So when you are done and type `exit` to leave 8 | this container, you can find the produced binaries there. 9 | -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/fedora/.bashrc: -------------------------------------------------------------------------------- 1 | PS1='fedora:\W$ ' 2 | 3 | # Display welcome message: 4 | [ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/fedora/.rpmmacros: -------------------------------------------------------------------------------- 1 | %_signature gpg 2 | %_gpg_path /root/.gnupg 3 | %_gpg_name ${gpg_name} 4 | %_gpgbin /usr/bin/gpg -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/fedora/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build on an old Fedora version on purpose, to maximize compatibility: 2 | FROM fmanbuildsystem/fedora:25 3 | 4 | ARG requirements 5 | 6 | ARG python_version=3.6.12 7 | # List found by trial and error, starting from 8 | # https://github.com/pyenv/pyenv/wiki#suggested-build-environment. `patch` is 9 | # required for at least Python 3.9.7. 10 | ARG python_build_deps="make gcc zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel openssl-devel tk-devel libffi-devel xz-devel patch" 11 | 12 | RUN dnf -y update && dnf clean all 13 | 14 | # Install pyenv: 15 | RUN dnf install -y curl git 16 | ENV PYENV_ROOT /root/.pyenv 17 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 18 | RUN curl https://pyenv.run | bash 19 | RUN pyenv update 20 | 21 | # Install Python: 22 | # findutils contains xargs, which is needed for the next step: 23 | RUN dnf install -y findutils 24 | RUN echo $python_build_deps | xargs dnf install -y 25 | RUN CONFIGURE_OPTS=--enable-shared pyenv install $python_version && \ 26 | pyenv global $python_version && \ 27 | pyenv rehash 28 | 29 | # Install fpm: 30 | RUN dnf install -y ruby-devel gcc make rpm-build libffi-devel && \ 31 | gem install --no-document fpm 32 | 33 | WORKDIR /root/${app_name} 34 | 35 | # Install Python requirements: 36 | ADD *.txt /tmp/requirements/ 37 | RUN pip install --upgrade pip && \ 38 | pip install -r "/tmp/requirements/${requirements}" 39 | RUN rm -rf /tmp/requirements/ 40 | 41 | # Welcome message, displayed by ~/.bashrc: 42 | ADD motd /etc/motd 43 | 44 | ADD .bashrc /root/.bashrc 45 | 46 | ADD gpg-agent.conf /root/.gnupg/gpg-agent.conf 47 | # Avoid GPG warning "unsafe permissions": 48 | RUN chmod -R 600 /root/.gnupg 49 | ADD private-key.gpg public-key.gpg /tmp/ 50 | RUN dnf install -y gpg rpm-sign && \ 51 | gpg -q --batch --yes --passphrase ${gpg_pass} --import /tmp/private-key.gpg /tmp/public-key.gpg && \ 52 | rpm --import /tmp/public-key.gpg && \ 53 | rm /tmp/private-key.gpg /tmp/public-key.gpg 54 | 55 | ADD .rpmmacros /root 56 | 57 | RUN dnf install -y createrepo_c 58 | 59 | ENTRYPOINT ["/bin/bash"] 60 | -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/fedora/gpg-agent.conf: -------------------------------------------------------------------------------- 1 | # Allows us to preload the GPG pass phrase for a key via gpg-preset-passphrase: 2 | allow-preset-passphrase -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/fedora/motd: -------------------------------------------------------------------------------- 1 | You are now in a Docker container running Fedora. To build your app 2 | for this platform, use the normal commands `fbs freeze` etc. 3 | 4 | Note that you can't launch GUIs here. So eg. `fbs run` won't work. 5 | 6 | Another caveat is that target/ here is special: It symlinks to your 7 | usual target/fedora/. So when you are done and type `exit` to leave 8 | this container, you can find the produced binaries there. 9 | -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/ubuntu/.bashrc: -------------------------------------------------------------------------------- 1 | PS1='ubuntu:\W$ ' 2 | 3 | # Display welcome message: 4 | [ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/ubuntu/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build on an old Ubuntu version on purpose, to maximize compatibility: 2 | FROM ubuntu:16.04 3 | 4 | ARG requirements 5 | 6 | ARG python_version=3.6.12 7 | # List from https://github.com/pyenv/pyenv/wiki#suggested-build-environment: 8 | ARG python_build_deps="make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" 9 | 10 | RUN apt-get update && \ 11 | apt-get upgrade -y 12 | 13 | # Install pyenv: 14 | RUN apt-get install -y curl git 15 | ENV PYENV_ROOT /root/.pyenv 16 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 17 | RUN curl https://pyenv.run | bash 18 | RUN pyenv update 19 | 20 | # Install Python: 21 | RUN echo $python_build_deps | xargs apt-get install -y --no-install-recommends 22 | RUN CONFIGURE_OPTS=--enable-shared pyenv install $python_version && \ 23 | pyenv global $python_version && \ 24 | pyenv rehash 25 | 26 | # Add missing file libGL.so.1 for PyQt5.QtGui: 27 | RUN apt-get install libgl1-mesa-glx -y 28 | 29 | # fpm: 30 | RUN apt-get install ruby ruby-dev build-essential -y && \ 31 | gem install --no-document fpm 32 | 33 | WORKDIR /root/${app_name} 34 | 35 | # Install Python requirements: 36 | ADD *.txt /tmp/requirements/ 37 | RUN pip install --upgrade pip && \ 38 | pip install -r "/tmp/requirements/${requirements}" 39 | RUN rm -rf /tmp/requirements/ 40 | 41 | # Welcome message, displayed by ~/.bashrc: 42 | ADD motd /etc/motd 43 | 44 | ADD .bashrc /root/.bashrc 45 | 46 | # Requirements for our use of reprepro: 47 | ADD gpg-agent.conf gpg.conf /root/.gnupg/ 48 | # Avoid GPG warning "unsafe permissions": 49 | RUN chmod -R 600 /root/.gnupg/ 50 | RUN apt-get install reprepro gnupg-agent gnupg2 -y 51 | ADD private-key.gpg public-key.gpg /tmp/ 52 | RUN gpg -q --batch --yes --passphrase ${gpg_pass} --import /tmp/private-key.gpg /tmp/public-key.gpg && \ 53 | rm /tmp/private-key.gpg /tmp/public-key.gpg -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/ubuntu/gpg-agent.conf: -------------------------------------------------------------------------------- 1 | # Allows us to preload the GPG pass phrase for a key via gpg-preset-passphrase: 2 | allow-preset-passphrase -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/ubuntu/gpg.conf: -------------------------------------------------------------------------------- 1 | # Old GPG versions (including the one we use, on Ubuntu 14) use SHA-1 by 2 | # default. Recent Ubuntu versions (in particular, >= 18) no longer allow this 3 | # and give "The following signatures were invalid" when `apt-get update` fetches 4 | # our SHA-1 signed reprepro repository. The following setting ensures that our 5 | # reprepro uses SHA-256 instead: 6 | digest-algo sha256 -------------------------------------------------------------------------------- /fbs/_defaults/src/build/docker/ubuntu/motd: -------------------------------------------------------------------------------- 1 | You are now in a Docker container running Ubuntu. To build your app 2 | for this platform, use the normal commands `fbs freeze` etc. 3 | 4 | Note that you can't launch GUIs here. So eg. `fbs run` won't work. 5 | 6 | Another caveat is that target/ here is special: It symlinks to your 7 | usual target/ubuntu/. So when you are done and type `exit` to leave 8 | this container, you can find the produced binaries there. 9 | -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/arch.json: -------------------------------------------------------------------------------- 1 | { 2 | "installer": "${app_name}.pkg.tar.xz", 3 | "depends": ["qt5-base"], 4 | "depends_opt": [], 5 | "gpg_preset_passphrase": "/usr/lib/gnupg/gpg-preset-passphrase", 6 | "repo_subdir": "arch" 7 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "freeze_dir": "target/${app_name}", 3 | "test_dirs": [ 4 | "src/unittest/python", 5 | "src/integrationtest/python" 6 | ], 7 | "files_to_filter": [ 8 | "src/build/docker/ubuntu/.bashrc", "src/build/docker/ubuntu/Dockerfile", 9 | "src/build/docker/arch/.bashrc", "src/build/docker/arch/Dockerfile", 10 | "src/build/docker/fedora/.bashrc", "src/build/docker/fedora/Dockerfile", 11 | "src/build/docker/fedora/.rpmmacros" 12 | ], 13 | "hidden_imports": [], 14 | "extra_pyinstaller_args": [], 15 | "public_settings": ["app_name", "author", "version", "environment"], 16 | "docker_images": { 17 | "ubuntu": { 18 | "build_files": ["requirements/", "src/sign/linux/"], 19 | "build_args": { 20 | "requirements": "ubuntu.txt" 21 | } 22 | }, 23 | "arch": { 24 | "build_files": ["requirements/", "src/sign/linux/"], 25 | "build_args": { 26 | "requirements": "arch.txt" 27 | } 28 | }, 29 | "fedora": { 30 | "build_files": ["requirements/", "src/sign/linux/"], 31 | "build_args": { 32 | "requirements": "fedora.txt" 33 | } 34 | } 35 | }, 36 | "release": false, 37 | "environment": "local" 38 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/fedora.json: -------------------------------------------------------------------------------- 1 | { 2 | "installer": "${app_name}.rpm", 3 | "gpg_preset_passphrase": "/usr/libexec/gpg-preset-passphrase", 4 | "repo_url": "https://fbs.sh/${fbs_user}/${app_name}/${repo_subdir}", 5 | "gpgkey_url": "https://fbs.sh/${fbs_user}/${app_name}/public-key.gpg", 6 | "repo_subdir": "rpm" 7 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/linux.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": "Utility;", 3 | "description": "", 4 | "author_email": "", 5 | "url": "", 6 | "depends": [], 7 | "files_to_filter": [ 8 | "src/installer/linux/usr/share/applications/AppName.desktop" 9 | ], 10 | "public_settings": ["categories", "description", "author_email", "url"] 11 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/mac.json: -------------------------------------------------------------------------------- 1 | { 2 | "freeze_dir": "target/${app_name}.app", 3 | "mac_bundle_identifier": "", 4 | "installer": "${app_name}.dmg", 5 | "files_to_filter": [ 6 | "src/freeze/mac/Contents/Info.plist" 7 | ], 8 | "show_console_window": false 9 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/release.json: -------------------------------------------------------------------------------- 1 | { 2 | "release": true, 3 | "environment": "production" 4 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/ubuntu.json: -------------------------------------------------------------------------------- 1 | { 2 | "installer": "${app_name}.deb", 3 | "gpg_preset_passphrase": "/usr/lib/gnupg2/gpg-preset-passphrase", 4 | "repo_subdir": "deb" 5 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/build/settings/windows.json: -------------------------------------------------------------------------------- 1 | { 2 | "installer": "${app_name}Setup.exe", 3 | "files_to_filter": [ 4 | "src/freeze/windows/version_info.py", 5 | "src/installer/windows/Installer.nsi" 6 | ], 7 | "show_console_window": false, 8 | "url": "" 9 | } -------------------------------------------------------------------------------- /fbs/_defaults/src/freeze/mac/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleExecutable 6 | ${app_name} 7 | CFBundleDisplayName 8 | ${app_name} 9 | CFBundlePackageType 10 | APPL 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleIconFile 14 | Icon.icns 15 | CFBundleIdentifier 16 | ${mac_bundle_identifier} 17 | LSBackgroundOnly 18 | 0 19 | CFBundleShortVersionString 20 | ${version} 21 | CFBundleVersion 22 | ${version} 23 | CFBundleName 24 | ${app_name} 25 | 26 | NSPrincipalClass 27 | NSApplication 28 | 29 | -------------------------------------------------------------------------------- /fbs/_defaults/src/freeze/windows/version_info.py: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | # 3 | # For more details about fixed file info 'ffi' see: 4 | # http://msdn.microsoft.com/en-us/library/ms646997.aspx 5 | 6 | VSVersionInfo( 7 | ffi=FixedFileInfo( 8 | # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) 9 | # Set not needed items to zero 0. Must always contain 4 elements. 10 | filevers=( 11 | int('${version}'.split('.')[0]), 12 | int('${version}'.split('.')[1]), 13 | int('${version}'.split('.')[2]), 14 | 0 15 | ), 16 | prodvers=( 17 | int('${version}'.split('.')[0]), 18 | int('${version}'.split('.')[1]), 19 | int('${version}'.split('.')[2]), 20 | 0 21 | ), 22 | # Contains a bitmask that specifies the valid bits 'flags'r 23 | mask=0x3f, 24 | # Contains a bitmask that specifies the Boolean attributes of the file. 25 | flags=0x0, 26 | # The operating system for which this file was designed. 27 | # 0x4 - NT and there is no need to change it. 28 | OS=0x40004, 29 | # The general type of file. 30 | # 0x1 - the file is an application. 31 | fileType=0x1, 32 | # The function of the file. 33 | # 0x0 - the function is not defined for this fileType 34 | subtype=0x0, 35 | # Creation date and time stamp. 36 | date=(0, 0) 37 | ), 38 | kids=[ 39 | StringFileInfo( 40 | [ 41 | StringTable( 42 | '040904B0', 43 | [StringStruct('CompanyName', '${author}'), 44 | StringStruct('FileDescription', '${app_name}'), 45 | StringStruct('FileVersion', '${version}.0'), 46 | StringStruct('InternalName', '${app_name}'), 47 | StringStruct('LegalCopyright', '© ${author}. All rights reserved.'), 48 | StringStruct('OriginalFilename', '${app_name}.exe'), 49 | StringStruct('ProductName', '${app_name}'), 50 | StringStruct('ProductVersion', '${version}.0')]) 51 | ]), 52 | VarFileInfo([VarStruct('Translation', [1033, 1200])]) 53 | ] 54 | ) -------------------------------------------------------------------------------- /fbs/_defaults/src/installer/linux/usr/share/applications/AppName.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=${app_name} 3 | Type=Application 4 | Exec=/opt/${app_name}/${app_name} 5 | Terminal=false 6 | NoDisplay=false 7 | Categories=${categories} 8 | Version=1.2 9 | Icon=${app_name} -------------------------------------------------------------------------------- /fbs/_defaults/src/installer/windows/Installer.nsi: -------------------------------------------------------------------------------- 1 | !include MUI2.nsh 2 | !include FileFunc.nsh 3 | !define MUI_ICON "..\${app_name}\Icon.ico" 4 | !define MUI_UNICON "..\${app_name}\Icon.ico" 5 | 6 | !getdllversion "..\${app_name}\${app_name}.exe" ver 7 | !define VERSION "${ver1}.${ver2}.${ver3}.${ver4}" 8 | 9 | VIProductVersion "${VERSION}" 10 | VIAddVersionKey "ProductName" "${app_name}" 11 | VIAddVersionKey "FileVersion" "${VERSION}" 12 | VIAddVersionKey "ProductVersion" "${VERSION}" 13 | VIAddVersionKey "LegalCopyright" "(C) ${author}" 14 | VIAddVersionKey "FileDescription" "${app_name}" 15 | 16 | ;-------------------------------- 17 | ;Perform Machine-level install, if possible 18 | 19 | !define MULTIUSER_EXECUTIONLEVEL Highest 20 | ;Add support for command-line args that let uninstaller know whether to 21 | ;uninstall machine- or user installation: 22 | !define MULTIUSER_INSTALLMODE_COMMANDLINE 23 | !include MultiUser.nsh 24 | !include LogicLib.nsh 25 | 26 | Function .onInit 27 | !insertmacro MULTIUSER_INIT 28 | ;Do not use InstallDir at all so we can detect empty $InstDir! 29 | ${If} $InstDir == "" ; /D not used 30 | ${If} $MultiUser.InstallMode == "AllUsers" 31 | StrCpy $InstDir "$PROGRAMFILES\${app_name}" 32 | ${Else} 33 | StrCpy $InstDir "$LOCALAPPDATA\${app_name}" 34 | ${EndIf} 35 | ${EndIf} 36 | FunctionEnd 37 | 38 | Function un.onInit 39 | !insertmacro MULTIUSER_UNINIT 40 | FunctionEnd 41 | 42 | ;-------------------------------- 43 | ;General 44 | 45 | Name "${app_name}" 46 | OutFile "..\${installer}" 47 | 48 | ;-------------------------------- 49 | ;Interface Settings 50 | 51 | !define MUI_ABORTWARNING 52 | 53 | ;-------------------------------- 54 | ;Pages 55 | 56 | !define MUI_WELCOMEPAGE_TEXT "This wizard will guide you through the installation of ${app_name}.$\r$\n$\r$\n$\r$\nClick Next to continue." 57 | !insertmacro MUI_PAGE_WELCOME 58 | !insertmacro MUI_PAGE_DIRECTORY 59 | !insertmacro MUI_PAGE_INSTFILES 60 | !define MUI_FINISHPAGE_NOAUTOCLOSE 61 | !define MUI_FINISHPAGE_RUN 62 | !define MUI_FINISHPAGE_RUN_CHECKED 63 | !define MUI_FINISHPAGE_RUN_TEXT "Run ${app_name}" 64 | !define MUI_FINISHPAGE_RUN_FUNCTION "LaunchAsNonAdmin" 65 | !insertmacro MUI_PAGE_FINISH 66 | 67 | !insertmacro MUI_UNPAGE_CONFIRM 68 | !insertmacro MUI_UNPAGE_INSTFILES 69 | 70 | ;-------------------------------- 71 | ;Languages 72 | 73 | !insertmacro MUI_LANGUAGE "English" 74 | 75 | ;-------------------------------- 76 | ;Installer Sections 77 | 78 | !define UNINST_KEY \ 79 | "Software\Microsoft\Windows\CurrentVersion\Uninstall\${app_name}" 80 | Section 81 | SetOutPath "$InstDir" 82 | File /r "..\${app_name}\*" 83 | WriteRegStr SHCTX "Software\${app_name}" "" $InstDir 84 | WriteUninstaller "$InstDir\uninstall.exe" 85 | CreateShortCut "$SMPROGRAMS\${app_name}.lnk" "$InstDir\${app_name}.exe" 86 | WriteRegStr SHCTX "${UNINST_KEY}" "DisplayName" "${app_name}" 87 | WriteRegStr SHCTX "${UNINST_KEY}" "UninstallString" \ 88 | "$\"$InstDir\uninstall.exe$\" /$MultiUser.InstallMode" 89 | WriteRegStr SHCTX "${UNINST_KEY}" "QuietUninstallString" \ 90 | "$\"$InstDir\uninstall.exe$\" /$MultiUser.InstallMode /S" 91 | WriteRegStr SHCTX "${UNINST_KEY}" "Publisher" "${author}" 92 | WriteRegStr SHCTX "${UNINST_KEY}" "DisplayIcon" "$InstDir\uninstall.exe" 93 | ${GetSize} "$InstDir" "/S=0K" $0 $1 $2 94 | IntFmt $0 "0x%08X" $0 95 | WriteRegDWORD SHCTX "${UNINST_KEY}" "EstimatedSize" "$0" 96 | 97 | SectionEnd 98 | 99 | ;-------------------------------- 100 | ;Uninstaller Section 101 | 102 | Section "Uninstall" 103 | 104 | RMDir /r "$InstDir" 105 | Delete "$SMPROGRAMS\${app_name}.lnk" 106 | DeleteRegKey /ifempty SHCTX "Software\${app_name}" 107 | DeleteRegKey SHCTX "${UNINST_KEY}" 108 | 109 | SectionEnd 110 | 111 | Function LaunchAsNonAdmin 112 | Exec '"$WINDIR\explorer.exe" "$InstDir\${app_name}.exe"' 113 | FunctionEnd -------------------------------------------------------------------------------- /fbs/_defaults/src/repo/fedora/AppName.repo: -------------------------------------------------------------------------------- 1 | [${app_name}] 2 | name=${app_name} 3 | baseurl=${repo_url} 4 | enabled=1 5 | gpgcheck=1 6 | gpgkey=${gpgkey_url} -------------------------------------------------------------------------------- /fbs/_defaults/src/repo/ubuntu/distributions: -------------------------------------------------------------------------------- 1 | Origin: ${app_name} 2 | Label: ${app_name} 3 | Codename: stable 4 | Architectures: amd64 5 | Components: main 6 | Description: ${description} 7 | SignWith: ${gpg_key} 8 | -------------------------------------------------------------------------------- /fbs/_gpg.py: -------------------------------------------------------------------------------- 1 | from fbs import SETTINGS 2 | from fbs_runtime import FbsError 3 | from subprocess import run, DEVNULL, check_call, check_output, PIPE, \ 4 | CalledProcessError 5 | 6 | import re 7 | 8 | def preset_gpg_passphrase(): 9 | # Ensure gpg-agent is running: 10 | run( 11 | ['gpg-agent', '--daemon', '--use-standard-socket', '-q'], 12 | stdout=DEVNULL, stderr=DEVNULL 13 | ) 14 | gpg_key = SETTINGS['gpg_key'] 15 | try: 16 | keygrip = _get_keygrip(gpg_key) 17 | except GpgDoesNotSupportKeygrip: 18 | # Old GPG versions don't support keygrips; They use the fingerprint 19 | # instead: 20 | keygrip = gpg_key 21 | check_call([ 22 | SETTINGS['gpg_preset_passphrase'], '--preset', '--passphrase', 23 | SETTINGS['gpg_pass'], keygrip 24 | ], stdout=DEVNULL) 25 | 26 | def _get_keygrip(pubkey_id): 27 | try: 28 | output = check_output( 29 | ['gpg2', '--with-keygrip', '-K', pubkey_id], 30 | universal_newlines=True, stderr=PIPE 31 | ) 32 | except CalledProcessError as e: 33 | if 'invalid option "--with-keygrip"' in e.stderr: 34 | raise GpgDoesNotSupportKeygrip() from None 35 | elif 'No secret key' in e.stderr: 36 | raise FbsError( 37 | "GPG could not read your key for code signing. Perhaps you " 38 | "don't want\nto run this command here, but after:\n" 39 | " fbs runvm {ubuntu|fedora|arch}" 40 | ) 41 | raise 42 | pure_signing_subkey = _find_keygrip(output, 'S') 43 | if pure_signing_subkey: 44 | return pure_signing_subkey 45 | any_signing_key = _find_keygrip(output, '[^]]*S[^]]*') 46 | if any_signing_key: 47 | return any_signing_key 48 | raise RuntimeError('Keygrip not found. Output was:\n' + output) 49 | 50 | def _find_keygrip(gpg2_output, type_re): 51 | lines = gpg2_output.split('\n') 52 | for i, line in enumerate(lines): 53 | if re.match(r'.*\[%s\]$' % type_re, line): 54 | for keygrip_line in lines[i + 1:]: 55 | m = re.match(r' +Keygrip = ([A-Z0-9]{40})', keygrip_line) 56 | if m: 57 | return m.group(1) 58 | 59 | class GpgDoesNotSupportKeygrip(RuntimeError): 60 | pass -------------------------------------------------------------------------------- /fbs/_server.py: -------------------------------------------------------------------------------- 1 | from urllib.error import HTTPError 2 | from urllib.request import Request, urlopen 3 | 4 | import json 5 | 6 | _API_URL = 'https://build-system.fman.io/api/' 7 | 8 | def post_json(path, data, encoding='utf-8'): 9 | # We could just use the requests library. But it doesn't pay off to add the 10 | # whole dependency for just this one function. 11 | request = Request(_API_URL + path) 12 | request.add_header('Content-Type', 'application/json; charset=' + encoding) 13 | data_bytes = json.dumps(data).encode(encoding) 14 | request.add_header('Content-Length', len(data_bytes)) 15 | try: 16 | with urlopen(request, data_bytes) as response: 17 | return response.getcode(), response.read().decode(encoding) 18 | except HTTPError as e: 19 | return e.getcode(), e.fp.read().decode(encoding) -------------------------------------------------------------------------------- /fbs/_state.py: -------------------------------------------------------------------------------- 1 | """ 2 | This INTERNAL module is used to manage fbs's global state. Having it here, in 3 | one central place, allows fbs's test suite to manipulate the state to test 4 | various scenarios. 5 | """ 6 | from collections import OrderedDict 7 | 8 | SETTINGS = {} 9 | LOADED_PROFILES = [] 10 | COMMANDS = OrderedDict() 11 | 12 | def get(): 13 | return dict(SETTINGS), list(LOADED_PROFILES), dict(COMMANDS) 14 | 15 | def restore(settings, loaded_profiles, commands): 16 | SETTINGS.clear() 17 | SETTINGS.update(settings) 18 | LOADED_PROFILES.clear() 19 | LOADED_PROFILES.extend(loaded_profiles) 20 | COMMANDS.clear() 21 | COMMANDS.update(commands) -------------------------------------------------------------------------------- /fbs/builtin_commands/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains all of fbs's built-in commands. They are invoked when you 3 | run `fbs ` on the command line. But you are also free to import them in 4 | your Python build script and execute them there. 5 | """ 6 | from fbs import path, SETTINGS, activate_profile 7 | from fbs.builtin_commands._util import prompt_for_value, is_valid_version, \ 8 | require_existing_project, update_json, require_frozen_app, require_installer 9 | from fbs.cmdline import command 10 | from fbs.resources import copy_with_filtering 11 | from fbs.upload import _upload_repo 12 | from fbs_runtime import FbsError 13 | from fbs_runtime.platform import is_windows, is_mac, is_linux, is_arch_linux, \ 14 | is_ubuntu, is_fedora 15 | from getpass import getuser 16 | from importlib.util import find_spec 17 | from os import listdir, remove, unlink, mkdir 18 | from os.path import join, isfile, isdir, islink, dirname, exists, relpath 19 | from shutil import rmtree 20 | from unittest import TestSuite, TextTestRunner, defaultTestLoader 21 | 22 | import logging 23 | import os 24 | import subprocess 25 | import sys 26 | 27 | _LOG = logging.getLogger(__name__) 28 | 29 | @command 30 | def startproject(): 31 | """ 32 | Start a new project in the current directory 33 | """ 34 | if exists('src'): 35 | raise FbsError('The src/ directory already exists. Aborting.') 36 | app = prompt_for_value('App name', default='MyApp') 37 | user = getuser().title() 38 | author = prompt_for_value('Author', default=user) 39 | has_pyqt = _has_module('PyQt5') 40 | has_pyside = _has_module('PySide2') 41 | if has_pyqt and not has_pyside: 42 | python_bindings = 'PyQt5' 43 | elif not has_pyqt and has_pyside: 44 | python_bindings = 'PySide2' 45 | else: 46 | python_bindings = prompt_for_value( 47 | 'Qt bindings', choices=('PyQt5', 'PySide2'), default='PyQt5' 48 | ) 49 | eg_bundle_id = 'com.%s.%s' % ( 50 | author.lower().split()[0], ''.join(app.lower().split()) 51 | ) 52 | mac_bundle_identifier = prompt_for_value( 53 | 'Mac bundle identifier (eg. %s, optional)' % eg_bundle_id, 54 | optional=True 55 | ) 56 | mkdir('src') 57 | template_dir = join(dirname(__file__), 'project_template') 58 | template_path = lambda relpath: join(template_dir, *relpath.split('/')) 59 | copy_with_filtering( 60 | template_dir, '.', { 61 | 'app_name': app, 62 | 'author': author, 63 | 'mac_bundle_identifier': mac_bundle_identifier, 64 | 'python_bindings': python_bindings 65 | }, 66 | files_to_filter=[ 67 | template_path('src/build/settings/base.json'), 68 | template_path('src/build/settings/mac.json'), 69 | template_path('src/main/python/main.py') 70 | ] 71 | ) 72 | print('') 73 | _LOG.info( 74 | "Created the src/ directory. If you have %s installed, you can now " 75 | "do:\n\n fbs run", python_bindings 76 | ) 77 | 78 | @command 79 | def run(): 80 | """ 81 | Run your app from source 82 | """ 83 | require_existing_project() 84 | if not _has_module('PyQt5') and not _has_module('PySide2'): 85 | raise FbsError( 86 | "Couldn't find PyQt5 or PySide2. Maybe you need to:\n" 87 | " pip install PyQt5==5.9.2 or\n" 88 | " pip install PySide2==5.12.2" 89 | ) 90 | env = dict(os.environ) 91 | pythonpath = path('src/main/python') 92 | old_pythonpath = env.get('PYTHONPATH', '') 93 | if old_pythonpath: 94 | pythonpath += os.pathsep + old_pythonpath 95 | env['PYTHONPATH'] = pythonpath 96 | subprocess.run([sys.executable, path(SETTINGS['main_module'])], env=env) 97 | 98 | @command 99 | def freeze(debug=False): 100 | """ 101 | Compile your code to a standalone executable 102 | """ 103 | require_existing_project() 104 | if not _has_module('PyInstaller'): 105 | raise FbsError( 106 | "Could not find PyInstaller. Maybe you need to:\n" 107 | " pip install PyInstaller==3.4" 108 | ) 109 | version = SETTINGS['version'] 110 | if not is_valid_version(version): 111 | raise FbsError( 112 | 'Invalid version detected in settings. It should be three\n' 113 | 'numbers separated by dots, such as "1.2.3". You have:\n\t"%s".\n' 114 | 'Usually, this can be fixed in src/build/settings/base.json.' 115 | % version 116 | ) 117 | # Import respective functions late to avoid circular import 118 | # fbs <-> fbs.freeze.X. 119 | app_name = SETTINGS['app_name'] 120 | if is_mac(): 121 | from fbs.freeze.mac import freeze_mac 122 | freeze_mac(debug=debug) 123 | executable = 'target/%s.app/Contents/MacOS/%s' % (app_name, app_name) 124 | else: 125 | executable = join('target', app_name, app_name) 126 | if is_windows(): 127 | from fbs.freeze.windows import freeze_windows 128 | freeze_windows(debug=debug) 129 | executable += '.exe' 130 | elif is_linux(): 131 | if is_ubuntu(): 132 | from fbs.freeze.ubuntu import freeze_ubuntu 133 | freeze_ubuntu(debug=debug) 134 | elif is_arch_linux(): 135 | from fbs.freeze.arch import freeze_arch 136 | freeze_arch(debug=debug) 137 | elif is_fedora(): 138 | from fbs.freeze.fedora import freeze_fedora 139 | freeze_fedora(debug=debug) 140 | else: 141 | from fbs.freeze.linux import freeze_linux 142 | freeze_linux(debug=debug) 143 | else: 144 | raise FbsError('Unsupported OS') 145 | _LOG.info( 146 | "Done. You can now run `%s`. If that doesn't work, see " 147 | "https://build-system.fman.io/troubleshooting.", executable 148 | ) 149 | 150 | @command 151 | def sign(): 152 | """ 153 | Sign your app, so the user's OS trusts it 154 | """ 155 | require_frozen_app() 156 | if is_windows(): 157 | from fbs.sign.windows import sign_windows 158 | sign_windows() 159 | _LOG.info( 160 | 'Signed all binary files in %s and its subdirectories.', 161 | relpath(path('${freeze_dir}'), path('.')) 162 | ) 163 | elif is_mac(): 164 | _LOG.info('fbs does not yet implement `sign` on macOS.') 165 | else: 166 | _LOG.info('This platform does not support signing frozen apps.') 167 | 168 | @command 169 | def installer(): 170 | """ 171 | Create an installer for your app 172 | """ 173 | require_frozen_app() 174 | linux_distribution_not_supported_msg = \ 175 | "Your Linux distribution is not supported, sorry. " \ 176 | "You can run `fbs buildvm` followed by `fbs runvm` to start a Docker " \ 177 | "VM of a supported distribution." 178 | try: 179 | installer_fname = SETTINGS['installer'] 180 | except KeyError: 181 | if is_linux(): 182 | raise FbsError(linux_distribution_not_supported_msg) 183 | raise 184 | out_file = join('target', installer_fname) 185 | msg_parts = ['Created %s.' % out_file] 186 | if is_windows(): 187 | from fbs.installer.windows import create_installer_windows 188 | create_installer_windows() 189 | elif is_mac(): 190 | from fbs.installer.mac import create_installer_mac 191 | create_installer_mac() 192 | elif is_linux(): 193 | app_name = SETTINGS['app_name'] 194 | if is_ubuntu(): 195 | from fbs.installer.ubuntu import create_installer_ubuntu 196 | create_installer_ubuntu() 197 | install_cmd = 'sudo dpkg -i ' + out_file 198 | remove_cmd = 'sudo dpkg --purge ' + app_name 199 | elif is_arch_linux(): 200 | from fbs.installer.arch import create_installer_arch 201 | create_installer_arch() 202 | install_cmd = 'sudo pacman -U ' + out_file 203 | remove_cmd = 'sudo pacman -R ' + app_name 204 | elif is_fedora(): 205 | from fbs.installer.fedora import create_installer_fedora 206 | create_installer_fedora() 207 | install_cmd = 'sudo dnf install ' + out_file 208 | remove_cmd = 'sudo dnf remove ' + app_name 209 | else: 210 | raise FbsError(linux_distribution_not_supported_msg) 211 | msg_parts.append( 212 | 'You can for instance install it via the following command:\n' 213 | ' %s\n' 214 | 'This places it in /opt/%s. To uninstall it again, you can use:\n' 215 | ' %s' 216 | % (install_cmd, app_name, remove_cmd) 217 | ) 218 | else: 219 | raise FbsError('Unsupported OS') 220 | _LOG.info(' '.join(msg_parts)) 221 | 222 | @command 223 | def sign_installer(): 224 | """ 225 | Sign installer, so the user's OS trusts it 226 | """ 227 | if is_mac(): 228 | _LOG.info('fbs does not yet implement `sign_installer` on macOS.') 229 | return 230 | if is_ubuntu(): 231 | _LOG.info('Ubuntu does not support signing installers.') 232 | return 233 | require_installer() 234 | if is_windows(): 235 | from fbs.sign_installer.windows import sign_installer_windows 236 | sign_installer_windows() 237 | elif is_arch_linux(): 238 | from fbs.sign_installer.arch import sign_installer_arch 239 | sign_installer_arch() 240 | elif is_fedora(): 241 | from fbs.sign_installer.fedora import sign_installer_fedora 242 | sign_installer_fedora() 243 | _LOG.info('Signed %s.', join('target', SETTINGS['installer'])) 244 | 245 | @command 246 | def repo(): 247 | """ 248 | Generate files for automatic updates 249 | """ 250 | require_existing_project() 251 | if not _repo_is_supported(): 252 | raise FbsError('This command is not supported on this platform.') 253 | app_name = SETTINGS['app_name'] 254 | pkg_name = app_name.lower() 255 | try: 256 | gpg_key = SETTINGS['gpg_key'] 257 | except KeyError: 258 | raise FbsError( 259 | 'GPG key for code signing is not configured. You might want to ' 260 | 'either\n' 261 | ' 1) run `fbs gengpgkey` or\n' 262 | ' 2) set "gpg_key" and "gpg_pass" in src/build/settings/.' 263 | ) 264 | if is_ubuntu(): 265 | from fbs.repo.ubuntu import create_repo_ubuntu 266 | if not SETTINGS['description']: 267 | _LOG.info( 268 | 'Hint: Your app\'s "description" is empty. Consider setting it ' 269 | 'in src/build/settings/linux.json.' 270 | ) 271 | create_repo_ubuntu() 272 | _LOG.info( 273 | 'Done. You can test the repository with the following commands:\n' 274 | ' echo "deb [arch=amd64] file://%s stable main" ' 275 | '| sudo tee /etc/apt/sources.list.d/%s.list\n' 276 | ' sudo apt-key add %s\n' 277 | ' sudo apt-get update\n' 278 | ' sudo apt-get install %s\n' 279 | 'To revert these changes:\n' 280 | ' sudo dpkg --purge %s\n' 281 | ' sudo apt-key del %s\n' 282 | ' sudo rm /etc/apt/sources.list.d/%s.list\n' 283 | ' sudo apt-get update', 284 | path('target/repo'), pkg_name, 285 | path('src/sign/linux/public-key.gpg'), pkg_name, pkg_name, gpg_key, 286 | pkg_name, 287 | extra={'wrap': False} 288 | ) 289 | elif is_arch_linux(): 290 | from fbs.repo.arch import create_repo_arch 291 | create_repo_arch() 292 | _LOG.info( 293 | "Done. You can test the repository with the following commands:\n" 294 | " sudo cp /etc/pacman.conf /etc/pacman.conf.bu\n" 295 | " echo -e '\\n[%s]\\nServer = file://%s' " 296 | "| sudo tee -a /etc/pacman.conf\n" 297 | " sudo pacman-key --add %s\n" 298 | " sudo pacman-key --lsign-key %s\n" 299 | " sudo pacman -Syu %s\n" 300 | "To revert these changes:\n" 301 | " sudo pacman -R %s\n" 302 | " sudo pacman-key --delete %s\n" 303 | " sudo mv /etc/pacman.conf.bu /etc/pacman.conf", 304 | app_name, path('target/repo'), 305 | path('src/sign/linux/public-key.gpg'), gpg_key, pkg_name, pkg_name, 306 | gpg_key, 307 | extra={'wrap': False} 308 | ) 309 | else: 310 | assert is_fedora() 311 | from fbs.repo.fedora import create_repo_fedora 312 | create_repo_fedora() 313 | _LOG.info( 314 | "Done. You can test the repository with the following commands:\n" 315 | " sudo rpm -v --import %s\n" 316 | " sudo dnf config-manager --add-repo file://%s/target/repo\n" 317 | " sudo dnf install %s\n" 318 | "To revert these changes:\n" 319 | " sudo dnf remove %s\n" 320 | " sudo rm /etc/yum.repos.d/*%s*.repo\n" 321 | " sudo rpm --erase gpg-pubkey-%s", 322 | path('src/sign/linux/public-key.gpg'), SETTINGS['project_dir'], 323 | pkg_name, pkg_name, app_name, gpg_key[-8:].lower(), 324 | extra={'wrap': False} 325 | ) 326 | 327 | def _repo_is_supported(): 328 | return is_ubuntu() or is_arch_linux() or is_fedora() 329 | 330 | @command 331 | def upload(): 332 | """ 333 | Upload installer and repository to fbs.sh 334 | """ 335 | require_existing_project() 336 | try: 337 | username = SETTINGS['fbs_user'] 338 | password = SETTINGS['fbs_pass'] 339 | except KeyError as e: 340 | raise FbsError( 341 | 'Could not find setting "%s". You may want to invoke one of the ' 342 | 'following:\n' 343 | ' * fbs register\n' 344 | ' * fbs login' 345 | % (e.args[0],) 346 | ) from None 347 | _upload_repo(username, password) 348 | app_name = SETTINGS['app_name'] 349 | url = lambda p: 'https://fbs.sh/%s/%s/%s' % (username, app_name, p) 350 | message = 'Done! ' 351 | pkg_name = app_name.lower() 352 | installer_url = url(SETTINGS['installer']) 353 | if is_linux(): 354 | message += 'Your users can now install your app via the following ' \ 355 | 'commands:\n' 356 | format_commands = lambda *cmds: '\n'.join(' ' + c for c in cmds) 357 | repo_url = url(SETTINGS['repo_subdir']) 358 | if is_ubuntu(): 359 | message += format_commands( 360 | "sudo apt-get install apt-transport-https", 361 | "wget -qO - %s | sudo apt-key add -" % url('public-key.gpg'), 362 | "echo 'deb [arch=amd64] %s stable main' | " % repo_url + 363 | "sudo tee /etc/apt/sources.list.d/%s.list" % pkg_name, 364 | "sudo apt-get update", 365 | "sudo apt-get install " + pkg_name 366 | ) 367 | message += '\nIf they already have your app installed, they can ' \ 368 | 'force an immediate update via:\n' 369 | message += format_commands( 370 | 'sudo apt-get update ' 371 | '-o Dir::Etc::sourcelist="/etc/apt/sources.list.d/%s.list" ' 372 | '-o Dir::Etc::sourceparts="-" -o APT::Get::List-Cleanup="0"' 373 | % pkg_name, 374 | 'sudo apt-get install --only-upgrade ' + pkg_name 375 | ) 376 | elif is_arch_linux(): 377 | message += format_commands( 378 | "curl -O %s && " % url('public-key.gpg') + 379 | "sudo pacman-key --add public-key.gpg && " + 380 | "sudo pacman-key --lsign-key %s && " % SETTINGS['gpg_key'] + 381 | "rm public-key.gpg", 382 | "echo -e '\\n[%s]\\nServer = %s' | sudo tee -a /etc/pacman.conf" 383 | % (app_name, repo_url), 384 | "sudo pacman -Syu " + pkg_name 385 | ) 386 | message += '\nIf they already have your app installed, they can ' \ 387 | 'force an immediate update via:\n' 388 | message += format_commands('sudo pacman -Syu --needed ' + pkg_name) 389 | elif is_fedora(): 390 | message += format_commands( 391 | "sudo rpm -v --import " + url('public-key.gpg'), 392 | "sudo dnf config-manager --add-repo %s/%s.repo" 393 | % (repo_url, app_name), 394 | "sudo dnf install " + pkg_name 395 | ) 396 | message += "\n(On CentOS, replace 'dnf' by 'yum' and " \ 397 | "'dnf config-manager' by 'yum-config-manager'.)" 398 | message += '\nIf they already have your app installed, they can ' \ 399 | 'force an immediate update via:\n' 400 | message += \ 401 | format_commands('sudo dnf upgrade %s --refresh' % pkg_name) 402 | message += '\nThis is for Fedora. For CentOS, use:\n' 403 | message += format_commands( 404 | 'sudo yum clean all && sudo yum upgrade ' + pkg_name 405 | ) 406 | else: 407 | raise FbsError('This Linux distribution is not supported.') 408 | message += '\nFinally, your users can also install without automatic ' \ 409 | 'updates by downloading:\n ' + installer_url 410 | extra = {'wrap': False} 411 | else: 412 | message += 'Your users can now download and install %s.' % installer_url 413 | extra = None 414 | _LOG.info(message, extra=extra) 415 | 416 | @command 417 | def release(version=None): 418 | """ 419 | Bump version and run clean,freeze,...,upload 420 | """ 421 | require_existing_project() 422 | if version is None: 423 | curr_version = SETTINGS['version'] 424 | next_version = _get_next_version(curr_version) 425 | release_version = prompt_for_value( 426 | 'Release version', default=next_version 427 | ) 428 | elif version == 'current': 429 | release_version = SETTINGS['version'] 430 | else: 431 | release_version = version 432 | if not is_valid_version(release_version): 433 | if not is_valid_version(version): 434 | raise FbsError( 435 | 'The release version of your app is invalid. It should be ' 436 | 'three\nnumbers separated by dots, such as "1.2.3". ' 437 | 'You have: "%s".' % release_version 438 | ) 439 | activate_profile('release') 440 | SETTINGS['version'] = release_version 441 | log_level = _LOG.level 442 | if log_level == logging.NOTSET: 443 | _LOG.setLevel(logging.WARNING) 444 | try: 445 | clean() 446 | freeze() 447 | if is_windows() and _has_windows_codesigning_certificate(): 448 | sign() 449 | installer() 450 | if (is_windows() and _has_windows_codesigning_certificate()) or \ 451 | is_arch_linux() or is_fedora(): 452 | sign_installer() 453 | if _repo_is_supported(): 454 | repo() 455 | finally: 456 | _LOG.setLevel(log_level) 457 | upload() 458 | base_json = 'src/build/settings/base.json' 459 | update_json(path(base_json), { 'version': release_version }) 460 | _LOG.info('Also, %s was updated with the new version.', base_json) 461 | 462 | @command 463 | def test(): 464 | """ 465 | Execute your automated tests 466 | """ 467 | require_existing_project() 468 | sys.path.append(path('src/main/python')) 469 | suite = TestSuite() 470 | test_dirs = SETTINGS['test_dirs'] 471 | for test_dir in map(path, test_dirs): 472 | sys.path.append(test_dir) 473 | try: 474 | dir_names = listdir(test_dir) 475 | except FileNotFoundError: 476 | continue 477 | for dir_name in dir_names: 478 | dir_path = join(test_dir, dir_name) 479 | if isfile(join(dir_path, '__init__.py')): 480 | suite.addTest(defaultTestLoader.discover( 481 | dir_name, top_level_dir=test_dir 482 | )) 483 | has_tests = bool(list(suite)) 484 | if has_tests: 485 | TextTestRunner().run(suite) 486 | else: 487 | _LOG.warning( 488 | 'No tests found. You can add them to:\n * '+ 489 | '\n * '.join(test_dirs) 490 | ) 491 | 492 | @command 493 | def clean(): 494 | """ 495 | Remove previous build outputs 496 | """ 497 | try: 498 | rmtree(path('target')) 499 | except FileNotFoundError: 500 | return 501 | except OSError: 502 | # In a docker container, target/ may be mounted so we can't delete it. 503 | # Delete its contents instead: 504 | for f in listdir(path('target')): 505 | fpath = join(path('target'), f) 506 | if isdir(fpath): 507 | rmtree(fpath, ignore_errors=True) 508 | elif isfile(fpath): 509 | remove(fpath) 510 | elif islink(fpath): 511 | unlink(fpath) 512 | 513 | def _has_windows_codesigning_certificate(): 514 | assert is_windows() 515 | from fbs.sign.windows import _CERTIFICATE_PATH 516 | return exists(path(_CERTIFICATE_PATH)) 517 | 518 | def _has_module(name): 519 | return bool(find_spec(name)) 520 | 521 | def _get_next_version(version): 522 | version_parts = version.split('.') 523 | next_patch = str(int(version_parts[-1]) + 1) 524 | return '.'.join(version_parts[:-1]) + '.' + next_patch -------------------------------------------------------------------------------- /fbs/builtin_commands/_account.py: -------------------------------------------------------------------------------- 1 | from fbs import path, _server 2 | from fbs.builtin_commands import prompt_for_value, require_existing_project 3 | from fbs.builtin_commands._util import update_json, SECRET_JSON 4 | from fbs.cmdline import command 5 | 6 | import logging 7 | 8 | _LOG = logging.getLogger(__name__) 9 | 10 | @command 11 | def register(): 12 | """ 13 | Create an account for uploading your files 14 | """ 15 | require_existing_project() 16 | username = prompt_for_value('Username') 17 | email = prompt_for_value('Real email') 18 | password = prompt_for_value('Password', password=True) 19 | print('') 20 | status, text = _server.post_json('register', { 21 | 'username': username, 'email': email, 'password': password 22 | }) 23 | if status == 201: 24 | if text: 25 | _LOG.info(text) 26 | _login(username, password) 27 | else: 28 | _LOG.error('Could not register:\n' + text) 29 | 30 | @command 31 | def login(): 32 | """ 33 | Save your account details to secret.json 34 | """ 35 | require_existing_project() 36 | username = prompt_for_value('Username') 37 | password = prompt_for_value('Password', password=True) 38 | print('') 39 | _login(username, password) 40 | 41 | def _login(username, password): 42 | update_json(path(SECRET_JSON), {'fbs_user': username, 'fbs_pass': password}) 43 | _LOG.info('Saved your username and password to %s.', SECRET_JSON) -------------------------------------------------------------------------------- /fbs/builtin_commands/_docker.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs.builtin_commands import require_existing_project 3 | from fbs.cmdline import command 4 | from fbs.resources import _copy 5 | from fbs_runtime import FbsError 6 | from fbs_runtime._source import default_path 7 | from os import listdir 8 | from os.path import exists 9 | from shutil import rmtree 10 | from subprocess import run, CalledProcessError, PIPE 11 | 12 | import logging 13 | 14 | __all__ = ['buildvm', 'runvm'] 15 | 16 | _LOG = logging.getLogger(__name__) 17 | 18 | @command 19 | def buildvm(name): 20 | """ 21 | Build a Linux VM. Eg.: buildvm ubuntu 22 | """ 23 | require_existing_project() 24 | build_dir = path('target/%s-docker-image' % name) 25 | if exists(build_dir): 26 | rmtree(build_dir) 27 | src_root = 'src/build/docker' 28 | available_vms = set(listdir(default_path(src_root))) 29 | if exists(path(src_root)): 30 | available_vms.update(listdir(path(src_root))) 31 | if name not in available_vms: 32 | raise FbsError( 33 | 'Could not find %s. Available VMs are:%s' % 34 | (name, ''.join(['\n * ' + vm for vm in available_vms])) 35 | ) 36 | src_dir = src_root + '/' + name 37 | for path_fn in default_path, path: 38 | _copy(path_fn, src_dir, build_dir) 39 | settings = SETTINGS['docker_images'].get(name, {}) 40 | for path_fn in default_path, path: 41 | for p in settings.get('build_files', []): 42 | _copy(path_fn, p, build_dir) 43 | args = ['build', '--pull', '-t', _get_docker_id(name), build_dir] 44 | for arg, value in settings.get('build_args', {}).items(): 45 | args.extend(['--build-arg', '%s=%s' % (arg, value)]) 46 | try: 47 | _run_docker( 48 | args, check=True, stdout=PIPE, stderr=PIPE, 49 | universal_newlines=True 50 | ) 51 | except CalledProcessError as e: 52 | if '/private-key.gpg: no such file or directory' in e.stderr: 53 | message = 'Could not find private-key.gpg. Maybe you want to ' \ 54 | 'run:\n fbs gengpgkey' 55 | else: 56 | message = e.stdout + '\n' + e.stderr 57 | raise FbsError(message) 58 | _LOG.info('Done. You can now execute:\n fbs runvm ' + name) 59 | 60 | @command 61 | def runvm(name): 62 | """ 63 | Run a Linux VM. Eg.: runvm ubuntu 64 | """ 65 | args = ['run', '-it'] 66 | for item in _get_docker_mounts(name).items(): 67 | args.extend(['-v', '%s:%s' % item]) 68 | docker_id = _get_docker_id(name) 69 | args.append(docker_id) 70 | try: 71 | _run_docker(args, stderr=PIPE, universal_newlines=True, check=True) 72 | except CalledProcessError as e: 73 | if 'Unable to find image' in e.stderr: 74 | raise FbsError( 75 | 'Docker could not find image %s. You may want to run:\n' 76 | ' fbs buildvm %s' % (docker_id, name) 77 | ) 78 | 79 | def _run_docker(args, **kwargs): 80 | try: 81 | return run(['docker'] + args, **kwargs) 82 | except FileNotFoundError: 83 | raise FbsError( 84 | 'fbs could not find Docker. Is it installed and on your PATH?' 85 | ) 86 | 87 | def _get_docker_id(name): 88 | prefix = SETTINGS['app_name'].replace(' ', '_').lower() 89 | suffix = name.lower() 90 | return prefix + '/' + suffix 91 | 92 | def _get_docker_mounts(name): 93 | result = {'target/' + name.lower(): 'target'} 94 | # These directories are created inside the container by `buildvm`: 95 | ignore = {'target', 'venv'} 96 | for file_name in listdir(path('.')): 97 | if file_name in ignore: 98 | continue 99 | result[file_name] = file_name 100 | path_in_docker = lambda p: '/root/%s/%s' % (SETTINGS['app_name'], p) 101 | return {path(src): path_in_docker(dest) for src, dest in result.items()} 102 | 103 | def _get_settings(name): 104 | return SETTINGS['docker_images'][name] -------------------------------------------------------------------------------- /fbs/builtin_commands/_gpg/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:12.7 2 | 3 | ARG name 4 | ARG email 5 | ARG passphrase 6 | ARG keylength=1024 7 | 8 | RUN apt-get update 9 | 10 | ADD gpg-agent.conf /root/.gnupg/gpg-agent.conf 11 | RUN chmod -R 600 /root/.gnupg/ 12 | RUN apt-get install gnupg2 pinentry-tty -y 13 | 14 | WORKDIR /root 15 | 16 | ADD genkey.sh /root/genkey.sh 17 | RUN chmod +x genkey.sh -------------------------------------------------------------------------------- /fbs/builtin_commands/_gpg/__init__.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs.builtin_commands._docker import _run_docker 3 | from fbs.builtin_commands._util import prompt_for_value, update_json, \ 4 | require_existing_project, BASE_JSON, SECRET_JSON 5 | from fbs.cmdline import command 6 | from fbs_runtime import FbsError 7 | from os import makedirs 8 | from os.path import dirname, exists 9 | from pathlib import Path 10 | from subprocess import DEVNULL, PIPE 11 | 12 | import logging 13 | 14 | _LOG = logging.getLogger(__name__) 15 | _DOCKER_IMAGE = 'fbs/gpg-generator' 16 | _DEST_DIR = 'src/sign/linux' 17 | _PUBKEY_NAME = 'public-key.gpg' 18 | _PRIVKEY_NAME = 'private-key.gpg' 19 | 20 | @command 21 | def gengpgkey(): 22 | """ 23 | Generate a GPG key for Linux code signing 24 | """ 25 | require_existing_project() 26 | if exists(_DEST_DIR): 27 | raise FbsError('The %s folder already exists. Aborting.' % _DEST_DIR) 28 | try: 29 | email = prompt_for_value('Email address') 30 | name = prompt_for_value('Real name', default=SETTINGS['author']) 31 | passphrase = prompt_for_value('Key password', password=True) 32 | except KeyboardInterrupt: 33 | print('') 34 | return 35 | print('') 36 | _LOG.info('Generating the GPG key. This can take a little...') 37 | _init_docker() 38 | args = ['run', '-t'] 39 | if exists('/dev/urandom'): 40 | # Give the key generator more entropy on Posix: 41 | args.extend(['-v', '/dev/urandom:/dev/random']) 42 | args.extend([_DOCKER_IMAGE, '/root/genkey.sh', name, email, passphrase]) 43 | result = _run_docker(args, check=True, stdout=PIPE, universal_newlines=True) 44 | key = _snip( 45 | result.stdout, 46 | "revocation certificate stored as '/root/.gnupg/openpgp-revocs.d/", 47 | ".rev'", 48 | include_bounds=False 49 | ) 50 | pubkey = _snip(result.stdout, 51 | '-----BEGIN PGP PUBLIC KEY BLOCK-----\n', 52 | '-----END PGP PUBLIC KEY BLOCK-----\n') 53 | privkey = _snip(result.stdout, 54 | '-----BEGIN PGP PRIVATE KEY BLOCK-----\n', 55 | '-----END PGP PRIVATE KEY BLOCK-----\n') 56 | makedirs(path(_DEST_DIR), exist_ok=True) 57 | pubkey_dest = _DEST_DIR + '/' + _PUBKEY_NAME 58 | Path(path(pubkey_dest)).write_text(pubkey) 59 | Path(path(_DEST_DIR + '/' + _PRIVKEY_NAME)).write_text(privkey) 60 | update_json(path(BASE_JSON), {'gpg_key': key, 'gpg_name': name}) 61 | update_json(path(SECRET_JSON), {'gpg_pass': passphrase}) 62 | _LOG.info( 63 | 'Done. Created %s and ...%s. Also updated %s and ...secret.json with ' 64 | 'the values you provided.', pubkey_dest, _PRIVKEY_NAME, BASE_JSON 65 | ) 66 | 67 | def _init_docker(): 68 | _run_docker( 69 | ['build', '-t', _DOCKER_IMAGE, dirname(__file__)], stdout=DEVNULL 70 | ) 71 | 72 | def _snip(str_, preamble, postamble, include_bounds=True): 73 | start = str_.index(preamble) 74 | end = str_.index(postamble, start + len(preamble)) 75 | if not include_bounds: 76 | start += len(preamble) 77 | if include_bounds: 78 | end += len(postamble) 79 | return str_[start:end] -------------------------------------------------------------------------------- /fbs/builtin_commands/_gpg/genkey.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | tmpfile=$(mktemp) 6 | 7 | cat >"$tmpfile" <, ] 11 | 12 | Without this hook, it prints a lot of output of the form 13 | 14 | parser.py:191: RuntimeWarning: pyside_type_init: 15 | UNRECOGNIZED: 'PySide2.QtGui....' 16 | 17 | Then, it prints Signature('QStringList') instead of the above ...(List). 18 | """ 19 | 20 | from glob import glob 21 | from os.path import dirname, relpath, join 22 | 23 | import PySide2.support 24 | 25 | """ 26 | This should give roughly the same results as: 27 | 28 | from PyInstaller.utils.hooks import collect_data_files 29 | datas = collect_data_files( 30 | 'PySide2', include_py_files=True, subdir='support' 31 | ) 32 | 33 | The reason we don't do it this way is that it would add a dynamic link to 34 | PyInstaller, and thus force the GPL on fbs, preventing it from being licensed 35 | under different terms (such as a commercial license). 36 | """ 37 | _base_dir = dirname(PySide2.support.__file__) 38 | _python_files = glob(join(_base_dir, '**', '*.py'), recursive=True) 39 | _site_packages = dirname(dirname(_base_dir)) 40 | datas = [(f, relpath(dirname(f), _site_packages)) for f in _python_files] -------------------------------------------------------------------------------- /fbs/freeze/hooks/hook-shiboken2.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | from os.path import dirname, relpath, join 3 | 4 | import PySide2 5 | 6 | support = None 7 | 8 | if PySide2.__version__ == "5.12.2": 9 | import PySide2.support as support 10 | else: 11 | try: 12 | # PySide2 < 5.12.2 13 | import shiboken2.support as support 14 | except ImportError: 15 | # PySide2 >= 5.12.3 16 | pass 17 | 18 | if support is not None: 19 | """ 20 | This should give roughly the same results as: 21 | 22 | from PyInstaller.utils.hooks import collect_data_files 23 | datas = collect_data_files( 24 | 'shiboken2', include_py_files=True, subdir='support' 25 | ) 26 | 27 | The reason we don't do it this way is that it would add a dynamic link to 28 | PyInstaller, and thus force the GPL on fbs, preventing it from being 29 | licensed under different terms (such as a commercial license). 30 | """ 31 | _base_dir = dirname(support.__file__) 32 | _python_files = glob(join(_base_dir, '**', '*.py'), recursive=True) 33 | _site_packages = dirname(dirname(_base_dir)) 34 | datas = [(f, relpath(dirname(f), _site_packages)) for f in _python_files] 35 | 36 | hiddenimports = ['typing'] -------------------------------------------------------------------------------- /fbs/freeze/linux.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs.freeze import _generate_resources, run_pyinstaller 3 | from glob import glob 4 | from os import remove 5 | from shutil import copy 6 | 7 | def freeze_linux(debug=False): 8 | run_pyinstaller(debug=debug) 9 | _generate_resources() 10 | copy(path('src/main/icons/Icon.ico'), path('${freeze_dir}')) 11 | # For some reason, PyInstaller packages libstdc++.so.6 even though it is 12 | # available on most Linux distributions. If we include it and run our app on 13 | # a different Ubuntu version, then Popen(...) calls fail with errors 14 | # "GLIBCXX_... not found" or "CXXABI_..." not found. So ensure we don't 15 | # package the file, so that the respective system's compatible version is 16 | # used: 17 | remove_shared_libraries( 18 | 'libstdc++.so.*', 'libtinfo.so.*', 'libreadline.so.*', 'libdrm.so.*' 19 | ) 20 | 21 | def remove_shared_libraries(*filename_patterns): 22 | for pattern in filename_patterns: 23 | for file_path in glob(path('${freeze_dir}/' + pattern)): 24 | remove(file_path) -------------------------------------------------------------------------------- /fbs/freeze/mac.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs.freeze import _generate_resources, run_pyinstaller 3 | from fbs.resources import get_icons 4 | from os import makedirs, unlink, rename, symlink 5 | from os.path import exists 6 | from shutil import copy, rmtree 7 | from subprocess import run 8 | 9 | def freeze_mac(debug=False): 10 | if not exists(path('target/Icon.icns')): 11 | _generate_iconset() 12 | run(['iconutil', '-c', 'icns', path('target/Icon.iconset')], check=True) 13 | args = [] 14 | if not (debug or SETTINGS['show_console_window']): 15 | args.append('--windowed') 16 | args.extend(['--icon', path('target/Icon.icns')]) 17 | bundle_identifier = SETTINGS['mac_bundle_identifier'] 18 | if bundle_identifier: 19 | args.extend([ 20 | '--osx-bundle-identifier', bundle_identifier 21 | ]) 22 | run_pyinstaller(args, debug) 23 | _remove_unwanted_pyinstaller_files() 24 | _fix_sparkle_delta_updates() 25 | _generate_resources() 26 | 27 | def _generate_iconset(): 28 | makedirs(path('target/Icon.iconset'), exist_ok=True) 29 | for size, scale, icon_path in get_icons(): 30 | dest_name = 'icon_%dx%d' % (size, size) 31 | if scale != 1: 32 | dest_name += '@%dx' % scale 33 | dest_name += '.png' 34 | copy(icon_path, path('target/Icon.iconset/' + dest_name)) 35 | 36 | def _remove_unwanted_pyinstaller_files(): 37 | for unwanted in ('include', 'lib', 'lib2to3'): 38 | try: 39 | unlink(path('${freeze_dir}/Contents/MacOS/' + unwanted)) 40 | except FileNotFoundError: 41 | pass 42 | try: 43 | rmtree(path('${freeze_dir}/Contents/Resources/' + unwanted)) 44 | except FileNotFoundError: 45 | pass 46 | 47 | def _fix_sparkle_delta_updates(): 48 | # Sparkle's Delta Updates mechanism does not support signed non-Mach-O files 49 | # in Contents/MacOS. base_library.zip, which is created by PyInstaller, 50 | # violates this. We therefore move base_library.zip to Contents/Resources. 51 | # Fortunately, everything still works if we then create a symlink 52 | # MacOS/base_library.zip -> ../Resources/base_library.zip. 53 | rename( 54 | path('${freeze_dir}/Contents/MacOS/base_library.zip'), 55 | path('${freeze_dir}/Contents/Resources/base_library.zip') 56 | ) 57 | symlink( 58 | '../Resources/base_library.zip', 59 | path('${freeze_dir}/Contents/MacOS/base_library.zip') 60 | ) -------------------------------------------------------------------------------- /fbs/freeze/ubuntu.py: -------------------------------------------------------------------------------- 1 | from fbs.freeze.linux import freeze_linux, remove_shared_libraries 2 | 3 | def freeze_ubuntu(debug=False): 4 | freeze_linux(debug) 5 | # When we build on Ubuntu on 14.04 and run on 17.10, the app fails to start 6 | # with the following error: 7 | # 8 | # > This application failed to start because it could not find or load the 9 | # > Qt platform plugin "xcb" in "". Available platform plugins are: 10 | # > eglfs, linuxfb, minimal, minimalegl, offscreen, vnc, xcb. 11 | # 12 | # Interestingly, the error does not occur when building on Ubuntu 16.04. 13 | # The difference between the two build outputs seems to be 14 | # libgpg-error.so.0. Removing it fixes the problem: 15 | remove_shared_libraries('libgpg-error.so.*') 16 | # libgtk-3.so is present on every Ubuntu system. Make sure we don't ship it 17 | # to avoid incompatibilities. In particular, running the frozen app with 18 | # libgtk-3.so from Ubuntu 14 on Ubuntu 16 produces many Gtk warnings 19 | # "Theme parsing error". 20 | remove_shared_libraries('libgtk-3.so.*') 21 | # We also don't want to ship libgio-2.0.so because it is usually present. 22 | # What's more, if we ship libgio without libgtk, then segmentation faults 23 | # occur when freezing on Ubuntu 14 and running on Ubuntu 16. The reason for 24 | # this is that libgio depends on libgtk. Because we don't ship libgtk, this 25 | # loads the user's libgtk, which is incompatible between Ubuntu 14 and 16. 26 | remove_shared_libraries('libgio-2.0.so.*') -------------------------------------------------------------------------------- /fbs/freeze/windows.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs.freeze import run_pyinstaller, _generate_resources 3 | from fbs.resources import _copy 4 | from fbs_runtime._source import default_path 5 | from os import remove 6 | from os.path import join, exists 7 | from shutil import copy 8 | 9 | import os 10 | import struct 11 | import sys 12 | 13 | def freeze_windows(debug=False): 14 | args = [] 15 | if not (debug or SETTINGS['show_console_window']): 16 | # The --windowed flag below prevents us from seeing any console output. 17 | # We therefore only add it when we're not debugging. 18 | args.append('--windowed') 19 | args.extend(['--icon', path('src/main/icons/Icon.ico')]) 20 | for path_fn in default_path, path: 21 | _copy(path_fn, 'src/freeze/windows/version_info.py', path('target/PyInstaller')) 22 | args.extend(['--version-file', path('target/PyInstaller/version_info.py')]) 23 | run_pyinstaller(args, debug) 24 | _restore_corrupted_python_dlls() 25 | _generate_resources() 26 | copy(path('src/main/icons/Icon.ico'), path('${freeze_dir}')) 27 | _add_missing_dlls() 28 | 29 | def _restore_corrupted_python_dlls(): 30 | # PyInstaller <= 3.4 somehow corrupts python3*.dll - see: 31 | # https://github.com/pyinstaller/pyinstaller/issues/2526 32 | # Restore the uncorrupted original: 33 | python_dlls = ( 34 | 'python%s.dll' % sys.version_info.major, 35 | 'python%s%s.dll' % (sys.version_info.major, sys.version_info.minor) 36 | ) 37 | for dll_name in python_dlls: 38 | try: 39 | remove(path('${freeze_dir}/' + dll_name)) 40 | except FileNotFoundError: 41 | pass 42 | else: 43 | copy(_find_on_path(dll_name), path('${freeze_dir}')) 44 | 45 | def _add_missing_dlls(): 46 | for dll_name in ( 47 | 'msvcr100.dll', 'msvcr110.dll', 'msvcp110.dll', 'vcruntime140.dll', 48 | 'msvcp140.dll', 'concrt140.dll', 'vccorlib140.dll' 49 | ): 50 | try: 51 | _add_missing_dll(dll_name) 52 | except LookupError: 53 | raise FileNotFoundError( 54 | "Could not find %s on your PATH. Please install the Visual C++ " 55 | "Redistributable for Visual Studio 2012 from:\n " 56 | "https://www.microsoft.com/en-us/download/details.aspx?id=30679" 57 | % dll_name 58 | ) from None 59 | for ucrt_dll in ('api-ms-win-crt-multibyte-l1-1-0.dll',): 60 | try: 61 | _add_missing_dll(ucrt_dll) 62 | except LookupError: 63 | bitness_32_or_64 = struct.calcsize("P") * 8 64 | raise FileNotFoundError( 65 | "Could not find %s on your PATH. If you are on Windows 10, you " 66 | "may have to install the Windows 10 SDK from " 67 | "https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk. " 68 | "Otherwise, try installing KB2999226 from " 69 | "https://support.microsoft.com/en-us/kb/2999226. " 70 | "In both cases, add the directory containing %s to your PATH " 71 | "environment variable afterwards. If there are 32 and 64 bit " 72 | "versions of the DLL, use the %s bit one (because that's the " 73 | "bitness of your current Python interpreter)." % ( 74 | ucrt_dll, ucrt_dll, bitness_32_or_64 75 | ) 76 | ) from None 77 | 78 | def _add_missing_dll(dll_name): 79 | freeze_dir = path('${freeze_dir}') 80 | if not exists(join(freeze_dir, dll_name)): 81 | copy(_find_on_path(dll_name), freeze_dir) 82 | 83 | def _find_on_path(file_name): 84 | path = os.environ.get("PATH", os.defpath) 85 | path_items = path.split(os.pathsep) 86 | if sys.platform == "win32": 87 | if not os.curdir in path_items: 88 | path_items.insert(0, os.curdir) 89 | seen = set() 90 | for dir_ in path_items: 91 | normdir = os.path.normcase(dir_) 92 | if not normdir in seen: 93 | seen.add(normdir) 94 | file_path = join(dir_, file_name) 95 | if exists(file_path): 96 | return file_path 97 | raise LookupError("Could not find %s on PATH" % file_name) 98 | -------------------------------------------------------------------------------- /fbs/installer/__init__.py: -------------------------------------------------------------------------------- 1 | from fbs import path, LOADED_PROFILES 2 | from fbs.resources import _copy 3 | from fbs_runtime._source import default_path 4 | 5 | def _generate_installer_resources(): 6 | for path_fn in default_path, path: 7 | for profile in LOADED_PROFILES: 8 | _copy(path_fn, 'src/installer/' + profile, path('target/installer')) -------------------------------------------------------------------------------- /fbs/installer/arch.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs.installer.linux import generate_installer_files, run_fpm 3 | from subprocess import run 4 | 5 | def create_installer_arch(): 6 | generate_installer_files() 7 | # Avoid pacman warning "directory permissions differ" when installing: 8 | run(['chmod', 'g-w', '-R', path('target/installer')], check=True) 9 | run_fpm('pacman') -------------------------------------------------------------------------------- /fbs/installer/fedora.py: -------------------------------------------------------------------------------- 1 | from fbs.installer.linux import generate_installer_files, run_fpm 2 | 3 | def create_installer_fedora(): 4 | generate_installer_files() 5 | run_fpm('rpm') -------------------------------------------------------------------------------- /fbs/installer/linux.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs.installer import _generate_installer_resources 3 | from fbs.resources import get_icons 4 | from fbs_runtime.platform import is_arch_linux 5 | from os import makedirs, remove, rename 6 | from os.path import join, dirname, exists 7 | from shutil import copy, rmtree, copytree 8 | from subprocess import run, DEVNULL 9 | 10 | def generate_installer_files(): 11 | if exists(path('target/installer')): 12 | rmtree(path('target/installer')) 13 | copytree(path('${freeze_dir}'), path('target/installer/opt/${app_name}')) 14 | _generate_installer_resources() 15 | # Special handling of the .desktop file: Replace AppName by actual name. 16 | apps_dir = path('target/installer/usr/share/applications') 17 | rename( 18 | join(apps_dir, 'AppName.desktop'), 19 | join(apps_dir, SETTINGS['app_name'] + '.desktop') 20 | ) 21 | _generate_icons() 22 | 23 | def run_fpm(output_type): 24 | dest = path('target/${installer}') 25 | if exists(dest): 26 | remove(dest) 27 | # Lower-case the name to avoid the following fpm warning: 28 | # > Debian tools (dpkg/apt) don't do well with packages that use capital 29 | # > letters in the name. In some cases it will automatically downcase 30 | # > them, in others it will not. It is confusing. Best to not use any 31 | # > capital letters at all. 32 | name = SETTINGS['app_name'].lower() 33 | args = [ 34 | 'fpm', '-s', 'dir', 35 | # We set the log level to error because fpm prints the following warning 36 | # even if we don't have anything in /etc: 37 | # > Debian packaging tools generally labels all files in /etc as config 38 | # > files, as mandated by policy, so fpm defaults to this behavior for 39 | # > deb packages. You can disable this default behavior with 40 | # > --deb-no-default-config-files flag 41 | '--log', 'error', 42 | '-C', path('target/installer'), 43 | '-n', name, 44 | '-v', SETTINGS['version'], 45 | '--vendor', SETTINGS['author'], 46 | '-t', output_type, 47 | '-p', dest 48 | ] 49 | if SETTINGS['description']: 50 | args.extend(['--description', SETTINGS['description']]) 51 | if SETTINGS['author_email']: 52 | args.extend([ 53 | '-m', '%s <%s>' % (SETTINGS['author'], SETTINGS['author_email']) 54 | ]) 55 | if SETTINGS['url']: 56 | args.extend(['--url', SETTINGS['url']]) 57 | for dependency in SETTINGS['depends']: 58 | args.extend(['-d', dependency]) 59 | if is_arch_linux(): 60 | for opt_dependency in SETTINGS['depends_opt']: 61 | args.extend(['--pacman-optional-depends', opt_dependency]) 62 | try: 63 | run(args, check=True, stdout=DEVNULL) 64 | except FileNotFoundError: 65 | raise FileNotFoundError( 66 | "fbs could not find executable 'fpm'. Please install fpm using the " 67 | "instructions at " 68 | "https://fpm.readthedocs.io/en/latest/installation.html." 69 | ) from None 70 | 71 | def _generate_icons(): 72 | dest_root = path('target/installer/usr/share/icons/hicolor') 73 | makedirs(dest_root) 74 | icons_fname = '%s.png' % SETTINGS['app_name'] 75 | for size, _, icon_path in get_icons(): 76 | icon_dest = join(dest_root, '%dx%d' % (size, size), 'apps', icons_fname) 77 | makedirs(dirname(icon_dest)) 78 | copy(icon_path, icon_dest) 79 | -------------------------------------------------------------------------------- /fbs/installer/mac/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from fbs import path, SETTINGS 3 | from fbs_runtime.platform import is_mac 4 | from os import replace, remove 5 | from os.path import join, dirname, exists 6 | from subprocess import check_call, DEVNULL 7 | 8 | 9 | def create_installer_mac(): 10 | app_name = SETTINGS['app_name'] 11 | dest = path('target/${installer}') 12 | dest_existed = exists(dest) 13 | if dest_existed: 14 | dest_bu = dest + '.bu' 15 | replace(dest, dest_bu) 16 | try: 17 | pdata = [ 18 | join(dirname(__file__), 'create-dmg', 'create-dmg'), 19 | '--volname', app_name, 20 | '--app-drop-link', '170', '10', 21 | '--icon', app_name + '.app', '0', '10', 22 | dest, 23 | path('${freeze_dir}') 24 | ] 25 | 26 | if is_mac(): 27 | major, minor = platform.mac_ver()[0].split('.')[:2] 28 | if (int(major) == 10 and int(minor) >= 15) or int(major) >= 11: 29 | pdata.insert(1, '--no-internet-enable') 30 | 31 | check_call(pdata, stdout=DEVNULL) 32 | except: 33 | if dest_existed: 34 | replace(dest_bu, dest) 35 | raise 36 | else: 37 | if dest_existed: 38 | remove(dest_bu) 39 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2008-2014 Andrey Tarantsov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/README.md: -------------------------------------------------------------------------------- 1 | create-dmg 2 | ========== 3 | 4 | A shell script to build fancy DMGs. 5 | 6 | 7 | Status and contribution policy 8 | ------------------------------ 9 | 10 | This project is maintained thanks to the contributors who send pull requests, and now (Sep 2018) with the help of [@aonez](https://github.com/aonez). 11 | 12 | We will merge any pull request that adds something useful and does not break existing things, and will often grant commit access to the repository. 13 | 14 | If you're an active user and want to be a maintainer, or just want to chat, please ping us at [gitter.im/create-dmg/Lobby](https://gitter.im/create-dmg/Lobby). 15 | 16 | 17 | Installation 18 | ------------ 19 | 20 | - You can install this script using [Homebrew](https://brew.sh): 21 | 22 | ```sh 23 | brew install create-dmg 24 | ``` 25 | 26 | - You can download the [latest release](https://github.com/andreyvit/create-dmg/releases/latest) 27 | 28 | - You can also clone the entire repository: 29 | 30 | ```sh 31 | git clone https://github.com/andreyvit/create-dmg.git 32 | ``` 33 | 34 | Usage 35 | ----- 36 | 37 | ```sh 38 | create-dmg [options...] [output\_name.dmg] [source\_folder] 39 | ``` 40 | 41 | All contents of source\_folder will be copied into the disk image. 42 | 43 | **Options:** 44 | 45 | * **--volname [name]:** set volume name (displayed in the Finder sidebar and window title) 46 | * **--volicon [icon.icns]:** set volume icon 47 | * **--background [pic.png]:** set folder background image (provide png, gif, jpg) 48 | * **--window-pos [x y]:** set position the folder window 49 | * **--window-size [width height]:** set size of the folder window 50 | * **--text-size [text size]:** set window text size (10-16) 51 | * **--icon-size [icon size]:** set window icons size (up to 128) 52 | * **--icon [file name] [x y]:** set position of the file's icon 53 | * **--hide-extension [file name]:** hide the extension of file 54 | * **--custom-icon [file name]/[custom icon]/[sample file] [x y]:** set position and custom icon 55 | * **--app-drop-link [x y]:** make a drop link to Applications, at location x, y 56 | * **--ql-drop-link [x y]:** make a drop link to /Library/QuickLook, at location x, y 57 | * **--eula [eula file]:** attach a license file to the dmg 58 | * **--no-internet-enable:** disable automatic mount© 59 | * **--format:** specify the final image format (default is UDZO) 60 | * **--add-file [target name] [path to source file] [x y]:** add additional file (option can be used multiple times) 61 | * **--add-folder [target name] [path to source folder] [x y]:** add additional folder (option can be used multiple times) 62 | * **--disk-image-size [x]:** set the disk image size manually to x MB 63 | * **--hdiutil-verbose:** execute hdiutil in verbose mode 64 | * **--hdiutil-quiet:** execute hdiutil in quiet mode 65 | * **--sandbox-safe:** execute hdiutil with sandbox compatibility and don not bless 66 | * **--version:** show tool version number 67 | * **-h, --help:** display the help 68 | 69 | 70 | Example 71 | ------- 72 | 73 | ```sh 74 | #!/bin/sh 75 | test -f Application-Installer.dmg && rm Application-Installer.dmg 76 | create-dmg \ 77 | --volname "Application Installer" \ 78 | --volicon "application\_icon.icns" \ 79 | --background "installer\_background.png" \ 80 | --window-pos 200 120 \ 81 | --window-size 800 400 \ 82 | --icon-size 100 \ 83 | --icon "Application.app" 200 190 \ 84 | --hide-extension "Application.app" \ 85 | --app-drop-link 600 185 \ 86 | "Application-Installer.dmg" \ 87 | "source_folder/" 88 | ``` 89 | 90 | Alternatives 91 | ------------ 92 | 93 | * [node-appdmg](https://github.com/LinusU/node-appdmg) 94 | * [dmgbuild](https://pypi.python.org/pypi/dmgbuild) 95 | * see the [StackOverflow question](http://stackoverflow.com/questions/96882/how-do-i-create-a-nice-looking-dmg-for-mac-os-x-using-command-line-tools) 96 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/builder/create-dmg.builder: -------------------------------------------------------------------------------- 1 | SET app_name create-dmg 2 | 3 | VERSION create-dmg.cur create-dmg heads/master 4 | 5 | NEWDIR build.dir temp %-build - 6 | 7 | NEWFILE create-dmg.zip featured %.zip % 8 | 9 | 10 | COPYTO [build.dir] 11 | INTO create-dmg [create-dmg.cur]/create-dmg 12 | INTO sample [create-dmg.cur]/sample 13 | INTO support [create-dmg.cur]/support 14 | 15 | SUBSTVARS [build.dir]/create-dmg [[]] 16 | 17 | 18 | ZIP [create-dmg.zip] 19 | INTO [build-files-prefix] [build.dir] 20 | 21 | 22 | PUT megabox-builds create-dmg.zip 23 | PUT megabox-builds build.log 24 | 25 | PUT s3-builds create-dmg.zip 26 | PUT s3-builds build.log 27 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/create-dmg: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Create a read-only disk image of the contents of a folder 4 | 5 | set -e; 6 | 7 | function pure_version() { 8 | echo '1.0.0.5' 9 | } 10 | 11 | function version() { 12 | echo "create-dmg $(pure_version)" 13 | } 14 | 15 | function usage() { 16 | version 17 | echo "Creates a fancy DMG file." 18 | echo "Usage: $(basename $0) [options] " 19 | echo "All contents of source_folder will be copied into the disk image." 20 | echo "Options:" 21 | echo " --volname name" 22 | echo " set volume name (displayed in the Finder sidebar and window title)" 23 | echo " --volicon icon.icns" 24 | echo " set volume icon" 25 | echo " --background pic.png" 26 | echo " set folder background image (provide png, gif, jpg)" 27 | echo " --window-pos x y" 28 | echo " set position the folder window" 29 | echo " --window-size width height" 30 | echo " set size of the folder window" 31 | echo " --text-size text_size" 32 | echo " set window text size (10-16)" 33 | echo " --icon-size icon_size" 34 | echo " set window icons size (up to 128)" 35 | echo " --icon file_name x y" 36 | echo " set position of the file's icon" 37 | echo " --hide-extension file_name" 38 | echo " hide the extension of file" 39 | echo " --custom-icon file_name custom_icon_or_sample_file x y" 40 | echo " set position and custom icon" 41 | echo " --app-drop-link x y" 42 | echo " make a drop link to Applications, at location x,y" 43 | echo " --ql-drop-link x y" 44 | echo " make a drop link to user QuickLook install dir, at location x,y" 45 | echo " --eula eula_file" 46 | echo " attach a license file to the dmg" 47 | echo " --no-internet-enable" 48 | echo " disable automatic mount©" 49 | echo " --format" 50 | echo " specify the final image format (default is UDZO)" 51 | echo " --add-file target_name path_to_source_file x y" 52 | echo " add additional file (option can be used multiple times)" 53 | echo " --add-folder target_name path_to_source_folder x y" 54 | echo " add additional folder (option can be used multiple times)" 55 | echo " --disk-image-size x" 56 | echo " set the disk image size manually to x MB" 57 | echo " --hdiutil-verbose" 58 | echo " execute hdiutil in verbose mode" 59 | echo " --hdiutil-quiet" 60 | echo " execute hdiutil in quiet mode" 61 | echo " --sandbox-safe" 62 | echo " execute hdiutil with sandbox compatibility and do not bless" 63 | echo " --version show tool version number" 64 | echo " -h, --help display this help" 65 | exit 0 66 | } 67 | 68 | WINX=10 69 | WINY=60 70 | WINW=500 71 | WINH=350 72 | ICON_SIZE=128 73 | TEXT_SIZE=16 74 | FORMAT="UDZO" 75 | ADD_FILE_SOURCES=() 76 | ADD_FILE_TARGETS=() 77 | ADD_FOLDER_SOURCES=() 78 | ADD_FOLDER_TARGETS=() 79 | IMAGEKEY="" 80 | HDIUTIL_VERBOSITY="" 81 | SANDBOX_SAFE=0 82 | 83 | while test "${1:0:1}" = "-"; do 84 | case $1 in 85 | --volname) 86 | VOLUME_NAME="$2" 87 | shift; shift;; 88 | --volicon) 89 | VOLUME_ICON_FILE="$2" 90 | shift; shift;; 91 | --background) 92 | BACKGROUND_FILE="$2" 93 | BACKGROUND_FILE_NAME="$(basename $BACKGROUND_FILE)" 94 | BACKGROUND_CLAUSE="set background picture of opts to file \".background:$BACKGROUND_FILE_NAME\"" 95 | REPOSITION_HIDDEN_FILES_CLAUSE="set position of every item to {theBottomRightX + 100, 100}" 96 | shift; shift;; 97 | --icon-size) 98 | ICON_SIZE="$2" 99 | shift; shift;; 100 | --text-size) 101 | TEXT_SIZE="$2" 102 | shift; shift;; 103 | --window-pos) 104 | WINX=$2; WINY=$3 105 | shift; shift; shift;; 106 | --window-size) 107 | WINW=$2; WINH=$3 108 | shift; shift; shift;; 109 | --icon) 110 | POSITION_CLAUSE="${POSITION_CLAUSE}set position of item \"$2\" to {$3, $4} 111 | " 112 | shift; shift; shift; shift;; 113 | --hide-extension) 114 | HIDING_CLAUSE="${HIDING_CLAUSE}set the extension hidden of item \"$2\" to true 115 | " 116 | shift; shift;; 117 | --custom-icon) 118 | shift; shift; shift; shift; shift;; 119 | -h | --help) 120 | usage;; 121 | --version) 122 | version; exit 0;; 123 | --pure-version) 124 | pure_version; exit 0;; 125 | --ql-drop-link) 126 | QL_LINK=$2 127 | QL_CLAUSE="set position of item \"QuickLook\" to {$2, $3} 128 | " 129 | shift; shift; shift;; 130 | --app-drop-link) 131 | APPLICATION_LINK=$2 132 | APPLICATION_CLAUSE="set position of item \"Applications\" to {$2, $3} 133 | " 134 | shift; shift; shift;; 135 | --eula) 136 | EULA_RSRC=$2 137 | shift; shift;; 138 | --no-internet-enable) 139 | NOINTERNET=1 140 | shift;; 141 | --format) 142 | FORMAT="$2" 143 | shift; shift;; 144 | --add-file) 145 | ADD_FILE_TARGETS+=("$2") 146 | ADD_FILE_SOURCES+=("$3") 147 | POSITION_CLAUSE="${POSITION_CLAUSE} 148 | set position of item \"$2\" to {$4, $5} 149 | " 150 | shift; shift; shift; shift; shift;; 151 | --add-folder) 152 | ADD_FOLDER_TARGETS+=("$2") 153 | ADD_FOLDER_SOURCES+=("$3") 154 | POSITION_CLAUSE="${POSITION_CLAUSE} 155 | set position of item \"$2\" to {$4, $5} 156 | " 157 | shift; shift; shift; shift; shift;; 158 | --disk-image-size) 159 | DISK_IMAGE_SIZE="$2" 160 | shift; shift;; 161 | --hdiutil-verbose) 162 | HDIUTIL_VERBOSITY='-verbose' 163 | shift;; 164 | --hdiutil-quiet) 165 | HDIUTIL_VERBOSITY='-quiet' 166 | shift;; 167 | --sandbox-safe) 168 | SANDBOX_SAFE=1 169 | shift;; 170 | -*) 171 | echo "Unknown option $1. Run with --help for help." 172 | exit 1;; 173 | esac 174 | case $FORMAT in 175 | UDZO) 176 | IMAGEKEY="-imagekey zlib-level=9";; 177 | UDBZ) 178 | IMAGEKEY="-imagekey bzip2-level=9";; 179 | esac 180 | done 181 | 182 | test -z "$2" && { 183 | echo "Not enough arguments. Invoke with --help for help." 184 | exit 1 185 | } 186 | 187 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 188 | DMG_PATH="$1" 189 | DMG_DIRNAME="$(dirname "$DMG_PATH")" 190 | DMG_DIR="$(cd "$DMG_DIRNAME" > /dev/null; pwd)" 191 | DMG_NAME="$(basename "$DMG_PATH")" 192 | DMG_TEMP_NAME="$DMG_DIR/rw.${DMG_NAME}" 193 | SRC_FOLDER="$(cd "$2" > /dev/null; pwd)" 194 | 195 | test -z "$VOLUME_NAME" && VOLUME_NAME="$(basename "$DMG_PATH" .dmg)" 196 | 197 | # brew formula will set this as 1 and embed the support scripts 198 | BREW_INSTALL=0 199 | 200 | AUX_PATH="$SCRIPT_DIR/support" 201 | 202 | if [ $BREW_INSTALL -eq 0 ]; then 203 | test -d "$AUX_PATH" || { 204 | echo "Cannot find support directory: $AUX_PATH" 205 | exit 1 206 | } 207 | fi 208 | 209 | if [ -f "$SRC_FOLDER/.DS_Store" ]; then 210 | echo "Deleting any .DS_Store in source folder" 211 | rm "$SRC_FOLDER/.DS_Store" 212 | fi 213 | 214 | # Create the image 215 | echo "Creating disk image..." 216 | test -f "${DMG_TEMP_NAME}" && rm -f "${DMG_TEMP_NAME}" 217 | 218 | # Using Megabytes since hdiutil fails with very large Byte numbers 219 | function blocks_to_megabytes() { 220 | # Add 1 extra MB, since there's no decimal retention here 221 | MB_SIZE=$((($1 * 512 / 1000 / 1000) + 1)) 222 | echo $MB_SIZE 223 | } 224 | 225 | function get_size() { 226 | # Get block size in disk 227 | bytes_size=`du -s "$1" | sed -e 's/ .*//g'` 228 | echo `blocks_to_megabytes $bytes_size` 229 | } 230 | 231 | # Create the DMG with the specified size or the hdiutil estimation 232 | CUSTOM_SIZE='' 233 | if ! test -z "$DISK_IMAGE_SIZE"; then 234 | CUSTOM_SIZE="-size ${DISK_IMAGE_SIZE}m" 235 | fi 236 | 237 | if [ $SANDBOX_SAFE -eq 0 ]; then 238 | hdiutil create ${HDIUTIL_VERBOSITY} -srcfolder "$SRC_FOLDER" -volname "${VOLUME_NAME}" -fs HFS+ -fsargs "-c c=64,a=16,e=16" -format UDRW ${CUSTOM_SIZE} "${DMG_TEMP_NAME}" 239 | else 240 | hdiutil makehybrid ${HDIUTIL_VERBOSITY} -default-volume-name "${VOLUME_NAME}" -hfs -o "${DMG_TEMP_NAME}" "$SRC_FOLDER" 241 | hdiutil convert -format UDRW -ov -o "${DMG_TEMP_NAME}" "${DMG_TEMP_NAME}" 242 | DISK_IMAGE_SIZE_CUSTOM=$DISK_IMAGE_SIZE 243 | fi 244 | 245 | # Get the created DMG actual size 246 | DISK_IMAGE_SIZE=`get_size "${DMG_TEMP_NAME}"` 247 | 248 | # Use the custom size if bigger 249 | if [ $SANDBOX_SAFE -eq 1 ] && [ $DISK_IMAGE_SIZE_CUSTOM -gt $DISK_IMAGE_SIZE ]; then 250 | DISK_IMAGE_SIZE=$DISK_IMAGE_SIZE_CUSTOM 251 | fi 252 | 253 | # Estimate the additional soruces size 254 | if ! test -z "$ADD_FILE_SOURCES"; then 255 | for i in "${!ADD_FILE_SOURCES[@]}" 256 | do 257 | SOURCE_SIZE=`get_size "${ADD_FILE_SOURCES[$i]}"` 258 | DISK_IMAGE_SIZE=$(expr $DISK_IMAGE_SIZE + $SOURCE_SIZE) 259 | done 260 | fi 261 | if ! test -z "$ADD_FOLDER_SOURCES"; then 262 | for i in "${!ADD_FOLDER_SOURCES[@]}" 263 | do 264 | SOURCE_SIZE=`get_size "${ADD_FOLDER_SOURCES[$i]}"` 265 | DISK_IMAGE_SIZE=$(expr $DISK_IMAGE_SIZE + $SOURCE_SIZE) 266 | done 267 | fi 268 | 269 | # Add extra space for additional resources 270 | DISK_IMAGE_SIZE=$(expr $DISK_IMAGE_SIZE + 20) 271 | 272 | # Resize the image for the extra stuff 273 | hdiutil resize ${HDIUTIL_VERBOSITY} -size ${DISK_IMAGE_SIZE}m "${DMG_TEMP_NAME}" 274 | 275 | # mount it 276 | echo "Mounting disk image..." 277 | MOUNT_DIR="/Volumes/${VOLUME_NAME}" 278 | 279 | # try unmount dmg if it was mounted previously (e.g. developer mounted dmg, installed app and forgot to unmount it) 280 | echo "Unmounting disk image..." 281 | DEV_NAME=$(hdiutil info | egrep --color=never '^/dev/' | sed 1q | awk '{print $1}') 282 | test -d "${MOUNT_DIR}" && hdiutil detach "${DEV_NAME}" 283 | 284 | echo "Mount directory: $MOUNT_DIR" 285 | DEV_NAME=$(hdiutil attach -readwrite -noverify -noautoopen "${DMG_TEMP_NAME}" | egrep --color=never '^/dev/' | sed 1q | awk '{print $1}') 286 | echo "Device name: $DEV_NAME" 287 | 288 | if ! test -z "$BACKGROUND_FILE"; then 289 | echo "Copying background file..." 290 | test -d "$MOUNT_DIR/.background" || mkdir "$MOUNT_DIR/.background" 291 | cp "$BACKGROUND_FILE" "$MOUNT_DIR/.background/$BACKGROUND_FILE_NAME" 292 | fi 293 | 294 | if ! test -z "$APPLICATION_LINK"; then 295 | echo "making link to Applications dir" 296 | echo $MOUNT_DIR 297 | ln -s /Applications "$MOUNT_DIR/Applications" 298 | fi 299 | 300 | if ! test -z "$QL_LINK"; then 301 | echo "making link to QuickLook install dir" 302 | echo $MOUNT_DIR 303 | ln -s "/Library/QuickLook" "$MOUNT_DIR/QuickLook" 304 | fi 305 | 306 | if ! test -z "$VOLUME_ICON_FILE"; then 307 | echo "Copying volume icon file '$VOLUME_ICON_FILE'..." 308 | cp "$VOLUME_ICON_FILE" "$MOUNT_DIR/.VolumeIcon.icns" 309 | SetFile -c icnC "$MOUNT_DIR/.VolumeIcon.icns" 310 | fi 311 | 312 | if ! test -z "$ADD_FILE_SOURCES"; then 313 | echo "Copying custom files..." 314 | for i in "${!ADD_FILE_SOURCES[@]}" 315 | do 316 | echo "${ADD_FILE_SOURCES[$i]}" 317 | cp -a "${ADD_FILE_SOURCES[$i]}" "$MOUNT_DIR/${ADD_FILE_TARGETS[$i]}" 318 | done 319 | fi 320 | 321 | if ! test -z "$ADD_FOLDER_SOURCES"; then 322 | echo "Copying custom folders..." 323 | for i in "${!ADD_FOLDER_SOURCES[@]}" 324 | do 325 | echo "${ADD_FOLDER_SOURCES[$i]}" 326 | cp -a "${ADD_FOLDER_SOURCES[$i]}" "$MOUNT_DIR/${ADD_FOLDER_TARGETS[$i]}" 327 | done 328 | fi 329 | 330 | # run applescript 331 | APPLESCRIPT=$(mktemp -t createdmg.tmp.XXXXXXXXXX) 332 | 333 | function applescript_source() { 334 | if [ $BREW_INSTALL -eq 0 ]; then 335 | cat "$AUX_PATH/template.applescript" 336 | else 337 | cat << 'EOS' 338 | # BREW_INLINE_APPLESCRIPT_PLACEHOLDER 339 | EOS 340 | fi 341 | } 342 | 343 | applescript_source | sed -e "s/WINX/$WINX/g" -e "s/WINY/$WINY/g" -e "s/WINW/$WINW/g" -e "s/WINH/$WINH/g" -e "s/BACKGROUND_CLAUSE/$BACKGROUND_CLAUSE/g" -e "s/REPOSITION_HIDDEN_FILES_CLAUSE/$REPOSITION_HIDDEN_FILES_CLAUSE/g" -e "s/ICON_SIZE/$ICON_SIZE/g" -e "s/TEXT_SIZE/$TEXT_SIZE/g" | perl -pe "s/POSITION_CLAUSE/$POSITION_CLAUSE/g" | perl -pe "s/QL_CLAUSE/$QL_CLAUSE/g" | perl -pe "s/APPLICATION_CLAUSE/$APPLICATION_CLAUSE/g" | perl -pe "s/HIDING_CLAUSE/$HIDING_CLAUSE/" >"$APPLESCRIPT" 344 | sleep 2 # pause to workaround occasional "Can’t get disk" (-1728) issues 345 | echo "Running Applescript: /usr/bin/osascript \"${APPLESCRIPT}\" \"${VOLUME_NAME}\"" 346 | "/usr/bin/osascript" "${APPLESCRIPT}" "${VOLUME_NAME}" || true 347 | echo "Done running the applescript..." 348 | sleep 4 349 | 350 | rm "$APPLESCRIPT" 351 | 352 | # make sure it's not world writeable 353 | echo "Fixing permissions..." 354 | chmod -Rf go-w "${MOUNT_DIR}" &> /dev/null || true 355 | echo "Done fixing permissions." 356 | 357 | # make the top window open itself on mount: 358 | if [ $SANDBOX_SAFE -eq 0 ]; then 359 | echo "Blessing started" 360 | bless --folder "${MOUNT_DIR}" --openfolder "${MOUNT_DIR}" 361 | echo "Blessing finished" 362 | else 363 | echo "Skipping blessing on sandbox" 364 | fi 365 | 366 | if ! test -z "$VOLUME_ICON_FILE"; then 367 | # tell the volume that it has a special file attribute 368 | SetFile -a C "$MOUNT_DIR" 369 | fi 370 | 371 | # unmount 372 | echo "Unmounting disk image..." 373 | hdiutil detach "${DEV_NAME}" 374 | 375 | # compress image 376 | echo "Compressing disk image..." 377 | hdiutil convert ${HDIUTIL_VERBOSITY} "${DMG_TEMP_NAME}" -format ${FORMAT} ${IMAGEKEY} -o "${DMG_DIR}/${DMG_NAME}" 378 | rm -f "${DMG_TEMP_NAME}" 379 | 380 | # adding EULA resources 381 | if [ ! -z "${EULA_RSRC}" -a "${EULA_RSRC}" != "-null-" ]; then 382 | echo "adding EULA resources" 383 | 384 | if [ $BREW_INSTALL -eq 0 ]; then 385 | "${AUX_PATH}/dmg-license.py" "${DMG_DIR}/${DMG_NAME}" "${EULA_RSRC}" 386 | else 387 | python - "${DMG_DIR}/${DMG_NAME}" "${EULA_RSRC}" << 'EOS' 388 | # BREW_INLINE_LICENSE_PLACEHOLDER 389 | EOS 390 | fi 391 | fi 392 | 393 | if [ ! -z "${NOINTERNET}" -a "${NOINTERNET}" == 1 ]; then 394 | echo "not setting 'internet-enable' on the dmg" 395 | else 396 | hdiutil internet-enable -yes "${DMG_DIR}/${DMG_NAME}" 397 | fi 398 | 399 | echo "Disk image done" 400 | exit 0 401 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/sample: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Since create-dmg does not override, be sure to delete previous DMG 4 | test -f Application-Installer.dmg && rm Application-Installer.dmg 5 | 6 | # Create the DMG 7 | ./create-dmg \ 8 | --volname "Application Installer" \ 9 | --volicon "application\_icon.icns" \ 10 | --background "installer\_background.png" \ 11 | --window-pos 200 120 \ 12 | --window-size 800 400 \ 13 | --icon-size 100 \ 14 | --icon "Application.app" 200 190 \ 15 | --hide-extension "Application.app" \ 16 | --app-drop-link 600 185 \ 17 | "Application-Installer.dmg" \ 18 | "source_folder/" -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/support/brew-me.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Bundle support scripts to main script 4 | 5 | cd "${0%/*}" 6 | 7 | script='../create-dmg' 8 | script_brew='../create-dmg-brew' 9 | applescript='template.applescript' 10 | licensescript='dmg-license.py' 11 | 12 | asl=$(awk "/BREW_INLINE_APPLESCRIPT_PLACEHOLDER/{ print NR; exit }" "$script") 13 | sed -n 1,$(( asl - 1 ))p "$script" | sed -e 's/BREW_INSTALL=0/BREW_INSTALL=1/g' > "$script_brew" 14 | cat "$applescript" >> "$script_brew" 15 | lsl=$(awk "/BREW_INLINE_LICENSE_PLACEHOLDER/{ print NR; exit }" "$script") 16 | sed -n $(( asl + 1 )),$(( lsl - 1 ))p "$script" >> "$script_brew" 17 | cat "$licensescript" | sed '1d' >> "$script_brew" 18 | sed -n $(( lsl + 1 )),\$p "$script" >> "$script_brew" 19 | 20 | mv "$script_brew" "$script" 21 | chmod +x "$script" 22 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/support/dmg-license.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | This script adds a license file to a DMG. Requires Xcode and a plain ascii text 4 | license file. 5 | Obviously only runs on a Mac. 6 | 7 | Copyright (C) 2011-2013 Jared Hobbs 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | """ 27 | from __future__ import print_function 28 | import os 29 | import sys 30 | import tempfile 31 | import optparse 32 | 33 | 34 | class Path(str): 35 | def __enter__(self): 36 | return self 37 | 38 | def __exit__(self, type, value, traceback): 39 | os.unlink(self) 40 | 41 | 42 | def mktemp(dir=None, suffix=''): 43 | (fd, filename) = tempfile.mkstemp(dir=dir, suffix=suffix) 44 | os.close(fd) 45 | return Path(filename) 46 | 47 | 48 | def main(options, args): 49 | dmgFile, license = args 50 | with mktemp('.') as tmpFile: 51 | with open(tmpFile, 'w') as f: 52 | f.write("""data 'TMPL' (128, "LPic") { 53 | $"1344 6566 6175 6C74 204C 616E 6775 6167" 54 | $"6520 4944 4457 5244 0543 6F75 6E74 4F43" 55 | $"4E54 042A 2A2A 2A4C 5354 430B 7379 7320" 56 | $"6C61 6E67 2049 4444 5752 441E 6C6F 6361" 57 | $"6C20 7265 7320 4944 2028 6F66 6673 6574" 58 | $"2066 726F 6D20 3530 3030 4457 5244 1032" 59 | $"2D62 7974 6520 6C61 6E67 7561 6765 3F44" 60 | $"5752 4404 2A2A 2A2A 4C53 5445" 61 | }; 62 | 63 | data 'LPic' (5000) { 64 | $"0000 0002 0000 0000 0000 0000 0004 0000" 65 | }; 66 | 67 | data 'STR#' (5000, "English buttons") { 68 | $"0006 0D45 6E67 6C69 7368 2074 6573 7431" 69 | $"0541 6772 6565 0844 6973 6167 7265 6505" 70 | $"5072 696E 7407 5361 7665 2E2E 2E7A 4966" 71 | $"2079 6F75 2061 6772 6565 2077 6974 6820" 72 | $"7468 6520 7465 726D 7320 6F66 2074 6869" 73 | $"7320 6C69 6365 6E73 652C 2063 6C69 636B" 74 | $"2022 4167 7265 6522 2074 6F20 6163 6365" 75 | $"7373 2074 6865 2073 6F66 7477 6172 652E" 76 | $"2020 4966 2079 6F75 2064 6F20 6E6F 7420" 77 | $"6167 7265 652C 2070 7265 7373 2022 4469" 78 | $"7361 6772 6565 2E22" 79 | }; 80 | 81 | data 'STR#' (5002, "English") { 82 | $"0006 0745 6E67 6C69 7368 0541 6772 6565" 83 | $"0844 6973 6167 7265 6505 5072 696E 7407" 84 | $"5361 7665 2E2E 2E7B 4966 2079 6F75 2061" 85 | $"6772 6565 2077 6974 6820 7468 6520 7465" 86 | $"726D 7320 6F66 2074 6869 7320 6C69 6365" 87 | $"6E73 652C 2070 7265 7373 2022 4167 7265" 88 | $"6522 2074 6F20 696E 7374 616C 6C20 7468" 89 | $"6520 736F 6674 7761 7265 2E20 2049 6620" 90 | $"796F 7520 646F 206E 6F74 2061 6772 6565" 91 | $"2C20 7072 6573 7320 2244 6973 6167 7265" 92 | $"6522 2E" 93 | };\n\n""") 94 | with open(license, 'r') as l: 95 | kind = 'RTF ' if license.lower().endswith('.rtf') else 'TEXT' 96 | f.write('data \'%s\' (5000, "English") {\n' % kind) 97 | def escape(s): 98 | return s.strip().replace('\\', '\\\\').replace('"', '\\"').replace('\0', '') 99 | 100 | for line in l: 101 | line = escape(line) 102 | for liner in [line[i:i+1000] for i in range(0, len(line), 1000)]: 103 | f.write(' "' + liner + '"\n') 104 | f.write(' "' + '\\n"\n') 105 | f.write('};\n\n') 106 | f.write("""data 'styl' (5000, "English") { 107 | $"0003 0000 0000 000C 0009 0014 0000 0000" 108 | $"0000 0000 0000 0000 0027 000C 0009 0014" 109 | $"0100 0000 0000 0000 0000 0000 002A 000C" 110 | $"0009 0014 0000 0000 0000 0000 0000" 111 | };\n""") 112 | os.system('hdiutil unflatten -quiet "%s"' % dmgFile) 113 | ret = os.system('%s -a %s -o "%s"' % 114 | (options.rez, tmpFile, dmgFile)) 115 | os.system('hdiutil flatten -quiet "%s"' % dmgFile) 116 | if options.compression is not None: 117 | os.system('cp %s %s.temp.dmg' % (dmgFile, dmgFile)) 118 | os.remove(dmgFile) 119 | if options.compression == "bz2": 120 | os.system('hdiutil convert %s.temp.dmg -format UDBZ -o %s' % 121 | (dmgFile, dmgFile)) 122 | elif options.compression == "gz": 123 | os.system('hdiutil convert %s.temp.dmg -format ' % dmgFile + 124 | 'UDZO -imagekey zlib-devel=9 -o %s' % dmgFile) 125 | os.remove('%s.temp.dmg' % dmgFile) 126 | if ret == 0: 127 | print("Successfully added license to '%s'" % dmgFile) 128 | else: 129 | print("Failed to add license to '%s'" % dmgFile) 130 | 131 | if __name__ == '__main__': 132 | parser = optparse.OptionParser() 133 | parser.set_usage("""%prog [OPTIONS] 134 | This program adds a software license agreement to a DMG file. 135 | It requires Xcode and either a plain ascii text 136 | or a with the RTF contents. 137 | 138 | See --help for more details.""") 139 | parser.add_option( 140 | '--rez', 141 | '-r', 142 | action='store', 143 | default='/Applications/Xcode.app/Contents/Developer/Tools/Rez', 144 | help='The path to the Rez tool. Defaults to %default' 145 | ) 146 | parser.add_option( 147 | '--compression', 148 | '-c', 149 | action='store', 150 | choices=['bz2', 'gz'], 151 | default=None, 152 | help='Optionally compress dmg using specified compression type. ' 153 | 'Choices are bz2 and gz.' 154 | ) 155 | options, args = parser.parse_args() 156 | cond = len(args) != 2 157 | if not os.path.exists(options.rez): 158 | print('Failed to find Rez at "%s"!\n' % options.rez) 159 | cond = True 160 | if cond: 161 | parser.print_usage() 162 | sys.exit(1) 163 | main(options, args) 164 | -------------------------------------------------------------------------------- /fbs/installer/mac/create-dmg/support/template.applescript: -------------------------------------------------------------------------------- 1 | on run (volumeName) 2 | tell application "Finder" 3 | tell disk (volumeName as string) 4 | open 5 | 6 | set theXOrigin to WINX 7 | set theYOrigin to WINY 8 | set theWidth to WINW 9 | set theHeight to WINH 10 | 11 | set theBottomRightX to (theXOrigin + theWidth) 12 | set theBottomRightY to (theYOrigin + theHeight) 13 | set dsStore to "\"" & "/Volumes/" & volumeName & "/" & ".DS_STORE\"" 14 | 15 | tell container window 16 | set current view to icon view 17 | set toolbar visible to false 18 | set statusbar visible to false 19 | set the bounds to {theXOrigin, theYOrigin, theBottomRightX, theBottomRightY} 20 | set statusbar visible to false 21 | REPOSITION_HIDDEN_FILES_CLAUSE 22 | end tell 23 | 24 | set opts to the icon view options of container window 25 | tell opts 26 | set icon size to ICON_SIZE 27 | set text size to TEXT_SIZE 28 | set arrangement to not arranged 29 | end tell 30 | BACKGROUND_CLAUSE 31 | 32 | -- Positioning 33 | POSITION_CLAUSE 34 | 35 | -- Hiding 36 | HIDING_CLAUSE 37 | 38 | -- Application and QL Link Clauses 39 | APPLICATION_CLAUSE 40 | QL_CLAUSE 41 | close 42 | open 43 | -- Force saving of the size 44 | delay 1 45 | 46 | tell container window 47 | set statusbar visible to false 48 | set the bounds to {theXOrigin, theYOrigin, theBottomRightX - 10, theBottomRightY - 10} 49 | end tell 50 | end tell 51 | 52 | delay 1 53 | 54 | tell disk (volumeName as string) 55 | tell container window 56 | set statusbar visible to false 57 | set the bounds to {theXOrigin, theYOrigin, theBottomRightX, theBottomRightY} 58 | end tell 59 | end tell 60 | 61 | --give the finder some time to write the .DS_Store file 62 | delay 3 63 | 64 | set waitTime to 0 65 | set ejectMe to false 66 | repeat while ejectMe is false 67 | delay 1 68 | set waitTime to waitTime + 1 69 | 70 | if (do shell script "[ -f " & dsStore & " ]; echo $?") = "0" then set ejectMe to true 71 | end repeat 72 | log "waited " & waitTime & " seconds for .DS_STORE to be created." 73 | end tell 74 | end run 75 | -------------------------------------------------------------------------------- /fbs/installer/ubuntu.py: -------------------------------------------------------------------------------- 1 | from fbs.installer.linux import generate_installer_files, run_fpm 2 | 3 | def create_installer_ubuntu(): 4 | generate_installer_files() 5 | run_fpm('deb') -------------------------------------------------------------------------------- /fbs/installer/windows.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs.installer import _generate_installer_resources 3 | from subprocess import check_call, DEVNULL 4 | 5 | def create_installer_windows(): 6 | _generate_installer_resources() 7 | try: 8 | check_call( 9 | ['makensis', 'Installer.nsi'], cwd=path('target/installer'), 10 | stdout=DEVNULL 11 | ) 12 | except FileNotFoundError: 13 | raise FileNotFoundError( 14 | "fbs could not find executable 'makensis'. Please install NSIS and " 15 | "add its installation directory to your PATH environment variable." 16 | ) from None -------------------------------------------------------------------------------- /fbs/repo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/fbs/repo/__init__.py -------------------------------------------------------------------------------- /fbs/repo/arch.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs_runtime import FbsError 3 | from os import makedirs 4 | from os.path import exists, join 5 | from shutil import rmtree, copy 6 | from subprocess import check_call, DEVNULL 7 | 8 | def create_repo_arch(): 9 | if not exists(path('target/${installer}.sig')): 10 | raise FbsError( 11 | 'Installer does not exist or is not signed. Maybe you need to ' 12 | 'run:\n' 13 | ' fbs signinst' 14 | ) 15 | dest_dir = path('target/repo') 16 | if exists(dest_dir): 17 | rmtree(dest_dir) 18 | makedirs(dest_dir) 19 | app_name = SETTINGS['app_name'] 20 | pkg_file = path('target/${installer}') 21 | pkg_file_versioned = '%s-%s.pkg.tar.xz' % (app_name, SETTINGS['version']) 22 | copy(pkg_file, join(dest_dir, pkg_file_versioned)) 23 | copy(pkg_file + '.sig', join(dest_dir, pkg_file_versioned + '.sig')) 24 | check_call( 25 | ['repo-add', '%s.db.tar.gz' % app_name, pkg_file_versioned], 26 | cwd=dest_dir, stdout=DEVNULL 27 | ) 28 | # Ensure the permissions are correct if uploading to a server: 29 | check_call(['chmod', 'g-w', '-R', dest_dir]) -------------------------------------------------------------------------------- /fbs/repo/fedora.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs.resources import copy_with_filtering 3 | from fbs_runtime._source import default_path 4 | from os import makedirs, rename 5 | from os.path import exists 6 | from shutil import rmtree, copy 7 | from subprocess import check_call, DEVNULL 8 | 9 | def create_repo_fedora(): 10 | if exists(path('target/repo')): 11 | rmtree(path('target/repo')) 12 | makedirs(path('target/repo/${version}')) 13 | copy(path('target/${installer}'), path('target/repo/${version}')) 14 | check_call(['createrepo_c', '.'], cwd=(path('target/repo')), stdout=DEVNULL) 15 | repo_file = path('src/repo/fedora/${app_name}.repo') 16 | use_default = not exists(repo_file) 17 | if use_default: 18 | repo_file = default_path('src/repo/fedora/AppName.repo') 19 | copy_with_filtering( 20 | repo_file, path('target/repo'), files_to_filter=[repo_file] 21 | ) 22 | if use_default: 23 | rename( 24 | path('target/repo/AppName.repo'), 25 | path('target/repo/${app_name}.repo') 26 | ) -------------------------------------------------------------------------------- /fbs/repo/ubuntu.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs._gpg import preset_gpg_passphrase 3 | from fbs.resources import copy_with_filtering 4 | from fbs_runtime._source import default_path 5 | from os import makedirs 6 | from os.path import exists 7 | from shutil import rmtree 8 | from subprocess import check_call, DEVNULL 9 | 10 | def create_repo_ubuntu(): 11 | dest_dir = path('target/repo') 12 | tmp_dir = path('target/repo-tmp') 13 | if exists(dest_dir): 14 | rmtree(dest_dir) 15 | if exists(tmp_dir): 16 | rmtree(tmp_dir) 17 | makedirs(tmp_dir) 18 | distr_file = 'src/repo/ubuntu/distributions' 19 | distr_path = path(distr_file) 20 | if not exists(distr_path): 21 | distr_path = default_path(distr_file) 22 | copy_with_filtering(distr_path, tmp_dir, files_to_filter=[distr_path]) 23 | preset_gpg_passphrase() 24 | check_call([ 25 | 'reprepro', '-b', dest_dir, '--confdir', tmp_dir, 26 | 'includedeb', 'stable', path('target/${installer}') 27 | ], stdout=DEVNULL) -------------------------------------------------------------------------------- /fbs/resources.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs_runtime import FbsError 3 | from fbs._state import LOADED_PROFILES 4 | from glob import glob 5 | from os import makedirs 6 | from os.path import dirname, isfile, join, basename, relpath, splitext, exists 7 | from pathlib import Path 8 | from shutil import copy, copymode 9 | 10 | import re 11 | import os 12 | 13 | def copy_with_filtering( 14 | src_dir_or_file, dest_dir, replacements=None, files_to_filter=None, 15 | exclude=None, placeholder='${%s}' 16 | ): 17 | """ 18 | Copy the given file or directory to the given destination, optionally 19 | applying filtering. 20 | """ 21 | if replacements is None: 22 | replacements = SETTINGS 23 | if files_to_filter is None: 24 | files_to_filter = [] 25 | if exclude is None: 26 | exclude = [] 27 | to_copy = _get_files_to_copy(src_dir_or_file, dest_dir, exclude) 28 | to_filter = _paths(files_to_filter) 29 | for src, dest in to_copy: 30 | makedirs(dirname(dest), exist_ok=True) 31 | if files_to_filter is None or src in to_filter: 32 | _copy_with_filtering(src, dest, replacements, placeholder) 33 | else: 34 | copy(src, dest) 35 | 36 | def get_icons(): 37 | """ 38 | Return a list [(size, scale, path)] of available app icons for the current 39 | platform. 40 | """ 41 | result = {} 42 | for profile in LOADED_PROFILES: 43 | icons_dir = 'src/main/icons/' + profile 44 | for icon_path in glob(path(icons_dir + '/*.png')): 45 | name = splitext(basename(icon_path))[0] 46 | match = re.match('(\d+)(?:@(\d+)x)?', name) 47 | if not match: 48 | raise FbsError('Invalid icon name: ' + icon_path) 49 | size, scale = int(match.group(1)), int(match.group(2) or '1') 50 | result[(size, scale)] = icon_path 51 | return [(size, scale, path) for (size, scale), path in result.items()] 52 | 53 | def _get_files_to_copy(src_dir_or_file, dest_dir, exclude): 54 | excludes = _paths(map(path, exclude)) 55 | if isfile(src_dir_or_file) and src_dir_or_file not in excludes: 56 | yield src_dir_or_file, join(dest_dir, basename(src_dir_or_file)) 57 | else: 58 | for (subdir, _, files) in os.walk(src_dir_or_file): 59 | dest_subdir = join(dest_dir, relpath(subdir, src_dir_or_file)) 60 | for file_ in files: 61 | file_path = join(subdir, file_) 62 | dest_path = join(dest_subdir, file_) 63 | if file_path not in excludes: 64 | yield file_path, dest_path 65 | 66 | def _copy_with_filtering( 67 | src_file, dest_file, dict_, placeholder='${%s}', encoding='utf-8' 68 | ): 69 | replacements = [] 70 | for key, value in dict_.items(): 71 | old = (placeholder % key).encode(encoding) 72 | new = str(value).encode(encoding) 73 | replacements.append((old, new)) 74 | with open(src_file, 'rb') as open_src_file: 75 | with open(dest_file, 'wb') as open_dest_file: 76 | for line in open_src_file: 77 | new_line = line 78 | for old, new in replacements: 79 | new_line = new_line.replace(old, new) 80 | open_dest_file.write(new_line) 81 | copymode(src_file, dest_file) 82 | 83 | class _paths: 84 | def __init__(self, paths): 85 | self._paths = [] 86 | # _defaults includes "files_to_filter" - eg. Installer.nsi. If these 87 | # files don't also exist in the "user's" src/ directory, then 88 | # Path(p).resolve() raises FileNotFoundError. Handle this: 89 | for p in paths: 90 | try: 91 | self._paths.append(self._resolve_strict(Path(p))) 92 | except FileNotFoundError: 93 | pass 94 | def __contains__(self, item): 95 | item = Path(item).resolve() 96 | for p in self._paths: 97 | # We do `p == item` here instead of `p.samefile(item)` because a 98 | # user reported that the former does not work. The affected paths 99 | # were in a VirtualBox shared folder in a Windows guest. They had 100 | # different st_ino values even though the paths were the same. 101 | # See https://github.com/mherrmann/fbs/issues/112. 102 | if p == item or p in item.parents: 103 | return True 104 | return False 105 | def _resolve_strict(self, path_): 106 | try: 107 | return path_.resolve(strict=True) 108 | except TypeError: 109 | # Python < 3.6: 110 | return path_.resolve() 111 | 112 | def _copy(path_fn, src, dst): # Used by several other internal fbs modules 113 | src = path_fn(src) 114 | if exists(src): 115 | filter_ = [path_fn(f) for f in SETTINGS['files_to_filter']] 116 | copy_with_filtering(src, dst, files_to_filter=filter_) 117 | return True 118 | return False -------------------------------------------------------------------------------- /fbs/sign/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/fbs/sign/__init__.py -------------------------------------------------------------------------------- /fbs/sign/windows.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs_runtime import FbsError 3 | from os import makedirs 4 | from os.path import join, splitext, dirname, basename, exists 5 | from shutil import copy 6 | from subprocess import call, run, DEVNULL 7 | 8 | import hashlib 9 | import json 10 | import os 11 | 12 | _CERTIFICATE_PATH = 'src/sign/windows/certificate.pfx' 13 | _TO_SIGN = ('.exe', '.cab', '.dll', '.ocx', '.msi', '.xpi') 14 | 15 | def sign_windows(): 16 | if not exists(path(_CERTIFICATE_PATH)): 17 | raise FbsError( 18 | 'Could not find a code signing certificate at:\n ' 19 | + _CERTIFICATE_PATH 20 | ) 21 | if 'windows_sign_pass' not in SETTINGS: 22 | raise FbsError( 23 | "Please set 'windows_sign_pass' to the password of %s in either " 24 | "src/build/settings/secret.json, .../windows.json or .../base.json." 25 | % _CERTIFICATE_PATH 26 | ) 27 | for subdir, _, files in os.walk(path('${freeze_dir}')): 28 | for file_ in files: 29 | extension = splitext(file_)[1] 30 | if extension in _TO_SIGN: 31 | sign_file(join(subdir, file_)) 32 | 33 | def sign_file(file_path, description='', url=''): 34 | helper = _SignHelper.instance() 35 | if not helper.is_signed(file_path): 36 | helper.sign(file_path, description, url) 37 | 38 | class _SignHelper: 39 | 40 | _INSTANCE = None 41 | 42 | @classmethod 43 | def instance(cls): 44 | if cls._INSTANCE is None: 45 | cls._INSTANCE = cls(path('cache/signed')) 46 | return cls._INSTANCE 47 | 48 | def __init__(self, cache_dir): 49 | self._cache_dir = cache_dir 50 | 51 | def is_signed(self, file_path): 52 | return not call( 53 | ['signtool', 'verify', '/pa', file_path], stdout=DEVNULL, 54 | stderr=DEVNULL 55 | ) 56 | 57 | def sign(self, file_path, description, url): 58 | json_path = self._get_json_path(file_path) 59 | try: 60 | with open(json_path) as f: 61 | cached = json.load(f) 62 | is_in_cache = description == cached['description'] and \ 63 | url == cached['url'] and \ 64 | self._hash(file_path) == cached['hash'] 65 | except FileNotFoundError: 66 | is_in_cache = False 67 | if not is_in_cache: 68 | self._sign(file_path, description, url) 69 | copy(self._get_path_in_cache(file_path), file_path) 70 | 71 | def _sign(self, file_path, description, url): 72 | path_in_cache = self._get_path_in_cache(file_path) 73 | makedirs(dirname(path_in_cache), exist_ok=True) 74 | copy(file_path, path_in_cache) 75 | hash_ = self._hash(path_in_cache) 76 | self._run_signtool(path_in_cache, 'sha1') 77 | self._run_signtool(path_in_cache, 'sha256') 78 | with open(self._get_json_path(file_path), 'w') as f: 79 | json.dump({ 80 | 'description': description, 81 | 'url': url, 82 | 'hash': hash_ 83 | }, f) 84 | 85 | def _get_json_path(self, file_path): 86 | return self._get_path_in_cache(file_path) + '.json' 87 | 88 | def _get_path_in_cache(self, file_path): 89 | return join(self._cache_dir, basename(file_path)) 90 | 91 | def _run_signtool(self, file_path, digest_alg, description='', url=''): 92 | password = SETTINGS['windows_sign_pass'] 93 | args = [ 94 | 'signtool', 'sign', '/f', path(_CERTIFICATE_PATH), '/p', password 95 | ] 96 | if 'windows_sign_server' in SETTINGS: 97 | args.extend(['/tr', SETTINGS['windows_sign_server']]) 98 | if description: 99 | args.extend(['/d', description]) 100 | if url: 101 | args.extend(['/du', url]) 102 | args.extend(['/as', '/fd', digest_alg, '/td', digest_alg]) 103 | args.append(file_path) 104 | run(args, check=True, stdout=DEVNULL) 105 | 106 | def _hash(self, file_path): 107 | bufsize = 65536 108 | hasher = hashlib.md5() 109 | with open(file_path, 'rb') as f: 110 | buf = f.read(bufsize) 111 | while buf: 112 | hasher.update(buf) 113 | buf = f.read(bufsize) 114 | return hasher.hexdigest() -------------------------------------------------------------------------------- /fbs/sign_installer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/fbs/sign_installer/__init__.py -------------------------------------------------------------------------------- /fbs/sign_installer/arch.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs._gpg import preset_gpg_passphrase 3 | from subprocess import check_call, DEVNULL 4 | 5 | def sign_installer_arch(): 6 | installer = path('target/${installer}') 7 | # Prevent GPG from prompting us for the passphrase when signing: 8 | preset_gpg_passphrase() 9 | check_call( 10 | ['gpg', '--batch', '--yes', '-u', SETTINGS['gpg_key'], 11 | '--output', installer + '.sig', '--detach-sig', installer], 12 | stdout=DEVNULL 13 | ) -------------------------------------------------------------------------------- /fbs/sign_installer/fedora.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs._gpg import preset_gpg_passphrase 3 | from subprocess import check_call, DEVNULL 4 | 5 | def sign_installer_fedora(): 6 | # Prevent GPG from prompting us for the passphrase when signing: 7 | preset_gpg_passphrase() 8 | check_call( 9 | ['rpm', '--addsign', path('target/${installer}')], stdout=DEVNULL 10 | ) -------------------------------------------------------------------------------- /fbs/sign_installer/windows.py: -------------------------------------------------------------------------------- 1 | from fbs import path, SETTINGS 2 | from fbs.sign.windows import sign_file 3 | 4 | def sign_installer_windows(): 5 | installer = path('target/${installer}') 6 | sign_file(installer, SETTINGS['app_name'] + ' Setup', SETTINGS['url']) -------------------------------------------------------------------------------- /fbs/upload.py: -------------------------------------------------------------------------------- 1 | from fbs import _server, SETTINGS, path 2 | from fbs._aws import upload_file, upload_folder_contents 3 | from fbs_runtime import FbsError 4 | from fbs_runtime.platform import is_linux 5 | from os.path import basename 6 | 7 | import json 8 | 9 | def _upload_repo(username, password): 10 | status, response = _server.post_json('start_upload', { 11 | 'username': username, 12 | 'password': password 13 | }) 14 | unexpected_response = lambda: FbsError( 15 | 'Received unexpected server response %d:\n%s' % (status, response) 16 | ) 17 | if status // 2 != 100: 18 | raise unexpected_response() 19 | try: 20 | data = json.loads(response) 21 | except ValueError: 22 | raise unexpected_response() 23 | try: 24 | credentials = data['bucket'], data['key'], data['secret'] 25 | except KeyError: 26 | raise unexpected_response() 27 | dest_path = lambda p: username + '/' + SETTINGS['app_name'] + '/' + p 28 | installer = path('target/${installer}') 29 | installer_dest = dest_path(basename(installer)) 30 | upload_file(installer, installer_dest, *credentials) 31 | uploaded = [installer_dest] 32 | if is_linux(): 33 | repo_dest = dest_path(SETTINGS['repo_subdir']) 34 | uploaded.extend( 35 | upload_folder_contents(path('target/repo'), repo_dest, *credentials) 36 | ) 37 | pubkey_dest = dest_path('public-key.gpg') 38 | upload_file( 39 | path('src/sign/linux/public-key.gpg'), pubkey_dest, *credentials 40 | ) 41 | uploaded.append(pubkey_dest) 42 | status, response = _server.post_json('complete_upload', { 43 | 'username': username, 44 | 'password': password, 45 | 'files': uploaded 46 | }) 47 | if status != 201: 48 | raise unexpected_response() -------------------------------------------------------------------------------- /fbs_runtime/__init__.py: -------------------------------------------------------------------------------- 1 | class FbsError(Exception): 2 | pass -------------------------------------------------------------------------------- /fbs_runtime/_fbs.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime import platform 2 | from fbs_runtime.platform import is_ubuntu, is_linux, is_arch_linux, is_fedora 3 | 4 | def get_core_settings(project_dir): 5 | return { 6 | 'project_dir': project_dir 7 | } 8 | 9 | def get_default_profiles(): 10 | result = ['base'] 11 | # The "secret" profile lets the user store sensitive settings such as 12 | # passwords in src/build/settings/secret.json. When using Git, the user can 13 | # exploit this by adding secret.json to .gitignore, thus preventing it from 14 | # being uploaded to services such as GitHub. 15 | result.append('secret') 16 | result.append(platform.name().lower()) 17 | if is_linux(): 18 | if is_ubuntu(): 19 | result.append('ubuntu') 20 | elif is_arch_linux(): 21 | result.append('arch') 22 | elif is_fedora(): 23 | result.append('fedora') 24 | return result 25 | 26 | def filter_public_settings(settings): 27 | return {k: settings[k] for k in settings['public_settings']} -------------------------------------------------------------------------------- /fbs_runtime/_frozen.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains functions that should only be called when running the 3 | frozen form of the app. 4 | """ 5 | 6 | from os.path import dirname, join, pardir 7 | from fbs_runtime.platform import is_mac 8 | 9 | import sys 10 | 11 | # The contents of this dictionary are injected via a PyInstaller runtime hook. 12 | # See: `fbs.freeze._PyInstallerRuntimehook`. 13 | BUILD_SETTINGS = {} 14 | 15 | def get_resource_dirs(): 16 | app_dir = dirname(sys.executable) 17 | return [join(app_dir, pardir, 'Resources') if is_mac() else app_dir] 18 | 19 | def load_build_settings(): 20 | return BUILD_SETTINGS -------------------------------------------------------------------------------- /fbs_runtime/_resources.py: -------------------------------------------------------------------------------- 1 | from os.path import join, exists, realpath 2 | 3 | import errno 4 | import os 5 | 6 | class ResourceLocator: 7 | def __init__(self, resource_dirs): 8 | self._dirs = resource_dirs 9 | def locate(self, *rel_path): 10 | for resource_dir in self._dirs: 11 | resource_path = join(resource_dir, *rel_path) 12 | if exists(resource_path): 13 | return realpath(resource_path) 14 | raise FileNotFoundError( 15 | errno.ENOENT, 'Could not locate resource', os.sep.join(rel_path) 16 | ) -------------------------------------------------------------------------------- /fbs_runtime/_settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | def load_settings(json_paths, base=None): 4 | """ 5 | Return settings from the given JSON files as a dictionary. This function 6 | expands placeholders: That is, if a settings file contains 7 | 8 | { 9 | "app_name": "MyApp", 10 | "freeze_dir": "target/${app_name}" 11 | } 12 | 13 | then "freeze_dir" in the result of this function is "target/MyApp". 14 | 15 | It also merges lists. Say base.json contains 16 | 17 | { "hidden_imports": ["a"] } 18 | 19 | and mac.json contains 20 | 21 | { "hidden_imports": ["b"] } 22 | 23 | then you obtain 24 | 25 | { "hidden_imports": ["a", "b'] }. 26 | """ 27 | if base is None: 28 | result = None 29 | else: 30 | result = dict(base) 31 | for json_path in json_paths: 32 | with open(json_path, 'r', encoding='utf-8') as f: 33 | data = json.load(f) 34 | result = data if result is None else _merge(result, data) 35 | while True: 36 | for key, value in result.items(): 37 | new_value = expand_placeholders(value, result) 38 | if new_value != value: 39 | result[key] = new_value 40 | break 41 | else: 42 | break 43 | return result 44 | 45 | def expand_placeholders(obj, settings): 46 | if isinstance(obj, str): 47 | for key, value in settings.items(): 48 | obj = obj.replace('${%s}' % key, str(value)) 49 | elif isinstance(obj, list): 50 | return [expand_placeholders(o, settings) for o in obj] 51 | elif isinstance(obj, dict): 52 | return {k: expand_placeholders(v, settings) for k, v in obj.items()} 53 | return obj 54 | 55 | def _merge(a, b): 56 | if type(a) != type(b): 57 | raise ValueError('Cannot merge %r and %r' % (a, b)) 58 | if isinstance(a, list): 59 | return a + b 60 | if isinstance(a, dict): 61 | result = dict(a) 62 | for k, v in b.items(): 63 | result[k] = _merge(a[k], v) if k in a else v 64 | return result 65 | return b -------------------------------------------------------------------------------- /fbs_runtime/_signal.py: -------------------------------------------------------------------------------- 1 | from socket import socketpair, SOCK_DGRAM 2 | 3 | import signal 4 | 5 | class SignalWakeupHandler: 6 | """ 7 | Python's `signal` module lets us define custom signal handlers. What we want 8 | in particular is a graceful handling of Ctrl+C, meaning that the app shuts 9 | down cleanly. 10 | 11 | The default implementation in Python (ie. if we don't set any signals) is to 12 | raise KeyboardInterrupt at arbitrary points in our code. This is not nice or 13 | easy to handle. 14 | 15 | The simplest implementation would be to call `signal(SIGINT, SIG_DFL)`. This 16 | immediately kills the app when the user presses Ctrl in the console window 17 | attached to it. This is not nice because no shutdown/cleanup code is 18 | executed. 19 | 20 | Another implementation would be to call `signal(SIGINT, ...app.exit())`. The 21 | problem is: This signal seems to only be delivered when the app has focus. 22 | So the user presses Ctrl+C in the console, then needs to switch to the GUI 23 | window for the signal to be delivered and the app to shut down. Not nice 24 | either. 25 | 26 | The present class fixes this problem. It integrates Python and Qt so that 27 | signal handlers installed with Python get called immediately. This way, we 28 | can immediately quit the app when the user presses Ctrl+C in the console. 29 | 30 | See: https://stackoverflow.com/a/37229299/1839209 31 | """ 32 | def __init__(self, app, QAbstractSocket): 33 | self._app = app 34 | self.old_fd = None 35 | # Create a socket pair 36 | self.wsock, self.rsock = socketpair(type=SOCK_DGRAM) 37 | self.socket = QAbstractSocket(QAbstractSocket.UdpSocket, app) 38 | # Let Qt listen on the one end 39 | self.socket.setSocketDescriptor(self.rsock.fileno()) 40 | # And let Python write on the other end 41 | self.wsock.setblocking(False) 42 | self.old_fd = signal.set_wakeup_fd(self.wsock.fileno()) 43 | # First Python code executed gets any exception from 44 | # the signal handler, so add a dummy handler first 45 | self.socket.readyRead.connect(lambda : None) 46 | # Second handler does the real handling 47 | self.socket.readyRead.connect(self._readSignal) 48 | def install(self): 49 | signal.signal(signal.SIGINT, lambda *_: self._app.exit(130)) 50 | def __del__(self): 51 | # Restore any old handler on deletion 52 | if self.old_fd is not None and signal and signal.set_wakeup_fd: 53 | signal.set_wakeup_fd(self.old_fd) 54 | def _readSignal(self): 55 | # Read the written byte. 56 | # Note: readyRead is blocked from occurring again until readData() 57 | # was called, so call it, even if you don't need the value. 58 | _ = self.socket.readData(1) -------------------------------------------------------------------------------- /fbs_runtime/_source.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains functions that should only be called by module `fbs`, or 3 | when running from source. 4 | """ 5 | 6 | from fbs_runtime import FbsError 7 | from fbs_runtime._fbs import get_default_profiles, get_core_settings, \ 8 | filter_public_settings 9 | from fbs_runtime._settings import load_settings 10 | from os.path import join, normpath, dirname, pardir, exists 11 | from pathlib import Path 12 | 13 | import os 14 | 15 | def get_project_dir(): 16 | result = Path(os.getcwd()) 17 | while result != result.parent: 18 | if (result / 'src' / 'main' / 'python').is_dir(): 19 | return str(result) 20 | result = result.parent 21 | raise FbsError( 22 | 'Could not determine the project base directory. ' 23 | 'Was expecting src/main/python.' 24 | ) 25 | 26 | def get_resource_dirs(project_dir): 27 | result = [path(project_dir, 'src/main/icons')] 28 | resources = path(project_dir, 'src/main/resources') 29 | result.extend( 30 | join(resources, profile) 31 | # Resource dirs are listed most-specific first whereas profiles are 32 | # listed most-specific last. We therefore need to reverse the order: 33 | for profile in reversed(get_default_profiles()) 34 | ) 35 | return result 36 | 37 | def load_build_settings(project_dir): 38 | core_settings = get_core_settings(project_dir) 39 | profiles = get_default_profiles() 40 | json_paths = get_settings_paths(project_dir, profiles) 41 | all_settings = load_settings(json_paths, core_settings) 42 | return filter_public_settings(all_settings) 43 | 44 | def get_settings_paths(project_dir, profiles): 45 | return list(filter(exists, ( 46 | path_fn('src/build/settings/%s.json' % profile) 47 | for path_fn in (default_path, lambda p: path(project_dir, p)) 48 | for profile in profiles 49 | ))) 50 | 51 | def default_path(path_str): 52 | defaults_dir = join(dirname(__file__), pardir, 'fbs', '_defaults') 53 | return path(defaults_dir, path_str) 54 | 55 | def path(base_dir, path_str): 56 | return normpath(join(base_dir, *path_str.split('/'))) -------------------------------------------------------------------------------- /fbs_runtime/_state.py: -------------------------------------------------------------------------------- 1 | """ 2 | This INTERNAL module is used to manage fbs_runtime's global state. Having it 3 | here, in one central place, allows fbs's test suite to manipulate the state to 4 | simulate various scenarios such as different operating systems. 5 | """ 6 | 7 | PLATFORM_NAME = None 8 | LINUX_DISTRIBUTION = None 9 | APPLICATION_CONTEXT = None 10 | 11 | def get(): 12 | return PLATFORM_NAME, LINUX_DISTRIBUTION, APPLICATION_CONTEXT 13 | 14 | def restore(platform_name, linux_distribution, application_context): 15 | global PLATFORM_NAME, LINUX_DISTRIBUTION, APPLICATION_CONTEXT 16 | PLATFORM_NAME = platform_name 17 | LINUX_DISTRIBUTION = linux_distribution 18 | APPLICATION_CONTEXT = application_context -------------------------------------------------------------------------------- /fbs_runtime/application_context/PyQt5.py: -------------------------------------------------------------------------------- 1 | """ 2 | Earlier fbs versions had the following code: 3 | 4 | try: 5 | from PyQt5 import ... 6 | except ImportError: 7 | from PySide2 import ... 8 | 9 | This lead to problems when both PyQt5 and PySide2 were on PYTHONPATH: 10 | 11 | 1) PyInstaller packaged both (!) libraries because it saw both imports. 12 | 2) The above made fbs always use PyQt5. But if the user's app uses PySide2, 13 | then PySide2 and PyQt5 classes / code would be mixed. 14 | 3) It wasn't clear (or deterministic, really) which Python binding took 15 | precedence. For instance, PyQt5 and PySide2 set different QML search paths. 16 | 17 | To fix this problems, the above code was split into separate files: One that 18 | contains all PyQt5 imports, and another that contains all PySide2 imports. The 19 | user is supposed to import precisely one of the two. This makes PyInstaller 20 | only package the one necessary library, and prevents the above problems. 21 | """ 22 | 23 | from . import _ApplicationContext, _QtBinding, cached_property 24 | from PyQt5.QtGui import QIcon 25 | from PyQt5.QtWidgets import QApplication 26 | from PyQt5.QtNetwork import QAbstractSocket 27 | 28 | class ApplicationContext(_ApplicationContext): 29 | @cached_property 30 | def _qt_binding(self): 31 | return _QtBinding(QApplication, QIcon, QAbstractSocket) -------------------------------------------------------------------------------- /fbs_runtime/application_context/PySide2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Earlier fbs versions had the following code: 3 | 4 | try: 5 | from PyQt5 import ... 6 | except ImportError: 7 | from PySide2 import ... 8 | 9 | This lead to problems when both PyQt5 and PySide2 were on PYTHONPATH: 10 | 11 | 1) PyInstaller packaged both (!) libraries because it saw both imports. 12 | 2) The above made fbs always use PyQt5. But if the user's app uses PySide2, 13 | then PySide2 and PyQt5 classes / code would be mixed. 14 | 3) It wasn't clear (or deterministic, really) which Python binding took 15 | precedence. For instance, PyQt5 and PySide2 set different QML search paths. 16 | 17 | To fix this problems, the above code was split into separate files: One that 18 | contains all PyQt5 imports, and another that contains all PySide2 imports. The 19 | user is supposed to import precisely one of the two. This makes PyInstaller 20 | only package the one necessary library, and prevents the above problems. 21 | """ 22 | 23 | from . import _ApplicationContext, _QtBinding, cached_property 24 | from PySide2.QtGui import QIcon 25 | from PySide2.QtWidgets import QApplication 26 | from PySide2.QtNetwork import QAbstractSocket 27 | 28 | class ApplicationContext(_ApplicationContext): 29 | @cached_property 30 | def _qt_binding(self): 31 | return _QtBinding(QApplication, QIcon, QAbstractSocket) -------------------------------------------------------------------------------- /fbs_runtime/application_context/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from fbs_runtime import _state, _frozen, _source 3 | from fbs_runtime._resources import ResourceLocator 4 | from fbs_runtime._signal import SignalWakeupHandler 5 | from fbs_runtime.excepthook import _Excepthook, StderrExceptionHandler 6 | from fbs_runtime.platform import is_windows, is_mac 7 | from functools import lru_cache 8 | 9 | import sys 10 | 11 | def cached_property(getter): 12 | """ 13 | A cached Python @property. You use it in conjunction with ApplicationContext 14 | below to instantiate the components that comprise your application. For more 15 | information, please consult the Manual: 16 | https://build-system.fman.io/manual/#cached_property 17 | """ 18 | return property(lru_cache()(getter)) 19 | 20 | class _ApplicationContext: 21 | """ 22 | The main point of contact between your application and fbs. For information 23 | on how to use it, please see the Manual: 24 | https://build-system.fman.io/manual/#your-python-code 25 | """ 26 | def __init__(self): 27 | if self.excepthook: 28 | self.excepthook.install() 29 | # Many Qt classes require a QApplication to have been instantiated. 30 | # Do this here, before everything else, to achieve this: 31 | self.app 32 | # We don't build as a console app on Windows, so no point in installing 33 | # the SIGINT handler: 34 | if not is_windows(): 35 | self._signal_wakeup_handler = \ 36 | SignalWakeupHandler(self.app, self._qt_binding.QAbstractSocket) 37 | self._signal_wakeup_handler.install() 38 | if self.app_icon: 39 | self.app.setWindowIcon(self.app_icon) 40 | def run(self): 41 | """ 42 | You should overwrite this method with the steps for starting your app. 43 | See eg. fbs's tutorial. 44 | """ 45 | raise NotImplementedError() 46 | @cached_property 47 | def app(self): 48 | """ 49 | The global Qt QApplication object for your app. Feel free to overwrite 50 | this property, eg. if you wish to use your own subclass of QApplication. 51 | An example of this is given in the Manual. 52 | """ 53 | result = self._qt_binding.QApplication([]) 54 | result.setApplicationName(self.build_settings['app_name']) 55 | result.setApplicationVersion(self.build_settings['version']) 56 | return result 57 | @cached_property 58 | def build_settings(self): 59 | """ 60 | This dictionary contains the values of the settings listed in setting 61 | "public_settings". Eg. `self.build_settings['version']`. 62 | """ 63 | if is_frozen(): 64 | return _frozen.load_build_settings() 65 | return _source.load_build_settings(self._project_dir) 66 | def get_resource(self, *rel_path): 67 | """ 68 | Return the absolute path to the data file with the given name or 69 | (relative) path. When running from source, searches src/main/resources. 70 | Otherwise, searches your app's installation directory. If no file with 71 | the given name or path exists, a FileNotFoundError is raised. 72 | """ 73 | return self._resource_locator.locate(*rel_path) 74 | @cached_property 75 | def exception_handlers(self): 76 | """ 77 | Return a list of exception handlers that should be invoked when an error 78 | occurs. See the documentation of module `fbs_runtime.excepthook` for 79 | more information. 80 | """ 81 | return [StderrExceptionHandler()] 82 | @cached_property 83 | def licensing(self): 84 | """ 85 | This field helps you implement a license key functionality for your 86 | application. For more information, see: 87 | https://build-system.fman.io/manual#license-keys 88 | """ 89 | 90 | # fbs's licensing implementation incurs a dependency on Python library 91 | # `rsa`. We don't want to force all users to install this library. 92 | # So we import fbs_runtime.licensing here, instead of at the top of this 93 | # file. This lets people who don't use licensing avoid the dependency. 94 | from fbs_runtime.licensing import _Licensing 95 | 96 | return _Licensing(self.build_settings['licensing_pubkey']) 97 | @cached_property 98 | def app_icon(self): 99 | """ 100 | The app icon. Not available on Mac because app icons are handled by the 101 | OS there. 102 | """ 103 | if not is_mac(): 104 | return self._qt_binding.QIcon(self.get_resource('Icon.ico')) 105 | @cached_property 106 | def excepthook(self): 107 | """ 108 | Overwrite this method to use a custom excepthook. It should be an object 109 | with a .install() method, or `None` if you want to completely disable 110 | fbs's excepthook implementation. 111 | """ 112 | return _Excepthook(self.exception_handlers) 113 | @cached_property 114 | def _qt_binding(self): 115 | # Implemented in subclasses. 116 | raise NotImplementedError() 117 | @cached_property 118 | def _resource_locator(self): 119 | if is_frozen(): 120 | resource_dirs = _frozen.get_resource_dirs() 121 | else: 122 | resource_dirs = _source.get_resource_dirs(self._project_dir) 123 | return ResourceLocator(resource_dirs) 124 | @cached_property 125 | def _project_dir(self): 126 | assert not is_frozen(), 'Only available when running from source' 127 | return _source.get_project_dir() 128 | 129 | _QtBinding = \ 130 | namedtuple('_QtBinding', ('QApplication', 'QIcon', 'QAbstractSocket')) 131 | 132 | def is_frozen(): 133 | """ 134 | Return True if running from the frozen (i.e. compiled form) of your app, or 135 | False when running from source. 136 | """ 137 | return getattr(sys, 'frozen', False) 138 | 139 | def get_application_context(DevelopmentAppCtxtCls, FrozenAppCtxtCls=None): 140 | if FrozenAppCtxtCls is None: 141 | FrozenAppCtxtCls = DevelopmentAppCtxtCls 142 | if _state.APPLICATION_CONTEXT is None: 143 | _state.APPLICATION_CONTEXT = \ 144 | FrozenAppCtxtCls() if is_frozen() else DevelopmentAppCtxtCls() 145 | return _state.APPLICATION_CONTEXT -------------------------------------------------------------------------------- /fbs_runtime/excepthook/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | By default, fbs overwrites sys.excepthook for better error reporting: 3 | 4 | 1) Applications based on PyQt5 or PySide2 are missing some stack trace entries 5 | in their tracebacks (see add_missing_qt_frames(...) below). fbs makes sure 6 | they are displayed, for easier debugging. 7 | 2) Python < 3.8 does not call sys.excepthook for non-main threads. fbs ensures 8 | that its own excepthook (and thus eg. the benefits of 1) above) does get 9 | called, so you see all errors. 10 | 3) fbs lets you define multiple `ExceptionHandler`s - see below. This lets you 11 | report errors through several channels. Eg. on the console, or on a hosted 12 | error reporting system. 13 | 14 | You can customise these mechanisms using the classes and functions in this 15 | module, and by changing ApplicationContext#exception_handlers and 16 | ...#excepthook. 17 | """ 18 | 19 | from collections import namedtuple 20 | 21 | import sys 22 | import threading 23 | import traceback 24 | 25 | class ExceptionHandler: 26 | """ 27 | Extend this class to implement your own exception handler(s). Then, add it 28 | to your ApplicationContext#exception_handlers. 29 | """ 30 | def init(self): 31 | pass 32 | def handle(self, exc_type, exc_value, enriched_tb): 33 | """ 34 | Return True from this method to prevent further ExceptionHandlers from 35 | being invoked for this exception. 36 | """ 37 | raise NotImplementedError() 38 | 39 | class StderrExceptionHandler(ExceptionHandler): 40 | """ 41 | Print exceptions to stderr. 42 | """ 43 | def handle(self, exc_type, exc_value, enriched_tb): 44 | # Normally, we'd like to use sys.__excepthook__ here. But it doesn't 45 | # work with our "fake" traceback (see add_missing_qt_frames(...)). 46 | # The following call avoids this yet produces the same result: 47 | traceback.print_exception(exc_type, exc_value, enriched_tb) 48 | 49 | class _Excepthook: 50 | """ 51 | fbs's excepthook. Forwards exceptions to the given handlers, until one of 52 | them returns True. Adds stack trace entries that are normally missing in 53 | PyQt5 / PySide2 applications (see add_missing_qt_frames(...)). Also ensures 54 | that, unlike in Python normally, it is called for exceptions in all threads. 55 | """ 56 | def __init__(self, handlers): 57 | self._handlers = handlers 58 | def install(self): 59 | for handler in self._handlers: 60 | handler.init() 61 | sys.excepthook = self 62 | enable_excepthook_for_threads() 63 | def __call__(self, exc_type, exc_value, exc_tb): 64 | if not isinstance(exc_value, SystemExit): 65 | enriched_tb = add_missing_qt_frames(exc_tb) if exc_tb else exc_tb 66 | for handler in self._handlers: 67 | if handler.handle(exc_type, exc_value, enriched_tb): 68 | break 69 | 70 | def enable_excepthook_for_threads(): 71 | """ 72 | `sys.excepthook` isn't called for exceptions raised in non-main-threads. 73 | This workaround fixes this for instances of (non-subclasses of) Thread. 74 | See: http://bugs.python.org/issue1230540 75 | """ 76 | init_original = threading.Thread.__init__ 77 | 78 | def init(self, *args, **kwargs): 79 | init_original(self, *args, **kwargs) 80 | run_original = self.run 81 | 82 | def run_with_except_hook(*args2, **kwargs2): 83 | try: 84 | run_original(*args2, **kwargs2) 85 | except Exception: 86 | sys.excepthook(*sys.exc_info()) 87 | 88 | self.run = run_with_except_hook 89 | 90 | threading.Thread.__init__ = init 91 | 92 | def add_missing_qt_frames(tb): 93 | """ 94 | Let f and h be Python functions and g be a function of Qt. If 95 | f() -> g() -> h() 96 | (where "->" means "calls"), and an exception occurs in h(), then the 97 | traceback does not contain f. This can make debugging very difficult. 98 | To fix this, this function creates a "fake" traceback that contains the 99 | missing entries. 100 | 101 | The code below can be used to reproduce the f() -> g() -> h() problem. 102 | It opens a window with a button. When you click it, an error occurs 103 | whose traceback does not include f(). 104 | 105 | The problem described here is not specific to PyQt5 - It occurs for 106 | PySide2 as well. To see this, replace PyQt5 by PySide2 below. 107 | 108 | from PyQt5.QtWidgets import * 109 | from PyQt5.QtCore import Qt 110 | 111 | class Window(QWidget): 112 | def __init__(self): 113 | super().__init__() 114 | btn = QPushButton('Click me', self) 115 | btn.clicked.connect(self.f) 116 | def f(self, _): 117 | self.inputMethodQuery(Qt.ImAnchorPosition) 118 | def inputMethodQuery(self, query): 119 | if query == Qt.ImAnchorPosition: 120 | # Make Qt call inputMethodQuery(ImCursorPosition). 121 | # This is our "g()": 122 | return super().inputMethodQuery(query) # "g()" 123 | self.h() 124 | def h(self): 125 | raise Exception() 126 | 127 | app = QApplication([]) 128 | window = Window() 129 | window.show() 130 | app.exec_() 131 | """ 132 | result = _fake_tb(tb.tb_frame, tb.tb_lasti, tb.tb_lineno, tb.tb_next) 133 | frame = tb.tb_frame.f_back 134 | while frame: 135 | result = _fake_tb(frame, frame.f_lasti, frame.f_lineno, result) 136 | frame = frame.f_back 137 | return result 138 | 139 | _fake_tb = \ 140 | namedtuple('fake_tb', ('tb_frame', 'tb_lasti', 'tb_lineno', 'tb_next')) -------------------------------------------------------------------------------- /fbs_runtime/excepthook/_util.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | class RateLimiter: 4 | def __init__(self, interval_secs, allowance, time_fn=time): 5 | self._interval = interval_secs 6 | self._allowance = allowance 7 | self._time_fn = time_fn 8 | self._interval_start = time_fn() 9 | self._num_requests = 0 10 | def please(self): 11 | now = self._time_fn() 12 | if now > self._interval_start + self._interval: 13 | self._num_requests = 0 14 | self._interval_start = now 15 | if self._num_requests < self._allowance: 16 | self._num_requests += 1 17 | return True 18 | return False -------------------------------------------------------------------------------- /fbs_runtime/excepthook/sentry.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime.excepthook import ExceptionHandler 2 | from fbs_runtime.excepthook._util import RateLimiter 3 | 4 | class SentryExceptionHandler(ExceptionHandler): 5 | def __init__( 6 | self, dsn, app_version, environment, callback=lambda: None, 7 | rate_limit=10 8 | ): 9 | raise RuntimeError( 10 | 'Error tracking via Sentry is only available in fbs Pro. ' 11 | 'Please obtain it from https://build-system.fman.io/pro.' 12 | ) -------------------------------------------------------------------------------- /fbs_runtime/licensing.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode, b64decode 2 | from rsa import PrivateKey, PublicKey, VerificationError 3 | 4 | import json 5 | import rsa 6 | 7 | def pack_license_key(data, privkey_args): 8 | """ 9 | Pack a dictionary of license key data to a string. You typically call this 10 | function on a server, when a user purchases a license. Eg.: 11 | 12 | lk_contents = pack_license_key({'email': 'some@user.com'}, ...) 13 | 14 | The parameter `privkey_args` is a dictionary containing values for the RSA 15 | fields "n", "e", "d", "p" and "q". You can generate it with fbs's command 16 | `init_licensing`. 17 | 18 | The resulting string is signed to prevent the end user from changing it. 19 | Use the function `unpack_license_key` below to reconstruct `data` from it. 20 | This also verifies that the string was not tampered with. 21 | 22 | This function has two non-obvious caveats: 23 | 24 | 1) It does not obfuscate the data. If `data` contains "key": "value", then 25 | "key": "value" is also visible in the resulting string. 26 | 27 | 2) Calling this function twice with the same arguments will result in the 28 | same string. This may be undesirable when you generate multiple license keys 29 | for the same user. A simple workaround for this is to add a unique parameter 30 | to `data`, such as the current timestamp. 31 | """ 32 | data_bytes = _dumpb(data) 33 | signature = rsa.sign(data_bytes, PrivateKey(**privkey_args), 'SHA-1') 34 | result = dict(data) 35 | if 'key' in data: 36 | raise ValueError('Data must not contain an element called "key"') 37 | result['key'] = b64encode(signature).decode('ascii') 38 | return json.dumps(result) 39 | 40 | class _Licensing: 41 | """ 42 | This internal class lets us inject the licensing functionality into the 43 | application context, in such a way that the fbs user does not have to worry 44 | about where the public key is stored. 45 | """ 46 | def __init__(self, pubkey_args): 47 | self._pubkey = pubkey_args 48 | def unpack_license_key(self, key_str): 49 | return unpack_license_key(key_str, self._pubkey) 50 | 51 | class InvalidKey(Exception): 52 | pass 53 | 54 | def unpack_license_key(key_str, pubkey_args): 55 | """ 56 | Decode a string of license key data produced by `pack_license_key`. In other 57 | words, this function is the inverse of `pack_...` above: 58 | 59 | data == unpack_license_key(pack_license_key(data, ...), ...) 60 | 61 | If the given string is not a valid key, `InvalidKey` is raised. 62 | 63 | The parameter `pubkey_args` is a dictionary containing values for the RSA 64 | fields "n" and "e". It can be generated with fbs's command `init_licensing`. 65 | """ 66 | try: 67 | result = json.loads(key_str) 68 | except ValueError: 69 | raise InvalidKey() from None 70 | try: 71 | signature = result.pop('key') 72 | except KeyError: 73 | raise InvalidKey() from None 74 | try: 75 | signature_bytes = b64decode(signature.encode('ascii')) 76 | except ValueError: 77 | raise InvalidKey() from None 78 | try: 79 | rsa.verify(_dumpb(result), signature_bytes, PublicKey(**pubkey_args)) 80 | except VerificationError: 81 | raise InvalidKey() from None 82 | return result 83 | 84 | def _dumpb(dict_): 85 | return json.dumps(dict_, sort_keys=True).encode('utf-8') -------------------------------------------------------------------------------- /fbs_runtime/platform.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime import _state, FbsError 2 | 3 | import os 4 | import sys 5 | 6 | def is_windows(): 7 | """ 8 | Return True if the current OS is Windows, False otherwise. 9 | """ 10 | return name() == 'Windows' 11 | 12 | def is_mac(): 13 | """ 14 | Return True if the current OS is macOS, False otherwise. 15 | """ 16 | return name() == 'Mac' 17 | 18 | def is_linux(): 19 | """ 20 | Return True if the current OS is Linux, False otherwise. 21 | """ 22 | return name() == 'Linux' 23 | 24 | def name(): 25 | """ 26 | Returns 'Windows', 'Mac' or 'Linux', depending on the current OS. If the OS 27 | can't be determined, FbsError is raised. 28 | """ 29 | if _state.PLATFORM_NAME is None: 30 | _state.PLATFORM_NAME = _get_name() 31 | return _state.PLATFORM_NAME 32 | 33 | def _get_name(): 34 | if sys.platform in ('win32', 'cygwin'): 35 | return 'Windows' 36 | if sys.platform == 'darwin': 37 | return 'Mac' 38 | if sys.platform.startswith('linux'): 39 | return 'Linux' 40 | raise FbsError('Unknown operating system.') 41 | 42 | def is_ubuntu(): 43 | try: 44 | return linux_distribution() in ('Ubuntu', 'Linux Mint', 'Pop!_OS') 45 | except FileNotFoundError: 46 | return False 47 | 48 | def is_arch_linux(): 49 | try: 50 | return linux_distribution() in ('Arch Linux', 'Manjaro Linux') 51 | except FileNotFoundError: 52 | return False 53 | 54 | def is_fedora(): 55 | try: 56 | return linux_distribution() in ('Fedora', 'CentOS Linux') 57 | except FileNotFoundError: 58 | return False 59 | 60 | def linux_distribution(): 61 | if _state.LINUX_DISTRIBUTION is None: 62 | _state.LINUX_DISTRIBUTION = _get_linux_distribution() 63 | return _state.LINUX_DISTRIBUTION 64 | 65 | def _get_linux_distribution(): 66 | if not is_linux(): 67 | return '' 68 | try: 69 | os_release = _get_os_release_name() 70 | except OSError: 71 | pass 72 | else: 73 | if os_release: 74 | return os_release 75 | return '' 76 | 77 | def is_gnome_based(): 78 | curr_desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').lower() 79 | return curr_desktop in ('unity', 'gnome', 'x-cinnamon') 80 | 81 | def is_kde_based(): 82 | curr_desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').lower() 83 | if curr_desktop == 'kde': 84 | return True 85 | gdmsession = os.environ.get('GDMSESSION', '').lower() 86 | return gdmsession.startswith('kde') 87 | 88 | def _get_os_release_name(): 89 | with open('/etc/os-release', 'r') as f: 90 | for line in f: 91 | line = line.rstrip() 92 | if line.startswith('NAME='): 93 | name = line[len('NAME='):] 94 | return name.strip('"') 95 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.9.2 2 | PyInstaller==3.4 3 | rsa>=3.4.2 4 | boto3 5 | sentry-sdk>=0.6.6 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Create cross-platform desktop applications with Python and Qt 2 | 3 | See: 4 | https://build-system.fman.io 5 | """ 6 | 7 | from os.path import relpath, join 8 | from setuptools import setup, find_packages 9 | 10 | import os 11 | 12 | def _get_package_data(pkg_dir, data_subdir): 13 | result = [] 14 | for dirpath, _, filenames in os.walk(join(pkg_dir, data_subdir)): 15 | for filename in filenames: 16 | filepath = join(dirpath, filename) 17 | result.append(relpath(filepath, pkg_dir)) 18 | return result 19 | 20 | description = 'Create cross-platform desktop applications with Python and Qt' 21 | setup( 22 | name='fbs', 23 | # Also update fbs/_defaults/requirements/base.txt when you change this: 24 | version='1.2.7', 25 | description=description, 26 | long_description= 27 | description + '\n\nHome page: https://build-system.fman.io', 28 | author='Michael Herrmann', 29 | author_email='michael+removethisifyouarehuman@herrmann.io', 30 | url='https://build-system.fman.io', 31 | packages=find_packages(exclude=('tests', 'tests.*')), 32 | package_data={ 33 | 'fbs': _get_package_data('fbs', '_defaults'), 34 | 'fbs.builtin_commands': 35 | _get_package_data('fbs/builtin_commands', 'project_template'), 36 | 'fbs.builtin_commands._gpg': 37 | ['Dockerfile', 'genkey.sh', 'gpg-agent.conf'], 38 | 'fbs.installer.mac': _get_package_data( 39 | 'fbs/installer/mac', 'create-dmg' 40 | ) 41 | }, 42 | install_requires=['PyInstaller==3.4'], 43 | extras_require={ 44 | # Also update requirements.txt when you change this: 45 | 'licensing': ['rsa>=3.4.2'], 46 | 'sentry': ['sentry-sdk>=0.6.6'], 47 | 'upload': ['boto3'] 48 | }, 49 | classifiers=[ 50 | 'Development Status :: 5 - Production/Stable', 51 | 'Intended Audience :: Developers', 52 | 53 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 54 | 55 | 'Operating System :: OS Independent', 56 | 57 | 'Programming Language :: Python', 58 | 'Programming Language :: Python :: 3', 59 | 'Programming Language :: Python :: 3.5', 60 | 'Programming Language :: Python :: 3.6', 61 | 62 | 'Topic :: Software Development :: Libraries', 63 | 'Topic :: Software Development :: Libraries :: Python Modules' 64 | ], 65 | entry_points={ 66 | 'console_scripts': ['fbs=fbs.__main__:_main'] 67 | }, 68 | license='GPLv3 or later', 69 | keywords='PyQt', 70 | platforms=['MacOS', 'Windows', 'Debian', 'Fedora', 'CentOS', 'Arch'], 71 | test_suite='tests' 72 | ) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_fbs/__init__.py: -------------------------------------------------------------------------------- 1 | from fbs.resources import copy_with_filtering 2 | from os.path import join, dirname 3 | from tempfile import TemporaryDirectory 4 | from unittest import TestCase 5 | 6 | import fbs 7 | import fbs.builtin_commands 8 | import fbs._state as fbs_state 9 | import fbs_runtime._state as runtime_state 10 | import json 11 | 12 | class FbsTest(TestCase): 13 | def setUp(self): 14 | super().setUp() 15 | # Copy template project to temporary directory: 16 | self._tmp_dir = TemporaryDirectory() 17 | self._project_dir = join(self._tmp_dir.name, 'project') 18 | project_template = \ 19 | join(dirname(fbs.builtin_commands.__file__), 'project_template') 20 | replacements = { 'python_bindings': 'PyQt5' } 21 | filter_ = [join(project_template, 'src', 'main', 'python', 'main.py')] 22 | copy_with_filtering( 23 | project_template, self._project_dir, replacements, filter_ 24 | ) 25 | self._update_settings('base.json', {'app_name': 'MyApp'}) 26 | # Save fbs's state: 27 | self._fbs_state_before = fbs_state.get() 28 | self._runtime_state_before = runtime_state.get() 29 | def init_fbs(self, platform_name=None): 30 | if platform_name is not None: 31 | runtime_state.restore(platform_name, None, None) 32 | fbs.init(self._project_dir) 33 | def tearDown(self): 34 | runtime_state.restore(*self._runtime_state_before) 35 | fbs_state.restore(*self._fbs_state_before) 36 | self._tmp_dir.cleanup() 37 | super().tearDown() 38 | def _update_settings(self, json_name, dict_): 39 | settings = self._read_settings(json_name) 40 | settings.update(dict_) 41 | self._write_settings(json_name, settings) 42 | def _read_settings(self, json_name): 43 | with open(self._json_path(json_name)) as f: 44 | return json.load(f) 45 | def _write_settings(self, json_name, dict_): 46 | with open(self._json_path(json_name), 'w') as f: 47 | json.dump(dict_, f) 48 | def _json_path(self, name): 49 | return join(self._project_dir, 'src', 'build', 'settings', name) -------------------------------------------------------------------------------- /tests/test_fbs/builtin_commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/tests/test_fbs/builtin_commands/__init__.py -------------------------------------------------------------------------------- /tests/test_fbs/builtin_commands/test___init__.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs.builtin_commands import freeze, installer 3 | from fbs_runtime.platform import is_mac, is_windows, is_linux 4 | from os import listdir 5 | from os.path import exists, join 6 | from tests.test_fbs import FbsTest 7 | 8 | class BuiltInCommandsTest(FbsTest): 9 | def test_freeze_installer(self): 10 | freeze() 11 | if is_mac(): 12 | executable = path('${freeze_dir}/Contents/MacOS/${app_name}') 13 | elif is_windows(): 14 | executable = path('${freeze_dir}/${app_name}.exe') 15 | else: 16 | executable = path('${freeze_dir}/${app_name}') 17 | self.assertTrue(exists(executable), executable + ' does not exist') 18 | installer() 19 | self.assertTrue(exists(path('target/${installer}'))) 20 | if is_linux(): 21 | applications_dir = path('target/installer/usr/share/applications') 22 | self.assertEqual(['MyApp.desktop'], listdir(applications_dir)) 23 | with open(join(applications_dir, 'MyApp.desktop')) as f: 24 | self.assertIn('MyApp', f.read()) 25 | def setUp(self): 26 | super().setUp() 27 | self.init_fbs() -------------------------------------------------------------------------------- /tests/test_fbs/builtin_commands/test__util.py: -------------------------------------------------------------------------------- 1 | from fbs.builtin_commands._util import _update_json_str 2 | from unittest import TestCase 3 | 4 | class UpdateJsonStrTest(TestCase): 5 | def test_empty(self): 6 | json_str = '{\n\t"a": "b"}' 7 | self.assertEqual(json_str, _update_json_str(json_str, {})) 8 | def test_single(self): 9 | json_str = '{\n\t"a": "b"}' 10 | self.assertEqual( 11 | '{\n\t"a": "b",\n\t"c": "d"\n}', 12 | _update_json_str(json_str, {'c': 'd'}) 13 | ) 14 | def test_spaces(self): 15 | self.assertEqual( 16 | '{\n "a": "b",\n "c": "d"\n}', 17 | _update_json_str('{\n "a": "b"}', {'c': 'd'}) 18 | ) -------------------------------------------------------------------------------- /tests/test_fbs/test_freeze.py: -------------------------------------------------------------------------------- 1 | from fbs import path 2 | from fbs.freeze import _generate_resources 3 | from os.path import exists 4 | from tests.test_fbs import FbsTest 5 | 6 | class GenerateResourcesTest(FbsTest): 7 | def test_generate_resources(self): 8 | self.init_fbs('Mac') 9 | _generate_resources() 10 | info_plist = path('${freeze_dir}/Contents/Info.plist') 11 | self.assertTrue(exists(info_plist)) 12 | with open(info_plist) as f: 13 | self.assertIn( 14 | 'MyApp', f.read(), "Did not replace '${app_name}' by 'MyApp'" 15 | ) -------------------------------------------------------------------------------- /tests/test_fbs/test_settings.py: -------------------------------------------------------------------------------- 1 | from fbs import SETTINGS 2 | from tests.test_fbs import FbsTest 3 | 4 | class LinuxSettingsTest(FbsTest): 5 | def test_default_does_not_overwrite(self): 6 | # Consider the following scenario: The user sets "url" in base.json 7 | # instead of the usual linux.json. If we loaded settings in the 8 | # following order: 9 | # 1) default base 10 | # 2) user base 11 | # 3) default linux 12 | # 4) user linux 13 | # Then 3) would overwrite 2) and thus the user's "url" setting. 14 | # This test ensures that the load order is instead: 15 | # 1) default base 16 | # 2) default linux 17 | # 3) user base 18 | # 4) user linux. 19 | self._update_settings('base.json', {'url': 'build-system.fman.io'}) 20 | 21 | # The project template's linux.json sets url="". This defeats the 22 | # purpose of this test. So delete the setting: 23 | linux_settings = self._read_settings('linux.json') 24 | del linux_settings['url'] 25 | self._write_settings('linux.json', linux_settings) 26 | 27 | self.init_fbs('Linux') 28 | self.assertEqual('build-system.fman.io', SETTINGS['url']) -------------------------------------------------------------------------------- /tests/test_fbs_runtime/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/tests/test_fbs_runtime/__init__.py -------------------------------------------------------------------------------- /tests/test_fbs_runtime/excepthook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mherrmann/fbs/dbd3854106de6c17e052b627fb3be5fc35989b0a/tests/test_fbs_runtime/excepthook/__init__.py -------------------------------------------------------------------------------- /tests/test_fbs_runtime/excepthook/test__util.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime.excepthook._util import RateLimiter 2 | from unittest import TestCase 3 | 4 | class RateLimiterTest(TestCase): 5 | def test_allowed_at_start(self): 6 | self.assertTrue(self._limiter.please()) 7 | def test_complex(self): 8 | self.assertTrue(self._limiter.please()) 9 | self._time += 1 10 | self.assertTrue(self._limiter.please()) 11 | self._time += 2 12 | self.assertTrue(self._limiter.please(), 'should have reset interval') 13 | self.assertTrue(self._limiter.please()) 14 | self.assertFalse(self._limiter.please()) 15 | self._time += 3 16 | self.assertTrue(self._limiter.please()) 17 | self.assertTrue(self._limiter.please()) 18 | self.assertFalse(self._limiter.please()) 19 | def setUp(self): 20 | super().setUp() 21 | self._time = 0 22 | self._limiter = RateLimiter( 23 | interval_secs=2, allowance=2, time_fn=lambda: self._time 24 | ) -------------------------------------------------------------------------------- /tests/test_fbs_runtime/test__settings.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime._settings import load_settings 2 | from os import listdir 3 | from os.path import join 4 | from tempfile import TemporaryDirectory 5 | from unittest import TestCase 6 | 7 | import json 8 | 9 | class LoadSettingsTest(TestCase): 10 | def test_empty(self): 11 | self._check({}) 12 | def test_string(self): 13 | self._check({'key': 'value'}) 14 | def test_int(self): 15 | self._check({'key': 2}) 16 | def test_multiple(self): 17 | self._check( 18 | {'a': 'b'}, {'c': 'd'}, 19 | expect={'a': 'b', 'c': 'd'} 20 | ) 21 | def test_replace(self): 22 | self._check( 23 | { 24 | 'app_name': 'MyApp', 25 | 'freeze_dir': 'target/${app_name}' 26 | }, 27 | expect={ 28 | 'app_name': 'MyApp', 29 | 'freeze_dir': 'target/MyApp' 30 | } 31 | ) 32 | def test_replace_across_files(self): 33 | self._check( 34 | {'app_name': 'MyApp'}, {'freeze_dir': 'target/${app_name}'}, 35 | expect={'app_name': 'MyApp', 'freeze_dir': 'target/MyApp'} 36 | ) 37 | def test_list(self): 38 | self._check({'list': ['item 1', 'item 2']}) 39 | def test_merge_lists(self): 40 | self._check( 41 | {'list': ['a']}, {'list': ['b']}, 42 | expect={'list': ['a', 'b']} 43 | ) 44 | def test_replace_in_list(self): 45 | self._check( 46 | {'l': ['${a}'], 'a': 'b'}, 47 | expect={'l': ['b'], 'a': 'b'} 48 | ) 49 | def test_replace_in_dict(self): 50 | self._check( 51 | {'d': {'x': '${a}'}, 'a': 'b'}, 52 | expect={'d': {'x': 'b'}, 'a': 'b'} 53 | ) 54 | def test_existing(self): 55 | # fbs.init(...) sets the `project_dir` setting. Test that this setting 56 | # is available to further settings in .json files: 57 | existing = {'project_dir': '/myproject'} 58 | fedora_json = self._dump({ 59 | 'repo_url': 'file://${project_dir}/target/repo' 60 | }) 61 | expected = { 62 | 'repo_url': 'file:///myproject/target/repo', 63 | 'project_dir': '/myproject' 64 | } 65 | self.assertEqual(expected, load_settings([fedora_json], existing)) 66 | def _check(self, *objs, expect=None): 67 | if expect is None: 68 | expect, = objs 69 | files = [self._dump(o) for o in objs] 70 | self.assertEqual(expect, load_settings(files)) 71 | def _dump(self, data): 72 | fname = str(len(listdir(self._tmp_dir.name))) 73 | fpath = join(self._tmp_dir.name, fname) 74 | with open(fpath, 'x') as f: 75 | json.dump(data, f) 76 | return fpath 77 | def setUp(self): 78 | super().setUp() 79 | self._tmp_dir = TemporaryDirectory() 80 | def tearDown(self): 81 | self._tmp_dir.cleanup() 82 | super().tearDown() -------------------------------------------------------------------------------- /tests/test_fbs_runtime/test_licensing.py: -------------------------------------------------------------------------------- 1 | from fbs_runtime.licensing import pack_license_key, unpack_license_key, \ 2 | InvalidKey 3 | from unittest import TestCase 4 | 5 | import json 6 | 7 | class LicensingTest(TestCase): 8 | def test_generate_then_load_license_key(self): 9 | keydata = pack_license_key(self._data, self._privkey) 10 | self.assertEqual(self._data, unpack_license_key(keydata, self._pubkey)) 11 | def test_empty_key(self): 12 | self._check_invalid_key('') 13 | def test_no_signature(self): 14 | self._check_invalid_key(json.dumps(self._data)) 15 | def test_user_tampered_with_key(self): 16 | keydata_str = pack_license_key(self._data, self._privkey) 17 | keydata = json.loads(keydata_str) 18 | keydata['date'] = '2020-01-02' 19 | keydata_modified = json.dumps(keydata) 20 | self._check_invalid_key(keydata_modified) 21 | def _check_invalid_key(self, keydata): 22 | with self.assertRaises(InvalidKey): 23 | unpack_license_key(keydata, self._pubkey) 24 | def setUp(self): 25 | super().setUp() 26 | self._privkey = { 27 | 'n': 9116365493586701555158688922318831122873224915843805750251776548409677052914547431708264536351375628651794208119141013931263465930598754883605782199184293, 28 | 'e': 65537, 29 | 'd': 6266571028366890536031538458435133467895063589403900835388292621051557912362806486944414077848185078422091551985740742061462958257856016077331527399436673, 30 | 'p': 7353587414156093503501011792798870181671531836014338388885442376689613159376021937, 31 | 'q': 1239716750498832082588803847923980076640962859612412544106088598157748789 32 | } 33 | self._pubkey = { 34 | 'n': 9116365493586701555158688922318831122873224915843805750251776548409677052914547431708264536351375628651794208119141013931263465930598754883605782199184293, 35 | 'e': 65537 36 | } 37 | self._data = {'email': 'some@user.com', 'date': '2019-01-02'} --------------------------------------------------------------------------------