├── .flake8 ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── media │ └── screenshot.png ├── installer ├── README.md ├── build_icons.sh ├── macos.spec ├── macos_installer.py ├── windows.spec └── windows_installer.nsi ├── poetry.lock ├── pyproject.toml ├── scripts └── run.py ├── tests ├── __init__.py ├── pytest.ini ├── test_dest_file.py ├── test_file_transfer_protocol.py ├── test_files │ └── file.txt ├── test_source_file.py ├── test_transit_protocol_pair.py ├── test_transit_protocol_receiver.py └── test_transit_protocol_sender.py ├── tox.ini └── wormhole_ui ├── __init__.py ├── errors.py ├── main.py ├── protocol ├── __init__.py ├── file_transfer_protocol.py ├── timeout.py ├── transit │ ├── __init__.py │ ├── dest_file.py │ ├── file_receiver.py │ ├── file_sender.py │ ├── progress.py │ ├── source_file.py │ ├── transit_protocol_base.py │ ├── transit_protocol_pair.py │ ├── transit_protocol_receiver.py │ └── transit_protocol_sender.py └── wormhole_protocol.py ├── resources ├── README.md ├── check.svg ├── icon.svg ├── icon128.png ├── icon16.png ├── icon24.png ├── icon256.png ├── icon32.png ├── icon48.png ├── icon64.png ├── times.svg ├── wormhole.icns └── wormhole.ico ├── util.py └── widgets ├── connect_dialog.py ├── errors.py ├── main_window.py ├── message_table.py ├── save_file_dialog.py ├── shutdown_message.py ├── ui ├── ConnectDialog.ui ├── MainWindow.ui ├── SaveFile.ui └── __init__.py └── ui_dialog.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.coverage 2 | /.python-version 3 | /.tox/ 4 | /.vscode/ 5 | /*.egg-info/ 6 | /build/ 7 | /dist/ 8 | .DS_Store 9 | __pycache__/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | dist: xenial 4 | language: python 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | 9 | install: pip install tox-travis poetry # codecov 10 | 11 | script: tox 12 | 13 | # after_success: codecov 14 | 15 | jobs: 16 | include: 17 | - stage: deploy 18 | script: poetry publish --build --username sneakypete81 --password $PYPI_PASS 19 | 20 | stages: 21 | - test 22 | - name: deploy 23 | if: tag IS present AND repo = sneakypete81/wormhole-ui 24 | 25 | env: 26 | global: 27 | - secure: "Bs+atULrcCLANMXXApC7P/lT/qdv4vf6y1Kxgx1ULrxuhcTiHDy2NFNQ2pQmQgyrWaNFl073ta/eRLYvBas7fYU1iYEzdNyyrq3KbmNW/rXntX69/6I2t9U5Dw70Uae9EL/3Zxz0liRp9Tc7M0pOu96v9k/0JLViO93qLp8mzB91jFTBcALyynuxdQPwikuJIjRdCWQW0TES9HBkWc5A2ZITQ/KqrExSL4DmTyQuDTNGr9iJ4U2fCJP+HfdBTzZNIPF8M/6mShY67EmjHiSb1S4fh3McvT5Sba2dq5+94AyKTKpSwd/tWYZRu9IvmiUcdKy4IqRmWSF9n3Z5Ukk3MeYk0qsgtKdIHSsunKf5koXo6pavKcqgspJMWC51sdlwmOKA8v5VCKQ83HaWPtoAXaD7D1qI/cRgJSLK8qpOU3+WJCDAWiRY1BI77eAjOB/eKWnsQrUi7GQvA3UlAFU2CyOZh8hcdCWQdlObaKi9Yoq6NHzqKgC99u3rMv+yOpkoyjYkR9ALaPWQ0Tp4dn+szriWXAk8ObqLsyBZTMpY3w1WD+0ZXvtFfWsbPBaSEmtP5YCP1/tmCXXBQkXcNkdQIw1szfCqU8icH/iAl1RTVi/Mr5P1rBCr3qN+MstxnIfbcpUd7GiU9x1Bt1vF0gUQbhNUrkxp0k967bhK+maPLl0=" 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Magic Wormhole UI Changelog 2 | 3 | ## v0.2.0 4 | 5 | * Add application icons (#5) 6 | * Add transfer complete/failed icons (#1) 7 | * Handle reception of invalid messages 8 | * Refactoring and unit tests (#10) 9 | 10 | ## v0.1.2 11 | 12 | * Add MacOS installer 13 | 14 | ## v0.1.1 15 | 16 | * Improve Windows UI formatting 17 | * Allow multiple files to be selected 18 | 19 | ## v0.1.0 20 | 21 | * For early evaluation only 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magic Wormhole UI 2 | 3 | A GUI for the [Magic Wormhole](https://github.com/warner/magic-wormhole/). Get things from one computer to another safely. 4 | 5 | ![Screenshot](docs/media/screenshot.png) 6 | 7 | [![PyPI](https://img.shields.io/pypi/v/wormhole-ui.svg)](https://pypi.python.org/pypi/wormhole-ui) 8 | [![Build Status](https://travis-ci.com/sneakypete81/wormhole-ui.svg?branch=master)](https://travis-ci.com/sneakypete81/wormhole-ui) 9 | 10 | ## Installation 11 | 12 | ### Windows 13 | Download the [Windows installer](https://github.com/sneakypete81/wormhole-ui/releases/latest/download/Magic.Wormhole.Installer.exe). 14 | 15 | ### MacOS 16 | Download the [MacOS installer](https://github.com/sneakypete81/wormhole-ui/releases/latest/download/Magic.Wormhole.Installer.dmg). 17 | 18 | ### Linux 19 | Installer coming soon. In the meantime, see below for installing with pipx. 20 | 21 | ### From Source 22 | The recommended method to run from the Python source is with [pipx](https://pipxproject.github.io/pipx/): 23 | ```sh 24 | pipx install wormhole-ui 25 | wormhole-ui 26 | ``` 27 | (or use pip if you prefer) 28 | 29 | ## Development 30 | 31 | Requires [Poetry](https://poetry.eustace.io/). 32 | 33 | ```sh 34 | git clone https://github.com/sneakypete81/wormhole-ui.git 35 | cd wormhole-ui 36 | poetry install 37 | ``` 38 | 39 | Then you can use the following: 40 | 41 | ```sh 42 | poetry run wormhole-ui # Run the app 43 | poetry run pytest # Run unit tests 44 | poetry run flake8 # Run the linter 45 | poetry run black . # Run the code autoformatter 46 | poetry run tox # Run all checks across all supported Python versions 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/docs/media/screenshot.png -------------------------------------------------------------------------------- /installer/README.md: -------------------------------------------------------------------------------- 1 | # Installers 2 | 3 | ## Release Procedure 4 | 5 | * Update `CHANGELOG.md` 6 | * Update version in `pyproject.toml` and `wormhole_ui/__init__.py`. 7 | * Run `poetry run tox` 8 | * Build installers (see below) 9 | * Test installers 10 | * Commit, tag, push 11 | * Publish to PyPI with `poetry publish` 12 | * Create a release on Github, including the changelog and installers 13 | 14 | ## Windows 15 | First build the executable with PyInstaller. This must be done on a Windows machine. 16 | 17 | ```sh 18 | poetry install 19 | poetry run pyinstaller installer/windows.spec --clean 20 | ``` 21 | 22 | This currently builds a --onefile bundle, since otherwise there's a proliferation of 23 | console windows whenever the wormhole connects. 24 | 25 | Then build the installer with [NSIS](https://nsis.sourceforge.io) v3.05. 26 | This can be done on any platform. 27 | 28 | ```sh 29 | makensis -DPRODUCT_VERSION=0.1.0 installer/windows_installer.nsi 30 | ``` 31 | 32 | The installer is written to the `dist` folder. 33 | 34 | ## MacOS 35 | 36 | Set up a High Sierra VM in Virualbox: 37 | 38 | Download the OS installer: 39 | https://apps.apple.com/gb/app/macos-high-sierra/id1246284741?mt=12 40 | 41 | Create an ISO: 42 | https://www.whatroute.net/installerapp2iso.html 43 | 44 | Setup ssh port forwarding and remote login: 45 | https://medium.com/@twister.mr/installing-macos-to-virtualbox-1fcc5cf22801 46 | 47 | To copy files in and out of the VM: 48 | 49 | ```sh 50 | rsync ~/Projects/wormhole-ui sneakypete81@127.0.0.1:~/Projects/ --rsh='ssh -p2222' -r -v --exclude=".git" --exclude=".tox" --exclude="build" --exclude="dist" 51 | 52 | scp -P 2222 -r sneakypete81@127.0.0.1:~/Projects/wormhole-ui/dist/* ~/Projects/wormhole-ui/dist 53 | ``` 54 | 55 | Before building, you will need to have a Python with Framework support: 56 | 57 | ```sh 58 | env PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install 59 | ``` 60 | 61 | First build the .app with PyInstaller. This must be done on a MacOS machine, ideally an old OS version such as the VM set up above. 62 | 63 | ```sh 64 | poetry install 65 | poetry run pyinstaller installer/macos.spec --clean 66 | ``` 67 | 68 | Then build the installer with dmgbuild. 69 | 70 | ```sh 71 | poetry run dmgbuild -s installer/macos_installer.py . . 72 | ``` 73 | 74 | The DMG is written to the `dist` folder. 75 | 76 | ## Icons 77 | The icons have been drawn in Inkscape and exported to various PNG sizes. 78 | 79 | If the icons are changed, rebuild the .icns and .ico iconsets with the following: 80 | 81 | ```sh 82 | installer/build_icons.sh 83 | ``` 84 | -------------------------------------------------------------------------------- /installer/build_icons.sh: -------------------------------------------------------------------------------- 1 | rm -rf build/icons 2 | 3 | echo "Building wormhole.icns..." 4 | mkdir -p build/icons/wormhole.iconset 5 | cp wormhole_ui/resources/icon16.png build/icons/wormhole.iconset/icon_16x16.png 6 | cp wormhole_ui/resources/icon32.png build/icons/wormhole.iconset/icon_16x16@2x.png 7 | cp wormhole_ui/resources/icon32.png build/icons/wormhole.iconset/icon_32x32.png 8 | cp wormhole_ui/resources/icon64.png build/icons/wormhole.iconset/icon_32x32@2x.png 9 | cp wormhole_ui/resources/icon64.png build/icons/wormhole.iconset/icon_64x64.png 10 | cp wormhole_ui/resources/icon128.png build/icons/wormhole.iconset/icon_64x64@2x.png 11 | cp wormhole_ui/resources/icon128.png build/icons/wormhole.iconset/icon_128x128.png 12 | cp wormhole_ui/resources/icon256.png build/icons/wormhole.iconset/icon_128x128@2x.png 13 | cp wormhole_ui/resources/icon256.png build/icons/wormhole.iconset/icon_256x256.png 14 | iconutil -c icns build/icons/wormhole.iconset/ -o wormhole_ui/resources/wormhole.icns 15 | 16 | echo "Building wormhole.ico..." 17 | mkdir -p build/icons/wormhole.iconwin 18 | cp wormhole_ui/resources/icon16.png build/icons/wormhole.iconwin 19 | cp wormhole_ui/resources/icon24.png build/icons/wormhole.iconwin 20 | cp wormhole_ui/resources/icon32.png build/icons/wormhole.iconwin 21 | cp wormhole_ui/resources/icon48.png build/icons/wormhole.iconwin 22 | cp wormhole_ui/resources/icon64.png build/icons/wormhole.iconwin 23 | cp wormhole_ui/resources/icon128.png build/icons/wormhole.iconwin 24 | cp wormhole_ui/resources/icon256.png build/icons/wormhole.iconwin 25 | npx @fiahfy/ico-convert build/icons/wormhole.iconwin/ wormhole_ui/resources/wormhole.ico 26 | -------------------------------------------------------------------------------- /installer/macos.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['../scripts/run.py'], 7 | pathex=[], 8 | binaries=[], 9 | datas=[ 10 | ('../wormhole_ui/widgets/ui/*.ui', 'wormhole_ui/widgets/ui'), 11 | ('../wormhole_ui/resources/*', 'wormhole_ui/resources'), 12 | ], 13 | hiddenimports=['PySide2.QtXml'], 14 | hookspath=[], 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False) 21 | pyz = PYZ(a.pure, a.zipped_data, 22 | cipher=block_cipher) 23 | exe = EXE(pyz, 24 | a.scripts, 25 | [], 26 | exclude_binaries=True, 27 | name='Magic Wormhole', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=False, 32 | console=False ) 33 | coll = COLLECT(exe, 34 | a.binaries, 35 | a.zipfiles, 36 | a.datas, 37 | strip=False, 38 | upx=False, 39 | upx_exclude=[], 40 | name='Magic Wormhole') 41 | app = BUNDLE(coll, 42 | name='Magic Wormhole.app', 43 | icon='../wormhole_ui/resources/wormhole.icns', 44 | bundle_identifier=None, 45 | info_plist={ 46 | 'NSPrincipalClass': 'NSApplication', 47 | 'NSRequiresAquaSystemAppearance': 'NO', 48 | 'NSHighResolutionCapable': 'YES'}) 49 | -------------------------------------------------------------------------------- /installer/macos_installer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import biplist 5 | import os.path 6 | 7 | # 8 | # Example settings file for dmgbuild 9 | # 10 | 11 | # .. Useful stuff .............................................................. 12 | 13 | application = "dist/Magic Wormhole.app" 14 | appname = os.path.basename(application) 15 | 16 | 17 | def icon_from_app(app_path): 18 | plist_path = os.path.join(app_path, "Contents", "Info.plist") 19 | plist = biplist.readPlist(plist_path) 20 | icon_name = plist["CFBundleIconFile"] 21 | icon_root, icon_ext = os.path.splitext(icon_name) 22 | if not icon_ext: 23 | icon_ext = ".icns" 24 | icon_name = icon_root + icon_ext 25 | return os.path.join(app_path, "Contents", "Resources", icon_name) 26 | 27 | 28 | # .. Basics .................................................................... 29 | 30 | # Uncomment to override the output filename 31 | filename = "dist/Magic Wormhole Installer.dmg" 32 | 33 | # Uncomment to override the output volume name 34 | volume_name = "Magic Wormhole Installer" 35 | 36 | # Volume format (see hdiutil create -help) 37 | format = "UDBZ" 38 | 39 | # Volume size 40 | size = None 41 | 42 | # Files to include 43 | files = [application] 44 | 45 | # Symlinks to create 46 | symlinks = {"Applications": "/Applications"} 47 | 48 | # Volume icon 49 | # 50 | # You can either define icon, in which case that icon file will be copied to the 51 | # image, *or* you can define badge_icon, in which case the icon file you specify 52 | # will be used to badge the system's Removable Disk icon 53 | # 54 | # icon = '/path/to/icon.icns' 55 | badge_icon = icon_from_app(application) 56 | 57 | # Where to put the icons 58 | icon_locations = {appname: (140, 120), "Applications": (500, 120)} 59 | 60 | # .. Window configuration ...................................................... 61 | 62 | # Background 63 | # 64 | # This is a STRING containing any of the following: 65 | # 66 | # #3344ff - web-style RGB color 67 | # #34f - web-style RGB color, short form (#34f == #3344ff) 68 | # rgb(1,0,0) - RGB color, each value is between 0 and 1 69 | # hsl(120,1,.5) - HSL (hue saturation lightness) color 70 | # hwb(300,0,0) - HWB (hue whiteness blackness) color 71 | # cmyk(0,1,0,0) - CMYK color 72 | # goldenrod - X11/SVG named color 73 | # builtin-arrow - A simple built-in background with a blue arrow 74 | # /foo/bar/baz.png - The path to an image file 75 | # 76 | # The hue component in hsl() and hwb() may include a unit; it defaults to 77 | # degrees ('deg'), but also supports radians ('rad') and gradians ('grad' 78 | # or 'gon'). 79 | # 80 | # Other color components may be expressed either in the range 0 to 1, or 81 | # as percentages (e.g. 60% is equivalent to 0.6). 82 | background = "builtin-arrow" 83 | 84 | show_status_bar = False 85 | show_tab_view = False 86 | show_toolbar = False 87 | show_pathbar = False 88 | show_sidebar = False 89 | sidebar_width = 180 90 | 91 | # Window position in ((x, y), (w, h)) format 92 | window_rect = ((100, 100), (640, 280)) 93 | 94 | # Select the default view; must be one of 95 | # 96 | # 'icon-view' 97 | # 'list-view' 98 | # 'column-view' 99 | # 'coverflow' 100 | # 101 | default_view = "icon-view" 102 | 103 | # General view configuration 104 | show_icon_preview = False 105 | 106 | # Set these to True to force inclusion of icon/list view settings (otherwise 107 | # we only include settings for the default view) 108 | include_icon_view_settings = "auto" 109 | include_list_view_settings = "auto" 110 | 111 | # .. Icon view configuration ................................................... 112 | 113 | arrange_by = None 114 | grid_offset = (0, 0) 115 | grid_spacing = 100 116 | scroll_position = (0, 0) 117 | label_pos = "bottom" # or 'right' 118 | text_size = 16 119 | icon_size = 128 120 | 121 | # .. List view configuration ................................................... 122 | 123 | # Column names are as follows: 124 | # 125 | # name 126 | # date-modified 127 | # date-created 128 | # date-added 129 | # date-last-opened 130 | # size 131 | # kind 132 | # label 133 | # version 134 | # comments 135 | # 136 | list_icon_size = 16 137 | list_text_size = 12 138 | list_scroll_position = (0, 0) 139 | list_sort_by = "name" 140 | list_use_relative_dates = True 141 | list_calculate_all_sizes = (False,) 142 | list_columns = ("name", "date-modified", "size", "kind", "date-added") 143 | list_column_widths = { 144 | "name": 300, 145 | "date-modified": 181, 146 | "date-created": 181, 147 | "date-added": 181, 148 | "date-last-opened": 181, 149 | "size": 97, 150 | "kind": 115, 151 | "label": 100, 152 | "version": 75, 153 | "comments": 300, 154 | } 155 | list_column_sort_directions = { 156 | "name": "ascending", 157 | "date-modified": "descending", 158 | "date-created": "descending", 159 | "date-added": "descending", 160 | "date-last-opened": "descending", 161 | "size": "descending", 162 | "kind": "ascending", 163 | "label": "ascending", 164 | "version": "ascending", 165 | "comments": "ascending", 166 | } 167 | -------------------------------------------------------------------------------- /installer/windows.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['../scripts/run.py'], 7 | pathex=[], 8 | binaries=[], 9 | datas=[ 10 | ('../wormhole_ui/widgets/ui/*.ui', 'wormhole_ui/widgets/ui'), 11 | ('../wormhole_ui/resources/*', 'wormhole_ui/resources'), 12 | ], 13 | hiddenimports=['PySide2.QtXml'], 14 | hookspath=[], 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False) 21 | pyz = PYZ(a.pure, a.zipped_data, 22 | cipher=block_cipher) 23 | exe = EXE(pyz, 24 | a.scripts, 25 | a.binaries, 26 | a.zipfiles, 27 | a.datas, 28 | [], 29 | name='Magic Wormhole', 30 | debug=False, 31 | bootloader_ignore_signals=False, 32 | strip=False, 33 | upx=False, 34 | upx_exclude=[], 35 | runtime_tmpdir=None, 36 | console=False, 37 | icon='../wormhole_ui/resources/wormhole.ico' ) 38 | 39 | # Settings without --onefile 40 | #exe = EXE(pyz, 41 | # a.scripts, 42 | # [], 43 | # exclude_binaries=True, 44 | # name='Magic Wormhole', 45 | # debug=False, 46 | # bootloader_ignore_signals=False, 47 | # strip=False, 48 | # upx=False, 49 | # console=False, 50 | # icon='../wormhole_ui/resources/wormhole.ico' ) 51 | #coll = COLLECT(exe, 52 | # a.binaries, 53 | # a.zipfiles, 54 | # a.datas, 55 | # upx=False, 56 | # strip=False, 57 | # upx_exclude=[], 58 | # name='Magic Wormhole') 59 | -------------------------------------------------------------------------------- /installer/windows_installer.nsi: -------------------------------------------------------------------------------- 1 | ; Pass the product version from the commandline: 2 | ; makensis.exe /DPRODUCT_VERSION=0.1.0 installer\windows_installer.nsi 3 | 4 | !define PRODUCT_NAME "Magic Wormhole" 5 | !define GUID "{2AA73AEC-43E8-42F3-8B75-A03DEC543AD0}" 6 | 7 | !define INSTALLER_NAME "${PRODUCT_NAME} Installer.exe" 8 | !define PRODUCT_ICON "wormhole.ico" 9 | !define PRODUCT_ICON_PATH "..\wormhole_ui\resources\${PRODUCT_ICON}" 10 | 11 | ; Marker file to tell the uninstaller that it's a user installation 12 | !define USER_INSTALL_MARKER _user_install_marker 13 | 14 | SetCompress off 15 | Unicode True 16 | 17 | !define MULTIUSER_EXECUTIONLEVEL Highest 18 | !define MULTIUSER_INSTALLMODE_DEFAULT_CURRENTUSER 19 | !define MULTIUSER_MUI 20 | !define MULTIUSER_INSTALLMODE_COMMANDLINE 21 | !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCT_NAME}" 22 | !include MultiUser.nsh 23 | 24 | ; Modern UI installer stuff 25 | !include "MUI2.nsh" 26 | !define MUI_ABORTWARNING 27 | !define MUI_ICON "${PRODUCT_ICON_PATH}" 28 | !define MUI_UNICON "${PRODUCT_ICON_PATH}" 29 | 30 | ; UI pages 31 | !insertmacro MUI_PAGE_WELCOME 32 | !insertmacro MULTIUSER_PAGE_INSTALLMODE 33 | !insertmacro MUI_PAGE_DIRECTORY 34 | !insertmacro MUI_PAGE_INSTFILES 35 | !insertmacro MUI_PAGE_FINISH 36 | !insertmacro MUI_LANGUAGE "English" 37 | 38 | Name "${PRODUCT_NAME} ${PRODUCT_VERSION}" 39 | OutFile "..\dist\${INSTALLER_NAME}" 40 | 41 | 42 | Section -SETTINGS 43 | SetOverwrite ifnewer 44 | SectionEnd 45 | 46 | 47 | Section "" UninstallPrevious 48 | DetailPrint "Deleting files from any previous installation" 49 | RMDir /r $INSTDIR 50 | SectionEnd 51 | 52 | 53 | Section "!${PRODUCT_NAME}" sec_app 54 | SetRegView 32 55 | SectionIn RO 56 | 57 | ; Copy program files 58 | SetOutPath "$INSTDIR" 59 | File "..\dist\${PRODUCT_NAME}.exe" 60 | File "${PRODUCT_ICON_PATH}" 61 | 62 | ; Marker file for per-user install 63 | StrCmp $MultiUser.InstallMode CurrentUser 0 +3 64 | FileOpen $0 "$INSTDIR\${USER_INSTALL_MARKER}" w 65 | FileClose $0 66 | SetFileAttributes "$INSTDIR\${USER_INSTALL_MARKER}" HIDDEN 67 | 68 | ; Install shortcuts 69 | ; The output path becomes the working directory for shortcuts 70 | SetOutPath "%HOMEDRIVE%\%HOMEPATH%" 71 | CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe" "" "$INSTDIR\${PRODUCT_ICON}" 72 | SetOutPath "$INSTDIR" 73 | 74 | WriteUninstaller "$INSTDIR\Uninstall ${PRODUCT_NAME}.exe" 75 | 76 | ; Add ourselves to Add/remove programs 77 | WriteRegStr SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 78 | "DisplayName" "${PRODUCT_NAME}" 79 | WriteRegStr SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 80 | "UninstallString" '"$INSTDIR\Uninstall ${PRODUCT_NAME}.exe"' 81 | WriteRegStr SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 82 | "InstallLocation" "$INSTDIR" 83 | WriteRegStr SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 84 | "DisplayIcon" "$INSTDIR\${PRODUCT_ICON}" 85 | WriteRegStr SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 86 | "DisplayVersion" "${PRODUCT_VERSION}" 87 | WriteRegDWORD SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 88 | "NoModify" 1 89 | WriteRegDWORD SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" \ 90 | "NoRepair" 1 91 | 92 | ; Check if we need to reboot 93 | IfRebootFlag 0 noreboot 94 | MessageBox MB_YESNO "A reboot is required to finish the installation. Do you wish to reboot now?" \ 95 | /SD IDNO IDNO noreboot 96 | Reboot 97 | noreboot: 98 | SectionEnd 99 | 100 | Section "Uninstall" 101 | SetRegView 32 102 | SetShellVarContext all 103 | 104 | IfFileExists "$INSTDIR\${USER_INSTALL_MARKER}" 0 +3 105 | SetShellVarContext current 106 | Delete "$INSTDIR\${USER_INSTALL_MARKER}" 107 | 108 | Delete "$SMPROGRAMS\${PRODUCT_NAME}.lnk" 109 | RMDir /r $INSTDIR 110 | 111 | DeleteRegKey SHCTX "Software\Microsoft\Windows\CurrentVersion\Uninstall\${GUID}" 112 | SectionEnd 113 | 114 | 115 | ; Functions 116 | 117 | 118 | Function .onMouseOverSection 119 | ; Find which section the mouse is over, and set the corresponding description. 120 | FindWindow $R0 "#32770" "" $HWNDPARENT 121 | GetDlgItem $R0 $R0 1043 ; description item (must be added to the UI) 122 | 123 | StrCmp $0 ${sec_app} "" +2 124 | SendMessage $R0 ${WM_SETTEXT} 0 "STR:${PRODUCT_NAME}" 125 | 126 | FunctionEnd 127 | 128 | Function .onInit 129 | !insertmacro MULTIUSER_INIT 130 | FunctionEnd 131 | 132 | Function un.onInit 133 | !insertmacro MULTIUSER_UNINIT 134 | FunctionEnd 135 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "wormhole-ui" 3 | version = "0.2.0" 4 | description = "UI for Magic Wormhole - get things from one computer to another safely" 5 | authors = ["sneakypete81 "] 6 | license = "GPL-3.0" 7 | readme = "README.md" 8 | repository = "https://github.com/sneakypete81/wormhole-ui" 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Environment :: MacOS X", 12 | "Environment :: Win32 (MS Windows)", 13 | "Environment :: X11 Applications :: Qt", 14 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 15 | "Topic :: Security :: Cryptography", 16 | "Topic :: System :: Networking", 17 | "Topic :: System :: Systems Administration", 18 | "Topic :: Utilities", 19 | ] 20 | 21 | [tool.poetry.scripts] 22 | wormhole-ui = 'wormhole_ui.main:run' 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.6, <3.8" # Some dependencies don't support Py3.8 yet 26 | magic_wormhole = ">=0.11.2,<0.13.0" 27 | PySide2 = "5.13.1" # Pinned to avoid MacOS build issue https://github.com/pyinstaller/pyinstaller/issues/4627 28 | qt5reactor = "^0.6" 29 | humanize = "3.2.0" # Pinned to avoid MacOS build issue https://github.com/sneakypete81/wormhole-ui/issues/27 30 | [tool.poetry.dev-dependencies] 31 | pytest = "^6.2" 32 | pytest-cov = "^2.10.1" 33 | pytest-mock = "^3.4.0" 34 | pytest-qt = "^3.3.0" 35 | pytest-twisted = "^1.13" 36 | 37 | pyinstaller = "^3.5" 38 | pywin32-ctypes = { version = "^0.2.0", platform = "win32" } 39 | pefile = { version = "^2019.4.18", platform = "win32" } 40 | macholib = { version = "^1.13", platform = "darwin" } 41 | dmgbuild = { version = "^1.3.3", platform = "darwin" } 42 | 43 | black = { version = "^20.8b1", python = "^3.6" } 44 | flake8 = "^3.8.4" 45 | tox = "^3.20.1" 46 | 47 | [build-system] 48 | requires = ["poetry>=0.12"] 49 | build-backend = "poetry.masonry.api" 50 | -------------------------------------------------------------------------------- /scripts/run.py: -------------------------------------------------------------------------------- 1 | from wormhole_ui.main import run 2 | 3 | run() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/tests/__init__.py -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore:The usage of `cmp` is deprecated:DeprecationWarning:automat._methodical 4 | -------------------------------------------------------------------------------- /tests/test_dest_file.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, calling, is_, raises 2 | 3 | from wormhole_ui.errors import RespondError 4 | from wormhole_ui.protocol.transit.dest_file import DestFile 5 | 6 | 7 | class TestDestFile: 8 | def test_attributes_are_set(self): 9 | dest_file = DestFile("file.txt", 42) 10 | 11 | assert_that(dest_file.name, is_("file.txt")) 12 | assert_that(dest_file.final_bytes, is_(42)) 13 | assert_that(dest_file.transfer_bytes, is_(42)) 14 | 15 | def test_path_to_filename_is_removed(self): 16 | dest_file = DestFile("path/to/file.txt", 42) 17 | 18 | assert_that(dest_file.name, is_("file.txt")) 19 | 20 | def test_open_creates_temp_file(self, tmp_path): 21 | tmp_path = tmp_path.resolve() 22 | dest_file = DestFile("file.txt", 42) 23 | 24 | dest_file.open(13, str(tmp_path)) 25 | 26 | assert_that(dest_file.file_object.name, is_(str(tmp_path / "file.txt.part"))) 27 | assert_that((tmp_path / "file.txt.part").exists()) 28 | 29 | def test_open_creates_unique_temp_file(self, tmp_path): 30 | tmp_path = tmp_path.resolve() 31 | dest_file = DestFile("file.txt", 42) 32 | (tmp_path / "file.txt.part").touch() 33 | 34 | dest_file.open(13, str(tmp_path)) 35 | 36 | assert_that(dest_file.file_object.name, is_(str(tmp_path / "file.txt.1.part"))) 37 | assert_that((tmp_path / "file.txt.1.part").exists()) 38 | 39 | def test_open_raises_error_if_insufficient_disk_space(self, tmp_path): 40 | tmp_path = tmp_path.resolve() 41 | dest_file = DestFile("file.txt", 1024 * 1024 * 1024 * 1024 * 1024) 42 | 43 | assert_that( 44 | calling(dest_file.open).with_args(13, str(tmp_path), raises(RespondError)) 45 | ) 46 | 47 | def test_finalise_closes_the_file(self, tmp_path): 48 | tmp_path = tmp_path.resolve() 49 | dest_file = DestFile("file.txt,", 42) 50 | dest_file.open(13, tmp_path) 51 | 52 | dest_file.finalise() 53 | 54 | assert_that(dest_file.file_object.closed, is_(True)) 55 | 56 | def test_finalise_renames_the_temp_file(self, tmp_path): 57 | tmp_path = tmp_path.resolve() 58 | dest_file = DestFile("file.txt", 42) 59 | dest_file.open(13, tmp_path) 60 | 61 | dest_file.finalise() 62 | 63 | assert_that(dest_file.full_path, is_(tmp_path / "file.txt")) 64 | assert_that((tmp_path / "file.txt").exists(), is_(True)) 65 | assert_that((tmp_path / "file.txt.part").exists(), is_(False)) 66 | 67 | def test_finalise_creates_unique_filename(self, tmp_path): 68 | tmp_path = tmp_path.resolve() 69 | dest_file = DestFile("file.txt", 42) 70 | dest_file.open(13, tmp_path) 71 | (tmp_path / "file.txt").touch() 72 | (tmp_path / "file.1.txt").touch() 73 | 74 | dest_file.finalise() 75 | 76 | assert_that(dest_file.name, is_("file.2.txt")) 77 | assert_that(dest_file.full_path, is_(tmp_path / "file.2.txt")) 78 | assert_that((tmp_path / "file.2.txt").exists(), is_(True)) 79 | 80 | def test_cleanup_closes_the_file(self, tmp_path): 81 | tmp_path = tmp_path.resolve() 82 | dest_file = DestFile("file.txt", 42) 83 | dest_file.open(13, tmp_path) 84 | 85 | dest_file.cleanup() 86 | 87 | assert_that(dest_file.file_object.closed, is_(True)) 88 | 89 | def test_cleanup_deletes_the_temp_file(self, tmp_path): 90 | tmp_path = tmp_path.resolve() 91 | dest_file = DestFile("file.txt", 42) 92 | dest_file.open(13, tmp_path) 93 | 94 | dest_file.cleanup() 95 | 96 | assert_that((tmp_path / "file.txt.part").exists(), is_(False)) 97 | 98 | def test_cleanup_does_nothing_if_temp_file_already_deleted(self, tmp_path): 99 | tmp_path = tmp_path.resolve() 100 | dest_file = DestFile("file.txt", 42) 101 | dest_file.open(13, tmp_path) 102 | dest_file.finalise() 103 | assert_that((tmp_path / "file.txt.part").exists(), is_(False)) 104 | 105 | dest_file.cleanup() 106 | -------------------------------------------------------------------------------- /tests/test_file_transfer_protocol.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, is_, starts_with 2 | import pytest 3 | 4 | from wormhole_ui.errors import ( 5 | MessageError, 6 | OfferError, 7 | RefusedError, 8 | RemoteError, 9 | RespondError, 10 | SendFileError, 11 | SendTextError, 12 | ) 13 | from wormhole_ui.protocol.file_transfer_protocol import FileTransferProtocol 14 | 15 | 16 | class TestBase: 17 | @pytest.fixture(autouse=True) 18 | def setup(self, mocker): 19 | wormhole = mocker.patch("wormhole_ui.protocol.file_transfer_protocol.wormhole") 20 | self.wormhole = wormhole.create() 21 | self.wormhole_create = wormhole.create 22 | 23 | self.transit = mocker.patch( 24 | "wormhole_ui.protocol.file_transfer_protocol.TransitProtocolPair" 25 | )() 26 | 27 | self.reactor = mocker.Mock() 28 | self.signals = mocker.Mock() 29 | 30 | def connect(self, signal): 31 | return signal.connect.call_args[0][0] 32 | 33 | 34 | class TestOpen(TestBase): 35 | def test_creates_a_wormhole(self, mocker): 36 | ftp = FileTransferProtocol(self.reactor, self.signals) 37 | ftp.open(None) 38 | 39 | self.wormhole_create.assert_called_with( 40 | appid="lothar.com/wormhole/text-or-file-xfer", 41 | relay_url="ws://relay.magic-wormhole.io:4000/v1", 42 | reactor=self.reactor, 43 | delegate=mocker.ANY, 44 | versions={"v0": {"mode": "connect"}}, 45 | ) 46 | 47 | def test_can_allocate_a_code(self): 48 | ftp = FileTransferProtocol(self.reactor, self.signals) 49 | 50 | ftp.open(None) 51 | 52 | self.wormhole.allocate_code.assert_called() 53 | 54 | def test_can_set_a_code(self): 55 | ftp = FileTransferProtocol(self.reactor, self.signals) 56 | 57 | ftp.open("42-is-a-code") 58 | 59 | self.wormhole.set_code.assert_called_with("42-is-a-code") 60 | 61 | 62 | class TestClose(TestBase): 63 | def test_can_close_the_wormhole_and_transit(self): 64 | ftp = FileTransferProtocol(self.reactor, self.signals) 65 | 66 | ftp.open(None) 67 | ftp.close() 68 | 69 | self.wormhole.close.assert_called() 70 | self.transit.close.assert_called() 71 | 72 | def test_emits_signal_once_wormhole_is_closed(self): 73 | ftp = FileTransferProtocol(self.reactor, self.signals) 74 | 75 | ftp.open(None) 76 | ftp.close() 77 | ftp._wormhole_delegate.wormhole_closed(result="ok") 78 | 79 | self.signals.wormhole_closed.emit.assert_called() 80 | 81 | def test_still_emits_signal_if_wormhole_was_not_open(self): 82 | ftp = FileTransferProtocol(self.reactor, self.signals) 83 | 84 | ftp.close() 85 | 86 | self.signals.wormhole_closed.emit.assert_called() 87 | 88 | 89 | class TestShutdown(TestBase): 90 | def test_can_close_the_wormhole_and_transit(self): 91 | ftp = FileTransferProtocol(self.reactor, self.signals) 92 | 93 | ftp.open(None) 94 | ftp.shutdown() 95 | 96 | self.wormhole.close.assert_called() 97 | self.transit.close.assert_called() 98 | 99 | def test_emits_signal_once_wormhole_is_closed(self): 100 | ftp = FileTransferProtocol(self.reactor, self.signals) 101 | 102 | ftp.open(None) 103 | ftp.shutdown() 104 | ftp._wormhole_delegate.wormhole_closed(result="ok") 105 | 106 | self.signals.wormhole_shutdown.emit.assert_called() 107 | 108 | def test_still_emits_signal_if_wormhole_is_not_open(self): 109 | ftp = FileTransferProtocol(self.reactor, self.signals) 110 | 111 | ftp.shutdown() 112 | 113 | self.signals.wormhole_shutdown.emit.assert_called() 114 | 115 | def test_sends_message_if_connected_and_connect_mode_supported(self): 116 | ftp = FileTransferProtocol(self.reactor, self.signals) 117 | wormhole_open = self.connect(self.signals.wormhole_open) 118 | versions_received = self.connect(self.signals.versions_received) 119 | 120 | ftp.open(None) 121 | wormhole_open() 122 | versions_received({"v0": {"mode": "connect"}}) 123 | ftp.shutdown() 124 | 125 | self.wormhole.send_message.assert_called_with(b'{"command": "shutdown"}') 126 | 127 | def test_doesnt_send_message_if_not_connected(self): 128 | ftp = FileTransferProtocol(self.reactor, self.signals) 129 | versions_received = self.connect(self.signals.versions_received) 130 | 131 | ftp.open(None) 132 | versions_received({"v0": {"mode": "connect"}}) 133 | ftp.shutdown() 134 | 135 | self.wormhole.send_message.assert_not_called() 136 | 137 | def test_doesnt_send_message_if_already_closed(self): 138 | ftp = FileTransferProtocol(self.reactor, self.signals) 139 | wormhole_open = self.connect(self.signals.wormhole_open) 140 | versions_received = self.connect(self.signals.versions_received) 141 | wormhole_closed = self.connect(self.signals.wormhole_closed) 142 | 143 | ftp.open(None) 144 | wormhole_open() 145 | versions_received({"v0": {"mode": "connect"}}) 146 | wormhole_closed() 147 | ftp.shutdown() 148 | 149 | self.wormhole.send_message.assert_not_called() 150 | 151 | def test_doesnt_send_message_if_peer_connect_mode_not_supported(self): 152 | ftp = FileTransferProtocol(self.reactor, self.signals) 153 | wormhole_open = self.connect(self.signals.wormhole_open) 154 | 155 | ftp.open(None) 156 | wormhole_open() 157 | ftp.shutdown() 158 | 159 | self.wormhole.send_message.assert_not_called() 160 | 161 | 162 | class TestSendMessage(TestBase): 163 | def test_can_send_data(self): 164 | ftp = FileTransferProtocol(self.reactor, self.signals) 165 | 166 | ftp.open(None) 167 | ftp.send_message("hello world") 168 | 169 | self.wormhole.send_message.assert_called_with( 170 | b'{"offer": {"message": "hello world"}}' 171 | ) 172 | 173 | 174 | class TestSendFile(TestBase): 175 | def test_calls_transit(self, mocker): 176 | ftp = FileTransferProtocol(self.reactor, self.signals) 177 | 178 | ftp.open(None) 179 | ftp.send_file(42, "test_file") 180 | 181 | self.transit.send_file.assert_called_with(42, "test_file") 182 | 183 | def test_is_sending_file_calls_transit(self, mocker): 184 | ftp = FileTransferProtocol(self.reactor, self.signals) 185 | 186 | ftp.open(None) 187 | self.transit.is_sending_file = mocker.sentinel.value 188 | 189 | assert_that(ftp.is_sending_file(), is_(mocker.sentinel.value)) 190 | 191 | 192 | class TestReceiveFile(TestBase): 193 | def test_calls_transit(self): 194 | ftp = FileTransferProtocol(self.reactor, self.signals) 195 | 196 | ftp.open(None) 197 | ftp.receive_file(42, "path/to/file") 198 | 199 | self.transit.receive_file.assert_called_with(42, "path/to/file") 200 | 201 | def test_is_receiving_file_calls_transit(self, mocker): 202 | ftp = FileTransferProtocol(self.reactor, self.signals) 203 | 204 | ftp.open(None) 205 | self.transit.is_receiving_file = mocker.sentinel.value 206 | 207 | assert_that(ftp.is_receiving_file(), is_(mocker.sentinel.value)) 208 | 209 | def test_wormhole_closed_after_receiving_file_if_connect_mode_not_supported(self): 210 | ftp = FileTransferProtocol(self.reactor, self.signals) 211 | file_transfer_complete = self.connect(self.signals.file_transfer_complete) 212 | 213 | ftp.open(None) 214 | file_transfer_complete(42, "filename") 215 | 216 | self.wormhole.close.assert_called() 217 | 218 | def test_wormhole_not_closed_after_receiving_file_if_connect_mode_supported(self): 219 | ftp = FileTransferProtocol(self.reactor, self.signals) 220 | versions_received = self.connect(self.signals.versions_received) 221 | file_transfer_complete = self.connect(self.signals.file_transfer_complete) 222 | 223 | ftp.open(None) 224 | versions_received({"v0": {"mode": "connect"}}) 225 | file_transfer_complete(42, "filename") 226 | 227 | self.wormhole.close.assert_not_called() 228 | 229 | 230 | class TestRespondError(TestBase): 231 | def test_sends_error_to_peer(self): 232 | ftp = FileTransferProtocol(self.reactor, self.signals) 233 | respond_error = self.connect(self.signals.respond_error) 234 | 235 | ftp.open(None) 236 | respond_error(OfferError("Invalid Offer"), "traceback") 237 | 238 | self.wormhole.send_message.assert_called_with(b'{"error": "Invalid Offer"}') 239 | 240 | def test_emits_error_signals(self): 241 | ftp = FileTransferProtocol(self.reactor, self.signals) 242 | respond_error = self.connect(self.signals.respond_error) 243 | 244 | ftp.open(None) 245 | respond_error(OfferError("Invalid Offer"), "traceback") 246 | 247 | self.signals.error.emit.assert_called() 248 | args = self.signals.error.emit.call_args[0] 249 | assert_that(args[0], is_(OfferError)) 250 | assert_that(args[1], is_("traceback")) 251 | 252 | def test_refused_error_closes_wormhole(self): 253 | ftp = FileTransferProtocol(self.reactor, self.signals) 254 | respond_error = self.connect(self.signals.respond_error) 255 | 256 | ftp.open(None) 257 | respond_error(RefusedError("User Cancelled"), "traceback") 258 | 259 | self.wormhole.send_message.assert_called_with(b'{"error": "User Cancelled"}') 260 | self.wormhole.close.assert_called() 261 | self.signals.error.emit.assert_not_called() 262 | 263 | 264 | class TestErrorMessage(TestBase): 265 | def test_emits_error_signal(self): 266 | ftp = FileTransferProtocol(self.reactor, self.signals) 267 | 268 | ftp.open(None) 269 | ftp._wormhole_delegate.wormhole_got_message(b'{"error": "message"}') 270 | 271 | self.signals.error.emit.assert_called_once() 272 | args = self.signals.error.emit.call_args[0] 273 | assert_that(args[0], is_(RemoteError)) 274 | assert_that(args[1], starts_with("Traceback")) 275 | 276 | 277 | class TestHandleMessage(TestBase): 278 | def test_message_offer_sends_answer(self): 279 | ftp = FileTransferProtocol(self.reactor, self.signals) 280 | 281 | ftp.open(None) 282 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": {"message": "test"}}') 283 | 284 | self.wormhole.send_message.assert_called_with( 285 | b'{"answer": {"message_ack": "ok"}}' 286 | ) 287 | 288 | def test_message_offer_emits_message_received(self): 289 | ftp = FileTransferProtocol(self.reactor, self.signals) 290 | 291 | ftp.open(None) 292 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": {"message": "test"}}') 293 | 294 | self.signals.message_received.emit.assert_called_with("test") 295 | 296 | def test_wormhole_closed_after_receiving_message_if_connect_mode_not_supported( 297 | self, 298 | ): 299 | ftp = FileTransferProtocol(self.reactor, self.signals) 300 | 301 | ftp.open(None) 302 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": {"message": "test"}}') 303 | 304 | self.wormhole.close.assert_called() 305 | 306 | def test_wormhole_not_closed_after_receiving_message_if_connect_mode_supported( 307 | self, 308 | ): 309 | ftp = FileTransferProtocol(self.reactor, self.signals) 310 | versions_received = self.connect(self.signals.versions_received) 311 | 312 | ftp.open(None) 313 | versions_received({"v0": {"mode": "connect"}}) 314 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": {"message": "test"}}') 315 | 316 | self.wormhole.close.assert_not_called() 317 | 318 | def test_file_offer_calls_transit(self): 319 | ftp = FileTransferProtocol(self.reactor, self.signals) 320 | 321 | ftp.open(None) 322 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": {"file": "test"}}') 323 | 324 | self.transit.handle_offer.assert_called_with({"file": "test"}) 325 | 326 | def test_file_offer_emits_receive_pending(self, mocker): 327 | ftp = FileTransferProtocol(self.reactor, self.signals) 328 | dest = mocker.Mock() 329 | dest.name = "filename" 330 | dest.final_bytes = 24000 331 | self.transit.handle_offer.return_value = dest 332 | 333 | ftp.open(None) 334 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": {"file": "test"}}') 335 | 336 | self.signals.file_receive_pending.emit.assert_called_with("filename", 24000) 337 | 338 | def test_invalid_offer_emits_respond_error(self): 339 | ftp = FileTransferProtocol(self.reactor, self.signals) 340 | self.transit.handle_offer.side_effect = RespondError("test") 341 | 342 | ftp.open(None) 343 | ftp._wormhole_delegate.wormhole_got_message(b'{"offer": "illegal"}') 344 | 345 | self.signals.respond_error.emit.assert_called() 346 | 347 | def test_transit_message_calls_transit(self): 348 | ftp = FileTransferProtocol(self.reactor, self.signals) 349 | 350 | ftp.open(None) 351 | ftp._wormhole_delegate.wormhole_got_message(b'{"transit": "contents"}') 352 | 353 | self.transit.handle_transit.assert_called_with("contents") 354 | 355 | def test_message_ack_with_ok_emits_message_sent(self): 356 | ftp = FileTransferProtocol(self.reactor, self.signals) 357 | 358 | ftp.open(None) 359 | ftp._wormhole_delegate.wormhole_got_message( 360 | b'{"answer": {"message_ack": "ok"}}' 361 | ) 362 | 363 | self.signals.message_sent.emit.assert_called_with(True) 364 | self.signals.error.emit.assert_not_called() 365 | 366 | def test_message_ack_with_error_emits_message_sent_and_error(self): 367 | ftp = FileTransferProtocol(self.reactor, self.signals) 368 | 369 | ftp.open(None) 370 | ftp._wormhole_delegate.wormhole_got_message( 371 | b'{"answer": {"message_ack": "error"}}' 372 | ) 373 | 374 | self.signals.message_sent.emit.assert_called_with(False) 375 | self.signals.error.emit.assert_called_once() 376 | args = self.signals.error.emit.call_args[0] 377 | assert_that(args[0], is_(SendTextError)) 378 | assert_that(args[1], starts_with("Traceback")) 379 | 380 | def test_wormhole_closed_after_receiving_message_ack_if_connect_mode_not_supported( 381 | self, 382 | ): 383 | ftp = FileTransferProtocol(self.reactor, self.signals) 384 | 385 | ftp.open(None) 386 | ftp._wormhole_delegate.wormhole_got_message( 387 | b'{"answer": {"message_ack": "ok"}}' 388 | ) 389 | 390 | self.wormhole.close.assert_called() 391 | 392 | def test_wormhole_not_closed_after_receiving_message_ack_if_connect_mode_supported( 393 | self, 394 | ): 395 | ftp = FileTransferProtocol(self.reactor, self.signals) 396 | versions_received = self.connect(self.signals.versions_received) 397 | 398 | ftp.open(None) 399 | versions_received({"v0": {"mode": "connect"}}) 400 | ftp._wormhole_delegate.wormhole_got_message( 401 | b'{"answer": {"message_ack": "ok"}}' 402 | ) 403 | 404 | self.wormhole.close.assert_not_called() 405 | 406 | def test_file_ack_with_ok_calls_transit(self): 407 | ftp = FileTransferProtocol(self.reactor, self.signals) 408 | 409 | ftp.open(None) 410 | ftp._wormhole_delegate.wormhole_got_message(b'{"answer": {"file_ack": "ok"}}') 411 | 412 | self.transit.handle_file_ack.assert_called_once() 413 | self.signals.error.emit.assert_not_called() 414 | 415 | def test_file_ack_with_error_emits_error(self): 416 | ftp = FileTransferProtocol(self.reactor, self.signals) 417 | 418 | ftp.open(None) 419 | ftp._wormhole_delegate.wormhole_got_message( 420 | b'{"answer": {"file_ack": "error"}}' 421 | ) 422 | 423 | self.signals.error.emit.assert_called_once() 424 | args = self.signals.error.emit.call_args[0] 425 | assert_that(args[0], is_(SendFileError)) 426 | assert_that(args[1], starts_with("Traceback")) 427 | 428 | def test_empty_message_emits_error(self): 429 | ftp = FileTransferProtocol(self.reactor, self.signals) 430 | 431 | ftp.open(None) 432 | ftp._wormhole_delegate.wormhole_got_message(b"") 433 | 434 | self.signals.error.emit.assert_called_once() 435 | args = self.signals.error.emit.call_args[0] 436 | assert_that(args[0], is_(MessageError)) 437 | assert_that(args[1], starts_with("Traceback")) 438 | 439 | def test_invalid_json_emits_error(self): 440 | ftp = FileTransferProtocol(self.reactor, self.signals) 441 | 442 | ftp.open(None) 443 | ftp._wormhole_delegate.wormhole_got_message(b'{"invalid": {"json"}') 444 | 445 | self.signals.error.emit.assert_called_once() 446 | args = self.signals.error.emit.call_args[0] 447 | assert_that(args[0], is_(MessageError)) 448 | assert_that(args[1], starts_with("Traceback")) 449 | 450 | 451 | class TestWormholeDelegate(TestBase): 452 | """Most of this functionality is tested elsewhere""" 453 | 454 | def test_got_code_emits_signal(self): 455 | ftp = FileTransferProtocol(self.reactor, self.signals) 456 | 457 | ftp.open(None) 458 | ftp._wormhole_delegate.wormhole_got_code("1-a-code") 459 | 460 | self.signals.code_received.emit.assert_called_once_with("1-a-code") 461 | 462 | def test_got_versions_emits_signals(self): 463 | ftp = FileTransferProtocol(self.reactor, self.signals) 464 | 465 | ftp.open(None) 466 | ftp._wormhole_delegate.wormhole_got_versions("versions") 467 | 468 | self.signals.versions_received.emit.assert_called_once_with("versions") 469 | self.signals.wormhole_open.emit.assert_called_once() 470 | 471 | 472 | class TestTransitDelegate(TestBase): 473 | def test_transit_progress_emits_signal(self): 474 | ftp = FileTransferProtocol(self.reactor, self.signals) 475 | 476 | ftp.open(None) 477 | ftp._transit_delegate.transit_progress(42, 50, 100) 478 | 479 | self.signals.file_transfer_progress.emit.assert_called_once_with(42, 50, 100) 480 | 481 | def test_transit_complete_emits_signal(self): 482 | ftp = FileTransferProtocol(self.reactor, self.signals) 483 | 484 | ftp.open(None) 485 | ftp._transit_delegate.transit_complete(42, "filename") 486 | 487 | self.signals.file_transfer_complete.emit.assert_called_once_with(42, "filename") 488 | 489 | def test_transit_error_emits_signal(self): 490 | ftp = FileTransferProtocol(self.reactor, self.signals) 491 | 492 | ftp.open(None) 493 | ftp._transit_delegate.transit_error(ValueError("error"), "traceback") 494 | 495 | self.signals.error.emit.assert_called_once() 496 | args = self.signals.error.emit.call_args[0] 497 | assert_that(args[0], is_(ValueError)) 498 | assert_that(args[1], is_("traceback")) 499 | -------------------------------------------------------------------------------- /tests/test_files/file.txt: -------------------------------------------------------------------------------- 1 | This is a file used for testing. -------------------------------------------------------------------------------- /tests/test_source_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from hamcrest import assert_that, is_, ends_with 4 | import pytest 5 | 6 | from wormhole_ui.protocol.transit.source_file import SourceFile 7 | 8 | 9 | @pytest.fixture 10 | def test_file_path(): 11 | return str(Path(__file__).parent / "test_files" / "file.txt") 12 | 13 | 14 | class TestSourceFile: 15 | def test_attributes_are_set(self, test_file_path): 16 | source_file = SourceFile(13, test_file_path) 17 | 18 | assert_that(source_file.id, is_(13)) 19 | assert_that(source_file.name, is_("file.txt")) 20 | 21 | def test_open_creates_file_object(self, test_file_path): 22 | source_file = SourceFile(13, test_file_path) 23 | 24 | source_file.open() 25 | 26 | assert_that(source_file.file_object.name, ends_with("file.txt")) 27 | 28 | def test_open_gets_file_size(self, test_file_path): 29 | source_file = SourceFile(13, test_file_path) 30 | 31 | source_file.open() 32 | 33 | assert_that(source_file.transfer_bytes, is_(32)) 34 | assert_that(source_file.final_bytes, is_(32)) 35 | -------------------------------------------------------------------------------- /tests/test_transit_protocol_pair.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, is_ 2 | import pytest 3 | 4 | from wormhole_ui.protocol.transit.transit_protocol_pair import TransitProtocolPair 5 | 6 | 7 | class TestBase: 8 | @pytest.fixture(autouse=True) 9 | def setup(self, mocker): 10 | self.sender = mocker.patch( 11 | "wormhole_ui.protocol.transit.transit_protocol_pair.TransitProtocolSender" 12 | )() 13 | self.receiver = mocker.patch( 14 | "wormhole_ui.protocol.transit.transit_protocol_pair.TransitProtocolReceiver" 15 | )() 16 | self.source_file = mocker.patch( 17 | "wormhole_ui.protocol.transit.transit_protocol_pair.SourceFile" 18 | )() 19 | 20 | 21 | class TestSendFile(TestBase): 22 | def test_sends_transit(self): 23 | transit = TransitProtocolPair(None, None, None) 24 | 25 | transit.send_file(13, "test_file") 26 | 27 | self.sender.send_transit.assert_called_once() 28 | 29 | def test_skips_transit_handshake_if_already_complete(self): 30 | transit = TransitProtocolPair(None, None, None) 31 | transit.send_file(13, "test_file") 32 | transit.handle_transit("transit") 33 | transit.handle_file_ack() 34 | on_send_finished = self.sender.send_file.call_args[0][1] 35 | on_send_finished() 36 | 37 | transit.send_file(13, "test_file") 38 | 39 | self.sender.send_transit.assert_called_once() 40 | assert_that(self.sender.send_offer.call_count, is_(2)) 41 | self.sender.send_offer.assert_called_with(self.source_file) 42 | 43 | def test_opens_source_file(self): 44 | transit = TransitProtocolPair(None, None, None) 45 | 46 | transit.send_file(13, "test_file") 47 | 48 | self.source_file.open.assert_called_once() 49 | 50 | 51 | class TestHandleTransit(TestBase): 52 | def test_handles_transit_when_sending(self): 53 | transit = TransitProtocolPair(None, None, None) 54 | transit.send_file(13, "test_file") 55 | 56 | transit.handle_transit("transit") 57 | 58 | self.sender.handle_transit.assert_called_once_with("transit") 59 | 60 | def test_only_handles_transit_the_first_time_when_sending(self): 61 | transit = TransitProtocolPair(None, None, None) 62 | transit.send_file(13, "test_file") 63 | transit.handle_transit("transit") 64 | transit.handle_file_ack() 65 | on_send_finished = self.sender.send_file.call_args[0][1] 66 | on_send_finished() 67 | 68 | transit.send_file(13, "test_file") 69 | transit.handle_transit("transit") 70 | 71 | self.sender.handle_transit.assert_called_once_with("transit") 72 | 73 | def test_sends_offer_when_sending(self): 74 | transit = TransitProtocolPair(None, None, None) 75 | transit.send_file(13, "test_file") 76 | 77 | transit.handle_transit("transit") 78 | 79 | self.sender.send_offer.assert_called_once_with(self.source_file) 80 | 81 | def test_handles_transit_when_receiving(self): 82 | transit = TransitProtocolPair(None, None, None) 83 | 84 | transit.handle_transit("transit") 85 | 86 | self.receiver.handle_transit.assert_called_once_with("transit") 87 | 88 | def test_only_handles_transit_the_first_time_when_receiving(self): 89 | transit = TransitProtocolPair(None, None, None) 90 | transit.handle_transit("transit") 91 | transit.handle_offer("offer") 92 | transit.receive_file(13, "test_file") 93 | on_receive_finished = self.receiver.receive_file.call_args[0][1] 94 | on_receive_finished() 95 | 96 | transit.handle_transit("transit") 97 | 98 | self.receiver.handle_transit.assert_called_once_with("transit") 99 | 100 | def test_sends_transit_when_receiving(self): 101 | transit = TransitProtocolPair(None, None, None) 102 | 103 | transit.handle_transit("transit") 104 | 105 | self.receiver.send_transit.assert_called_once() 106 | 107 | 108 | class TestHandleFileAck(TestBase): 109 | def test_sends_file(self, mocker): 110 | transit = TransitProtocolPair(None, None, None) 111 | transit.send_file(13, "test_file") 112 | transit.handle_transit("transit") 113 | 114 | transit.handle_file_ack() 115 | 116 | self.sender.send_file.assert_called_once_with(self.source_file, mocker.ANY) 117 | 118 | 119 | class TestHandleOffer(TestBase): 120 | def test_handles_offer(self): 121 | transit = TransitProtocolPair(None, None, None) 122 | transit.handle_transit("transit") 123 | 124 | transit.handle_offer("offer") 125 | 126 | self.receiver.handle_offer.assert_called_once_with("offer") 127 | 128 | def test_returns_dest_file(self, mocker): 129 | dest_file = mocker.Mock() 130 | self.receiver.handle_offer.return_value = dest_file 131 | transit = TransitProtocolPair(None, None, None) 132 | transit.handle_transit("transit") 133 | 134 | result = transit.handle_offer("offer") 135 | 136 | assert_that(result, is_(dest_file)) 137 | 138 | 139 | class TestReceiveFile(TestBase): 140 | def test_file_is_received(self, mocker): 141 | dest_file = mocker.Mock() 142 | self.receiver.handle_offer.return_value = dest_file 143 | transit = TransitProtocolPair(None, None, None) 144 | transit.handle_transit("transit") 145 | transit.handle_offer("offer") 146 | 147 | transit.receive_file(13, "test_file") 148 | 149 | self.receiver.receive_file.assert_called_once_with(dest_file, mocker.ANY) 150 | 151 | 152 | class TestIsSendingFile(TestBase): 153 | def test_is_false_before_sending(self): 154 | transit = TransitProtocolPair(None, None, None) 155 | 156 | assert_that(transit.is_sending_file, is_(False)) 157 | 158 | def test_is_true_while_sending(self): 159 | transit = TransitProtocolPair(None, None, None) 160 | transit.send_file(13, "test_file") 161 | 162 | assert_that(transit.is_sending_file, is_(True)) 163 | 164 | def test_is_false_after_sending(self): 165 | transit = TransitProtocolPair(None, None, None) 166 | transit.send_file(13, "test_file") 167 | transit.handle_transit("transit") 168 | transit.handle_file_ack() 169 | assert_that(transit.is_sending_file, is_(True)) 170 | 171 | on_send_finished = self.sender.send_file.call_args[0][1] 172 | on_send_finished() 173 | 174 | assert_that(transit.is_sending_file, is_(False)) 175 | 176 | 177 | class TestIsReceivingFile(TestBase): 178 | def test_is_false_before_receiving(self): 179 | transit = TransitProtocolPair(None, None, None) 180 | 181 | assert_that(transit.is_receiving_file, is_(False)) 182 | 183 | def test_is_true_while_receiving(self): 184 | transit = TransitProtocolPair(None, None, None) 185 | transit.handle_transit("transit") 186 | transit.handle_offer("offer") 187 | transit.receive_file(13, "test_file") 188 | 189 | assert_that(transit.is_receiving_file, is_(True)) 190 | 191 | def test_is_false_after_receiving(self): 192 | transit = TransitProtocolPair(None, None, None) 193 | transit.handle_transit("transit") 194 | transit.handle_offer("offer") 195 | transit.receive_file(13, "test_file") 196 | 197 | on_receive_finished = self.receiver.receive_file.call_args[0][1] 198 | on_receive_finished() 199 | 200 | assert_that(transit.is_receiving_file, is_(False)) 201 | -------------------------------------------------------------------------------- /tests/test_transit_protocol_receiver.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, is_, starts_with, calling, raises 2 | import pytest 3 | from twisted.internet import defer 4 | 5 | from wormhole_ui.errors import RespondError 6 | from wormhole_ui.protocol.transit.transit_protocol_receiver import ( 7 | TransitProtocolReceiver, 8 | ) 9 | 10 | 11 | class TestBase: 12 | @pytest.fixture(autouse=True) 13 | def setup(self, mocker): 14 | self.transit = mocker.patch( 15 | "wormhole_ui.protocol.transit.transit_protocol_receiver.TransitReceiver" 16 | )() 17 | self.file_receiver = mocker.patch( 18 | "wormhole_ui.protocol.transit.transit_protocol_receiver.FileReceiver" 19 | )() 20 | self.wormhole = mocker.Mock() 21 | self.delegate = mocker.Mock() 22 | 23 | 24 | class TestHandleTransit(TestBase): 25 | def test_adds_hints(self, mocker): 26 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, None) 27 | transit_receiver.handle_transit({"hints-v1": "received_hints"}) 28 | 29 | self.transit.add_connection_hints.assert_called_once_with("received_hints") 30 | 31 | def test_sets_key(self, mocker): 32 | self.transit.TRANSIT_KEY_LENGTH = 128 33 | self.wormhole.derive_key.return_value = mocker.sentinel.key 34 | 35 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, None) 36 | transit_receiver.handle_transit({}) 37 | 38 | self.wormhole.derive_key.assert_called_once_with( 39 | "lothar.com/wormhole/text-or-file-xfer/transit-key", 128 40 | ) 41 | self.transit.set_transit_key.assert_called_once_with(mocker.sentinel.key) 42 | 43 | 44 | class TestSendTransit(TestBase): 45 | def test_sends_transit(self, mocker): 46 | self.transit.get_connection_abilities.return_value = "abilities" 47 | self.transit.get_connection_hints.return_value = defer.Deferred() 48 | 49 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, None) 50 | transit_receiver.send_transit() 51 | self.transit.get_connection_hints.return_value.callback("hints") 52 | 53 | self.wormhole.send_message.assert_called_once_with( 54 | b'{"transit": {"abilities-v1": "abilities", "hints-v1": "hints"}}', 55 | ) 56 | 57 | def test_emits_transit_error_on_exception(self, mocker): 58 | self.transit.get_connection_abilities.return_value = "abilities" 59 | self.transit.get_connection_hints.return_value = defer.Deferred() 60 | 61 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, self.delegate) 62 | transit_receiver.send_transit() 63 | self.transit.get_connection_hints.return_value.errback(Exception("Error")) 64 | 65 | self.delegate.transit_error.assert_called_once() 66 | kwargs = self.delegate.transit_error.call_args[1] 67 | assert_that(kwargs["exception"], is_(Exception)) 68 | assert_that(kwargs["traceback"], starts_with("Traceback")) 69 | 70 | 71 | class TestHandleOffer(TestBase): 72 | def test_offer_is_parsed(self, mocker): 73 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, None) 74 | result = transit_receiver.handle_offer( 75 | {"file": {"filename": "test_file", "filesize": 42}} 76 | ) 77 | 78 | assert_that(result.name, is_("test_file")) 79 | assert_that(result.final_bytes, is_(42)) 80 | 81 | def test_invalid_offer_raises_exception(self, mocker): 82 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, None) 83 | 84 | assert_that( 85 | calling(transit_receiver.handle_offer).with_args({"invalid": "test_file"}), 86 | raises(RespondError), 87 | ) 88 | 89 | 90 | class TestReceiveFile(TestBase): 91 | def test_receives_file_and_calls_transit_complete(self, mocker): 92 | dest_file = mocker.Mock(id=13) 93 | dest_file.name = "test_file" 94 | self.file_receiver.open.return_value = defer.Deferred() 95 | self.file_receiver.receive.return_value = defer.Deferred() 96 | self.file_receiver.send_ack.return_value = defer.Deferred() 97 | receive_finished_handler = mocker.Mock() 98 | 99 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, self.delegate) 100 | transit_receiver.receive_file(dest_file, receive_finished_handler) 101 | 102 | self.file_receiver.open.return_value.callback(None) 103 | self.file_receiver.receive.return_value.callback("1234") 104 | self.file_receiver.send_ack.return_value.callback(None) 105 | 106 | self.file_receiver.open.assert_called_once() 107 | self.file_receiver.receive.assert_called_once_with(dest_file, mocker.ANY) 108 | self.file_receiver.send_ack.assert_called_once_with("1234") 109 | dest_file.finalise.assert_called_once() 110 | self.delegate.transit_complete.assert_called_once_with(13, "test_file") 111 | receive_finished_handler.assert_called_once() 112 | 113 | def test_raises_error_if_exception_thrown(self, mocker): 114 | self.file_receiver.open.return_value = defer.Deferred() 115 | receive_finished_handler = mocker.Mock() 116 | 117 | transit_receiver = TransitProtocolReceiver(None, self.wormhole, self.delegate) 118 | transit_receiver.receive_file(mocker.Mock(), receive_finished_handler) 119 | 120 | self.file_receiver.open.return_value.errback(Exception("Error")) 121 | 122 | self.delegate.transit_complete.assert_not_called() 123 | kwargs = self.delegate.transit_error.call_args[1] 124 | assert_that(kwargs["exception"], is_(Exception)) 125 | assert_that(kwargs["traceback"], starts_with("Traceback")) 126 | receive_finished_handler.assert_called_once() 127 | -------------------------------------------------------------------------------- /tests/test_transit_protocol_sender.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, is_, starts_with 2 | import pytest 3 | from twisted.internet import defer 4 | 5 | from wormhole_ui.errors import SendFileError 6 | from wormhole_ui.protocol.transit.transit_protocol_sender import TransitProtocolSender 7 | 8 | 9 | class TestBase: 10 | @pytest.fixture(autouse=True) 11 | def setup(self, mocker): 12 | self.transit = mocker.patch( 13 | "wormhole_ui.protocol.transit.transit_protocol_sender.TransitSender" 14 | )() 15 | self.file_sender = mocker.patch( 16 | "wormhole_ui.protocol.transit.transit_protocol_sender.FileSender" 17 | )() 18 | self.wormhole = mocker.Mock() 19 | self.delegate = mocker.Mock() 20 | 21 | 22 | class TestSendTransit(TestBase): 23 | def test_sends_transit(self, mocker): 24 | self.transit.get_connection_abilities.return_value = "abilities" 25 | self.transit.get_connection_hints.return_value = defer.Deferred() 26 | 27 | transit_sender = TransitProtocolSender(None, self.wormhole, None) 28 | transit_sender.send_transit() 29 | self.transit.get_connection_hints.return_value.callback("hints") 30 | 31 | self.wormhole.send_message.assert_called_once_with( 32 | b'{"transit": {"abilities-v1": "abilities", "hints-v1": "hints"}}', 33 | ) 34 | 35 | def test_emits_transit_error_on_exception(self, mocker): 36 | self.transit.get_connection_abilities.return_value = "abilities" 37 | self.transit.get_connection_hints.return_value = defer.Deferred() 38 | 39 | transit_sender = TransitProtocolSender(None, self.wormhole, self.delegate) 40 | transit_sender.send_transit() 41 | self.transit.get_connection_hints.return_value.errback(Exception("Error")) 42 | 43 | self.delegate.transit_error.assert_called_once() 44 | kwargs = self.delegate.transit_error.call_args[1] 45 | assert_that(kwargs["exception"], is_(Exception)) 46 | assert_that(kwargs["traceback"], starts_with("Traceback")) 47 | 48 | 49 | class TestHandleTransit(TestBase): 50 | def test_adds_hints(self, mocker): 51 | transit_sender = TransitProtocolSender(None, self.wormhole, None) 52 | transit_sender.handle_transit({"hints-v1": "received_hints"}) 53 | 54 | self.transit.add_connection_hints.assert_called_once_with("received_hints") 55 | 56 | def test_sets_key(self, mocker): 57 | self.transit.TRANSIT_KEY_LENGTH = 128 58 | self.wormhole.derive_key.return_value = mocker.sentinel.key 59 | 60 | transit_sender = TransitProtocolSender(None, self.wormhole, None) 61 | transit_sender.handle_transit({}) 62 | 63 | self.wormhole.derive_key.assert_called_once_with( 64 | "lothar.com/wormhole/text-or-file-xfer/transit-key", 128 65 | ) 66 | self.transit.set_transit_key.assert_called_once_with(mocker.sentinel.key) 67 | 68 | 69 | class TestSendOffer(TestBase): 70 | def test_offer_is_sent(self, mocker): 71 | source_file = mocker.Mock(final_bytes=42) 72 | source_file.name = "test_file" 73 | 74 | transit_sender = TransitProtocolSender(None, self.wormhole, None) 75 | transit_sender.send_offer(source_file) 76 | 77 | self.wormhole.send_message.assert_called_with( 78 | b'{"offer": {"file": {"filename": "test_file", "filesize": 42}}}', 79 | ) 80 | 81 | 82 | class TestHandleFileAck(TestBase): 83 | def test_sends_file_and_calls_transit_complete(self, mocker): 84 | source_file = mocker.Mock(id=13, final_bytes=42) 85 | source_file.name = "test_file" 86 | self.file_sender.open.return_value = defer.Deferred() 87 | self.file_sender.send.return_value = defer.Deferred() 88 | self.file_sender.wait_for_ack.return_value = defer.Deferred() 89 | send_finished_handler = mocker.Mock() 90 | 91 | transit_sender = TransitProtocolSender(None, self.wormhole, self.delegate) 92 | transit_sender.send_file(source_file, send_finished_handler) 93 | 94 | self.file_sender.open.return_value.callback(None) 95 | self.file_sender.send.return_value.callback("1234") 96 | self.file_sender.wait_for_ack.return_value.callback("1234") 97 | 98 | self.file_sender.open.assert_called_once() 99 | self.file_sender.send.assert_called_once_with(source_file, mocker.ANY) 100 | self.file_sender.wait_for_ack.assert_called_once() 101 | self.delegate.transit_complete.assert_called_once_with(13, "test_file") 102 | self.delegate.transit_error.assert_not_called() 103 | send_finished_handler.assert_called_once() 104 | 105 | def test_raises_error_on_hash_mismatch(self, mocker): 106 | source_file = mocker.Mock(id=13, final_bytes=42) 107 | source_file.name = "test_file" 108 | self.file_sender.send.return_value = "4321" 109 | self.file_sender.wait_for_ack.return_value = "1234" 110 | send_finished_handler = mocker.Mock() 111 | 112 | transit_sender = TransitProtocolSender(None, self.wormhole, self.delegate) 113 | transit_sender.send_file(source_file, send_finished_handler) 114 | 115 | self.delegate.transit_complete.assert_not_called() 116 | kwargs = self.delegate.transit_error.call_args[1] 117 | assert_that(kwargs["exception"], is_(SendFileError)) 118 | assert_that(kwargs["traceback"], starts_with("Traceback")) 119 | send_finished_handler.assert_called_once() 120 | 121 | def test_doesnt_raise_error_if_hash_missing(self, mocker): 122 | source_file = mocker.Mock(id=13, final_bytes=42) 123 | source_file.name = "test_file" 124 | self.file_sender.send.return_value = "4321" 125 | self.file_sender.wait_for_ack.return_value = None 126 | send_finished_handler = mocker.Mock() 127 | 128 | transit_sender = TransitProtocolSender(None, self.wormhole, self.delegate) 129 | transit_sender.send_file(source_file, send_finished_handler) 130 | 131 | self.delegate.transit_complete.assert_called_once_with(13, "test_file") 132 | self.delegate.transit_error.assert_not_called() 133 | send_finished_handler.assert_called_once() 134 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = 4 | py36 5 | py37 6 | 7 | [testenv] 8 | whitelist_externals = poetry 9 | skip_install = true 10 | commands = 11 | poetry install -v 12 | pytest --cov=wormhole_ui 13 | flake8 14 | black --check --diff . 15 | ; mypy . 16 | -------------------------------------------------------------------------------- /wormhole_ui/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.0" 2 | -------------------------------------------------------------------------------- /wormhole_ui/errors.py: -------------------------------------------------------------------------------- 1 | class WormholeGuiError(Exception): 2 | pass 3 | 4 | 5 | class RespondError(WormholeGuiError): 6 | """Error that needs to be signalled across the wormhole""" 7 | 8 | def __init__(self, cause): 9 | self.cause = cause 10 | 11 | 12 | class RemoteError(WormholeGuiError): 13 | """Error that was signaled from the other end of the wormhole""" 14 | 15 | pass 16 | 17 | 18 | class SendTextError(WormholeGuiError): 19 | """Other side sent message_ack not ok""" 20 | 21 | pass 22 | 23 | 24 | class SendFileError(WormholeGuiError): 25 | """Other side sent file_ack not ok""" 26 | 27 | pass 28 | 29 | 30 | class ReceiveFileError(WormholeGuiError): 31 | """Transit connection closed before full file was received""" 32 | 33 | pass 34 | 35 | 36 | class MessageError(WormholeGuiError): 37 | """Invalid message received""" 38 | 39 | pass 40 | 41 | 42 | class OfferError(WormholeGuiError): 43 | """Invalid offer received""" 44 | 45 | pass 46 | 47 | 48 | class DiskSpaceError(WormholeGuiError): 49 | """Couldn't receive a file due to low disk space""" 50 | 51 | pass 52 | 53 | 54 | class RefusedError(WormholeGuiError): 55 | """The file transfer was refused""" 56 | 57 | pass 58 | -------------------------------------------------------------------------------- /wormhole_ui/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from PySide2 import QtCore, QtGui 5 | from PySide2.QtWidgets import QApplication 6 | import qt5reactor 7 | import twisted.internet 8 | 9 | from .util import get_icon_path 10 | 11 | # Fix for pyinstaller packages app to avoid ReactorAlreadyInstalledError 12 | # See https://github.com/kivy/kivy/issues/4182 and 13 | # https://github.com/pyinstaller/pyinstaller/issues/3390 14 | if "twisted.internet.reactor" in sys.modules: 15 | del sys.modules["twisted.internet.reactor"] 16 | 17 | # Importing readline (in the wormhole dependency) after initialising QApplication 18 | # causes segfault in Ubuntu. Importing it here to workaround this. 19 | try: 20 | import readline # noqa: F401, E402 21 | except ImportError: 22 | pass 23 | 24 | QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) 25 | QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) 26 | 27 | QApplication([]) 28 | qt5reactor.install() 29 | 30 | from .widgets.main_window import MainWindow # noqa: E402 31 | from .protocol import WormholeProtocol # noqa: E402 32 | 33 | 34 | def run(): 35 | logging.basicConfig(level=logging.INFO) 36 | QApplication.setWindowIcon(QtGui.QIcon(get_icon_path())) 37 | 38 | reactor = twisted.internet.reactor 39 | wormhole = WormholeProtocol(reactor) 40 | main_window = MainWindow(wormhole) 41 | main_window.run() 42 | 43 | sys.exit(reactor.run()) 44 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | from .wormhole_protocol import WormholeProtocol 2 | 3 | __all__ = [WormholeProtocol] 4 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/file_transfer_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import traceback 4 | 5 | from PySide2.QtCore import QObject, Slot 6 | import wormhole 7 | from wormhole.cli import public_relay 8 | from wormhole.errors import LonelyError 9 | 10 | from ..errors import ( 11 | MessageError, 12 | RefusedError, 13 | RemoteError, 14 | RespondError, 15 | SendFileError, 16 | SendTextError, 17 | ) 18 | from .timeout import Timeout 19 | from .transit import TransitProtocolPair 20 | 21 | TIMEOUT_SECONDS = 2 22 | APPID = "lothar.com/wormhole/text-or-file-xfer" 23 | 24 | 25 | class FileTransferProtocol(QObject): 26 | def __init__(self, reactor, signals): 27 | self._reactor = reactor 28 | self._wormhole = None 29 | self._is_wormhole_connected = False 30 | self._transit = None 31 | self._peer_versions = {} 32 | self._wormhole_delegate = WormholeDelegate(signals, self._handle_message) 33 | self._transit_delegate = TransitDelegate(signals) 34 | self._timeout = Timeout(reactor, TIMEOUT_SECONDS) 35 | 36 | self._signals = signals 37 | self._signals.versions_received.connect(self._on_versions_received) 38 | self._signals.wormhole_open.connect(self._on_wormhole_open) 39 | self._signals.wormhole_closed.connect(self._on_wormhole_closed) 40 | self._signals.file_transfer_complete.connect(self._on_file_transfer_complete) 41 | self._signals.respond_error.connect(self._on_respond_error) 42 | 43 | def open(self, code): 44 | logging.debug("open wormhole") 45 | assert self._wormhole is None 46 | 47 | self._wormhole = wormhole.create( 48 | appid=APPID, 49 | relay_url=public_relay.RENDEZVOUS_RELAY, 50 | reactor=self._reactor, 51 | delegate=self._wormhole_delegate, 52 | versions={"v0": {"mode": "connect"}}, 53 | ) 54 | 55 | self._transit = TransitProtocolPair( 56 | self._reactor, self._wormhole, self._transit_delegate 57 | ) 58 | 59 | if code is None or code == "": 60 | self._wormhole.allocate_code() 61 | else: 62 | self._wormhole.set_code(code) 63 | 64 | def close(self): 65 | logging.debug("close wormhole") 66 | if self._wormhole is None: 67 | self._signals.wormhole_closed.emit() 68 | else: 69 | self._transit.close() 70 | self._wormhole.close() 71 | 72 | def shutdown(self): 73 | logging.debug("shutdown wormhole") 74 | if self._wormhole is None: 75 | self._signals.wormhole_shutdown.emit() 76 | else: 77 | if self._is_wormhole_connected and self._peer_supports_connect_mode(): 78 | self._send_command("shutdown") 79 | 80 | self._wormhole_delegate.shutdown() 81 | self.close() 82 | 83 | @Slot() 84 | def _on_wormhole_open(self): 85 | self._is_wormhole_connected = True 86 | 87 | @Slot() 88 | def _on_wormhole_closed(self): 89 | self._wormhole = None 90 | self._is_wormhole_connected = False 91 | 92 | @Slot(dict) 93 | def _on_versions_received(self, versions): 94 | self._peer_versions = versions 95 | 96 | @Slot(int, str) 97 | def _on_file_transfer_complete(self, id, filename): 98 | if not self._peer_supports_connect_mode(): 99 | self.close() 100 | 101 | @Slot(Exception, str) 102 | def _on_respond_error(self, exception, traceback): 103 | self._send_data({"error": str(exception)}) 104 | if isinstance(exception, RefusedError): 105 | self.close() 106 | else: 107 | self._signals.error.emit(exception, traceback) 108 | 109 | def _peer_supports_connect_mode(self): 110 | if "v0" not in self._peer_versions: 111 | return False 112 | return self._peer_versions["v0"].get("mode") == "connect" 113 | 114 | def send_message(self, message): 115 | self._send_data({"offer": {"message": message}}) 116 | 117 | def _send_command(self, command): 118 | self._send_data({"command": command}) 119 | 120 | def send_file(self, id, file_path): 121 | self._transit.send_file(id, file_path) 122 | 123 | def receive_file(self, id, dest_path): 124 | self._transit.receive_file(id, dest_path) 125 | 126 | def is_sending_file(self): 127 | return self._transit.is_sending_file 128 | 129 | def is_receiving_file(self): 130 | return self._transit.is_receiving_file 131 | 132 | def _send_data(self, data): 133 | assert isinstance(data, dict) 134 | logging.debug(f"Sending: {data}") 135 | self._wormhole.send_message(json.dumps(data).encode("utf-8")) 136 | 137 | def _handle_message(self, data_bytes): 138 | try: 139 | data_string = data_bytes.decode("utf-8") 140 | data = json.loads(data_string) 141 | except json.JSONDecodeError: 142 | raise MessageError(f"Invalid message received: {data_string}") 143 | 144 | if "error" in data: 145 | raise RemoteError(data["error"]) 146 | 147 | for key, contents in data.items(): 148 | if key == "offer": 149 | self._handle_offer(contents) 150 | 151 | elif key == "transit": 152 | self._transit.handle_transit(contents) 153 | 154 | elif key == "command" and contents == "shutdown": 155 | self._signals.wormhole_shutdown_received.emit() 156 | self.close() 157 | 158 | elif key == "answer" and "message_ack" in contents: 159 | result = contents["message_ack"] 160 | is_ok = result == "ok" 161 | self._signals.message_sent.emit(is_ok) 162 | if not is_ok: 163 | raise SendTextError(result) 164 | if not self._peer_supports_connect_mode(): 165 | self.close() 166 | 167 | elif key == "answer" and "file_ack" in contents: 168 | result = contents["file_ack"] 169 | if result == "ok": 170 | self._transit.handle_file_ack() 171 | else: 172 | raise SendFileError(result) 173 | 174 | else: 175 | logging.warning(f"Unexpected data received: {key}: {contents}") 176 | 177 | def _handle_offer(self, offer): 178 | if "message" in offer: 179 | self._send_data({"answer": {"message_ack": "ok"}}) 180 | self._signals.message_received.emit(offer["message"]) 181 | if not self._peer_supports_connect_mode(): 182 | self.close() 183 | else: 184 | dest_file = self._transit.handle_offer(offer) 185 | self._signals.file_receive_pending.emit( 186 | dest_file.name, dest_file.final_bytes 187 | ) 188 | 189 | 190 | class WormholeDelegate: 191 | def __init__(self, signals, message_handler): 192 | self._signals = signals 193 | self._message_handler = message_handler 194 | self._shutting_down = False 195 | 196 | def shutdown(self): 197 | self._shutting_down = True 198 | 199 | def wormhole_got_welcome(self, welcome): 200 | logging.debug(f"wormhole_got_welcome: {welcome}") 201 | 202 | def wormhole_got_code(self, code): 203 | logging.debug(f"wormhole_got_code: {code}") 204 | self._signals.code_received.emit(code) 205 | 206 | def wormhole_got_unverified_key(self, key): 207 | logging.debug(f"wormhole_got_unverified_key: {key}") 208 | 209 | def wormhole_got_verifier(self, verifier): 210 | logging.debug(f"wormhole_got_verifier: {verifier}") 211 | 212 | def wormhole_got_versions(self, versions): 213 | logging.debug(f"wormhole_got_versions: {versions}") 214 | self._signals.versions_received.emit(versions) 215 | self._signals.wormhole_open.emit() 216 | 217 | def wormhole_got_message(self, data): 218 | logging.debug(f"wormhole_got_message: {data}") 219 | try: 220 | self._message_handler(data) 221 | except RespondError as exception: 222 | self._signals.respond_error.emit(exception.cause, traceback.format_exc()) 223 | except Exception as exception: 224 | self._signals.error.emit(exception, traceback.format_exc()) 225 | 226 | def wormhole_closed(self, result): 227 | logging.debug(f"wormhole_closed: {repr(result)}") 228 | if self._shutting_down: 229 | logging.debug("Emit wormhole_shutdown") 230 | self._signals.wormhole_shutdown.emit() 231 | else: 232 | if isinstance(result, LonelyError): 233 | pass 234 | elif isinstance(result, Exception): 235 | self._signals.error.emit(result, None) 236 | self._signals.wormhole_closed.emit() 237 | 238 | 239 | class TransitDelegate: 240 | def __init__(self, signals): 241 | self._signals = signals 242 | 243 | def transit_progress(self, id, transferred_bytes, total_bytes): 244 | self._signals.file_transfer_progress.emit(id, transferred_bytes, total_bytes) 245 | 246 | def transit_complete(self, id, filename): 247 | logging.debug(f"transit_complete: {id}, {filename}") 248 | self._signals.file_transfer_complete.emit(id, filename) 249 | 250 | def transit_error(self, exception, traceback=None): 251 | self._signals.error.emit(exception, traceback) 252 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/timeout.py: -------------------------------------------------------------------------------- 1 | class Timeout: 2 | def __init__(self, reactor, timeout_seconds): 3 | self._reactor = reactor 4 | self._timeout_seconds = timeout_seconds 5 | self._deferred = None 6 | 7 | def start(self, callback, *args, **kwds): 8 | self.stop() 9 | 10 | self._deferred = self._reactor.callLater( 11 | self._timeout_seconds, callback, *args, **kwds 12 | ) 13 | 14 | def stop(self): 15 | if self._deferred is not None: 16 | self._deferred.cancel() 17 | self._deferred = None 18 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/__init__.py: -------------------------------------------------------------------------------- 1 | from .transit_protocol_pair import TransitProtocolPair 2 | 3 | __all__ = [TransitProtocolPair] 4 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/dest_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from ...errors import DiskSpaceError, RespondError 5 | 6 | 7 | class DestFile: 8 | def __init__(self, filename, filesize): 9 | self.id = None 10 | # Path().name is intended to protect us against 11 | # "~/.ssh/authorized_keys" and other attacks 12 | self.name = Path(filename).name 13 | self.full_path = None 14 | self.final_bytes = filesize 15 | self.transfer_bytes = self.final_bytes 16 | self.file_object = None 17 | self._temp_path = None 18 | 19 | def open(self, id, dest_path): 20 | self.id = id 21 | self.full_path = Path(dest_path).resolve() / self.name 22 | self._temp_path = _find_unique_path( 23 | self.full_path.with_suffix(self.full_path.suffix + ".part") 24 | ) 25 | 26 | if not _has_disk_space(self.full_path, self.transfer_bytes): 27 | raise RespondError( 28 | DiskSpaceError( 29 | f"Insufficient free disk space (need {self.transfer_bytes}B)" 30 | ) 31 | ) 32 | 33 | self.file_object = open(self._temp_path, "wb") 34 | 35 | def finalise(self): 36 | self.file_object.close() 37 | 38 | self.full_path = _find_unique_path(self.full_path) 39 | self.name = self.full_path.name 40 | return self._temp_path.rename(self.full_path) 41 | 42 | def cleanup(self): 43 | self.file_object.close() 44 | try: 45 | self._temp_path.unlink() 46 | except Exception: 47 | pass 48 | 49 | 50 | def _find_unique_path(path): 51 | path_attempt = path 52 | count = 1 53 | while path_attempt.exists(): 54 | path_attempt = path.with_suffix(f".{count}" + path.suffix) 55 | count += 1 56 | 57 | return path_attempt 58 | 59 | 60 | def _has_disk_space(target, target_size): 61 | # f_bfree is the blocks available to a root user. It might be more 62 | # accurate to use f_bavail (blocks available to non-root user), but we 63 | # don't know which user is running us, and a lot of installations don't 64 | # bother with reserving extra space for root, so let's just stick to the 65 | # basic (larger) estimate. 66 | try: 67 | s = os.statvfs(target.parent) 68 | return s.f_frsize * s.f_bfree > target_size 69 | except AttributeError: 70 | return True 71 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/file_receiver.py: -------------------------------------------------------------------------------- 1 | from binascii import hexlify 2 | import hashlib 3 | import json 4 | 5 | from twisted.internet import defer 6 | 7 | from ...errors import ReceiveFileError 8 | 9 | 10 | class FileReceiver: 11 | def __init__(self, transit): 12 | self._transit = transit 13 | self._pipe = None 14 | 15 | @defer.inlineCallbacks 16 | def open(self): 17 | if self._pipe is None: 18 | self._pipe = yield self._transit.connect() 19 | 20 | def close(self): 21 | if self._pipe is not None: 22 | self._pipe.close() 23 | self._pipe = None 24 | 25 | @defer.inlineCallbacks 26 | def receive(self, dest_file, progress): 27 | hasher = hashlib.sha256() 28 | received = yield self._pipe.writeToFile( 29 | dest_file.file_object, 30 | dest_file.transfer_bytes, 31 | progress=progress.update, 32 | hasher=hasher.update, 33 | ) 34 | datahash = hasher.digest() 35 | 36 | if received < dest_file.transfer_bytes: 37 | raise ReceiveFileError("Connection dropped before full file received") 38 | assert received == dest_file.transfer_bytes 39 | 40 | return datahash 41 | 42 | @defer.inlineCallbacks 43 | def send_ack(self, datahash): 44 | datahash_hex = hexlify(datahash).decode("ascii") 45 | ack = {"ack": "ok", "sha256": datahash_hex} 46 | ack_bytes = json.dumps(ack).encode("utf-8") 47 | 48 | yield self._pipe.send_record(ack_bytes) 49 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/file_sender.py: -------------------------------------------------------------------------------- 1 | from binascii import hexlify 2 | import hashlib 3 | import json 4 | import logging 5 | 6 | import twisted.internet 7 | from twisted.internet import defer 8 | import twisted.protocols 9 | 10 | from ...errors import SendFileError 11 | 12 | 13 | class FileSender: 14 | def __init__(self, transit): 15 | self._transit = transit 16 | self._pipe = None 17 | 18 | @defer.inlineCallbacks 19 | def open(self): 20 | if self._pipe is None: 21 | self._pipe = yield self._transit.connect() 22 | 23 | def close(self): 24 | if self._pipe is not None: 25 | self._pipe.close() 26 | self._pipe = None 27 | 28 | @defer.inlineCallbacks 29 | def send(self, source_file, progress): 30 | logging.info(f"Sending ({self._pipe.describe()})..") 31 | sender = twisted.protocols.basic.FileSender() 32 | hasher = hashlib.sha256() 33 | 34 | def _update(data): 35 | hasher.update(data) 36 | progress.update(len(data)) 37 | return data 38 | 39 | if source_file.final_bytes > 0: 40 | yield sender.beginFileTransfer( 41 | source_file.file_object, self._pipe, transform=_update 42 | ) 43 | return hexlify(hasher.digest()).decode("ascii") 44 | 45 | @defer.inlineCallbacks 46 | def wait_for_ack(self): 47 | ack_bytes = yield self._pipe.receive_record() 48 | ack = json.loads(ack_bytes.decode("utf-8")) 49 | 50 | ok = ack.get("ack", "") 51 | if ok != "ok": 52 | raise SendFileError(f"Transfer failed: {ack}") 53 | 54 | return ack.get("sha256", None) 55 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/progress.py: -------------------------------------------------------------------------------- 1 | class Progress: 2 | def __init__(self, delegate, id, total_bytes): 3 | self._delegate = delegate 4 | self._id = id 5 | self._total_bytes = total_bytes 6 | self._transferred_bytes = 0 7 | 8 | def update(self, increment_bytes): 9 | self._transferred_bytes += increment_bytes 10 | self._delegate.transit_progress( 11 | self._id, self._transferred_bytes, self._total_bytes 12 | ) 13 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/source_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | class SourceFile: 5 | def __init__(self, id, file_path): 6 | file_path = Path(file_path).resolve() 7 | assert file_path.exists() 8 | 9 | self.id = id 10 | self.name = file_path.name 11 | self.full_path = file_path 12 | self.final_bytes = None 13 | self.transfer_bytes = None 14 | self.file_object = None 15 | 16 | def open(self): 17 | self.file_object = open(self.full_path, "rb") 18 | self.file_object.seek(0, 2) 19 | self.final_bytes = self.file_object.tell() 20 | self.transfer_bytes = self.final_bytes 21 | self.file_object.seek(0, 0) 22 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/transit_protocol_base.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from twisted.internet import defer 5 | 6 | 7 | class TransitProtocolBase: 8 | def __init__(self, wormhole, delegate, transit): 9 | self._wormhole = wormhole 10 | self._delegate = delegate 11 | self._transit = transit 12 | 13 | self._send_transit_deferred = None 14 | 15 | def handle_transit(self, transit_message): 16 | self._add_hints(transit_message) 17 | self._derive_key() 18 | 19 | def send_transit(self): 20 | self._send_transit_deferred = self._send_transit() 21 | self._send_transit_deferred.addErrback(self._on_deferred_error) 22 | 23 | @defer.inlineCallbacks 24 | def _send_transit(self): 25 | our_abilities = self._transit.get_connection_abilities() 26 | our_hints = yield self._transit.get_connection_hints() 27 | our_transit_message = { 28 | "abilities-v1": our_abilities, 29 | "hints-v1": our_hints, 30 | } 31 | self._send_data({"transit": our_transit_message}) 32 | 33 | def _derive_key(self): 34 | # Fixed APPID (see https://github.com/warner/magic-wormhole/issues/339) 35 | BUG339_APPID = "lothar.com/wormhole/text-or-file-xfer" 36 | transit_key = self._wormhole.derive_key( 37 | BUG339_APPID + "/transit-key", self._transit.TRANSIT_KEY_LENGTH 38 | ) 39 | self._transit.set_transit_key(transit_key) 40 | 41 | def _add_hints(self, transit_message): 42 | hints = transit_message.get("hints-v1", []) 43 | if hints: 44 | self._transit.add_connection_hints(hints) 45 | 46 | def _send_data(self, data): 47 | assert isinstance(data, dict) 48 | logging.debug(f"Sending: {data}") 49 | self._wormhole.send_message(json.dumps(data).encode("utf-8")) 50 | 51 | def _on_deferred_error(self, failure): 52 | self._delegate.transit_error( 53 | exception=failure.value, 54 | traceback=failure.getTraceback(elideFrameworkCode=True), 55 | ) 56 | return failure 57 | 58 | def close(self): 59 | if self._send_transit_deferred is not None: 60 | self._send_transit_deferred.cancel() 61 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/transit_protocol_pair.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .source_file import SourceFile 4 | from .transit_protocol_sender import TransitProtocolSender 5 | from .transit_protocol_receiver import TransitProtocolReceiver 6 | 7 | 8 | class TransitProtocolPair: 9 | def __init__(self, reactor, wormhole, delegate): 10 | self._receiver = TransitProtocolReceiver(reactor, wormhole, delegate) 11 | self._sender = TransitProtocolSender(reactor, wormhole, delegate) 12 | 13 | self._source_file = None 14 | self._dest_file = None 15 | 16 | self._send_transit_handshake_complete = False 17 | self._receive_transit_handshake_complete = False 18 | self._awaiting_transit_response = False 19 | self.is_sending_file = False 20 | self.is_receiving_file = False 21 | 22 | def send_file(self, id, file_path): 23 | logging.debug("TransitProtocolPair::send_file") 24 | assert not self.is_sending_file 25 | self.is_sending_file = True 26 | 27 | self._source_file = SourceFile(id, file_path) 28 | self._source_file.open() 29 | 30 | if not self._send_transit_handshake_complete: 31 | self._awaiting_transit_response = True 32 | self._sender.send_transit() 33 | else: 34 | self._sender.send_offer(self._source_file) 35 | 36 | def handle_transit(self, transit_message): 37 | logging.debug("TransitProtocolPair::handle_transit") 38 | 39 | if self._awaiting_transit_response: 40 | # We're waiting for a response, so this is for the sender 41 | assert self.is_sending_file 42 | 43 | if not self._send_transit_handshake_complete: 44 | self._send_transit_handshake_complete = True 45 | self._sender.handle_transit(transit_message) 46 | 47 | self._awaiting_transit_response = False 48 | self._sender.send_offer(self._source_file) 49 | 50 | else: 51 | # We haven't sent a transit message, so this is for the receiver 52 | assert not self.is_receiving_file 53 | 54 | if not self._receive_transit_handshake_complete: 55 | self._receive_transit_handshake_complete = True 56 | self._receiver.handle_transit(transit_message) 57 | 58 | self._receiver.send_transit() 59 | 60 | def handle_file_ack(self): 61 | logging.debug("TransitProtocolPair::handle_file_ack") 62 | assert self.is_sending_file 63 | 64 | def on_send_finished(): 65 | self.is_sending_file = False 66 | self._source_file = None 67 | 68 | self._sender.send_file(self._source_file, on_send_finished) 69 | 70 | def handle_offer(self, offer): 71 | logging.debug("TransitProtocolPair::handle_offer") 72 | assert not self.is_receiving_file 73 | 74 | self._dest_file = self._receiver.handle_offer(offer) 75 | return self._dest_file 76 | 77 | def receive_file(self, id, dest_path): 78 | logging.debug("TransitProtocolPair::receive_file") 79 | assert not self.is_receiving_file 80 | self.is_receiving_file = True 81 | 82 | def on_receive_finished(): 83 | self.is_receiving_file = False 84 | if self._dest_file is not None: 85 | self._dest_file.cleanup() 86 | self._dest_file = None 87 | 88 | self._dest_file.open(id, dest_path) 89 | self._receiver.receive_file(self._dest_file, on_receive_finished) 90 | 91 | def close(self): 92 | self._source_file = None 93 | self._dest_file = None 94 | self._send_transit_handshake_complete = False 95 | self._receive_transit_handshake_complete = False 96 | self._awaiting_transit_response = False 97 | self.is_sending_file = False 98 | self.is_receiving_file = False 99 | 100 | self._sender.close() 101 | self._receiver.close() 102 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/transit_protocol_receiver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from twisted.internet import defer 4 | from wormhole.cli import public_relay 5 | from wormhole.transit import TransitReceiver 6 | 7 | from .dest_file import DestFile 8 | from ...errors import ( 9 | OfferError, 10 | RespondError, 11 | ) 12 | from .file_receiver import FileReceiver 13 | from .progress import Progress 14 | from .transit_protocol_base import TransitProtocolBase 15 | 16 | 17 | class TransitProtocolReceiver(TransitProtocolBase): 18 | def __init__(self, reactor, wormhole, delegate): 19 | transit = TransitReceiver( 20 | transit_relay=public_relay.TRANSIT_RELAY, 21 | reactor=reactor, 22 | ) 23 | super().__init__(wormhole, delegate, transit) 24 | 25 | self._file_receiver = FileReceiver(transit) 26 | self._send_transit_deferred = None 27 | self._receive_file_deferred = None 28 | 29 | def handle_offer(self, offer): 30 | if "file" not in offer: 31 | raise RespondError(OfferError(f"Unknown offer: {offer}")) 32 | 33 | filename = offer["file"]["filename"] 34 | filesize = offer["file"]["filesize"] 35 | return DestFile(filename, filesize) 36 | 37 | def receive_file(self, dest_file, receive_finished_handler): 38 | self._send_data({"answer": {"file_ack": "ok"}}) 39 | 40 | self._receive_file_deferred = self._receive_file(dest_file) 41 | self._receive_file_deferred.addErrback(self._on_deferred_error) 42 | self._receive_file_deferred.addBoth(lambda _: receive_finished_handler()) 43 | 44 | @defer.inlineCallbacks 45 | def _receive_file(self, dest_file): 46 | progress = Progress(self._delegate, dest_file.id, dest_file.transfer_bytes) 47 | 48 | yield self._file_receiver.open() 49 | datahash = yield self._file_receiver.receive(dest_file, progress) 50 | 51 | dest_file.finalise() 52 | yield self._file_receiver.send_ack(datahash) 53 | 54 | logging.info("File received, transfer complete") 55 | self._delegate.transit_complete(dest_file.id, dest_file.name) 56 | 57 | def close(self): 58 | super().close() 59 | 60 | self._file_receiver.close() 61 | if self._send_transit_deferred is not None: 62 | self._send_transit_deferred.cancel() 63 | if self._receive_file_deferred is not None: 64 | self._receive_file_deferred.cancel() 65 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/transit/transit_protocol_sender.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from twisted.internet import defer 4 | from wormhole.cli import public_relay 5 | from wormhole.transit import TransitSender 6 | 7 | from ...errors import SendFileError 8 | from .file_sender import FileSender 9 | from .progress import Progress 10 | from .transit_protocol_base import TransitProtocolBase 11 | 12 | 13 | class TransitProtocolSender(TransitProtocolBase): 14 | def __init__(self, reactor, wormhole, delegate): 15 | transit = TransitSender( 16 | transit_relay=public_relay.TRANSIT_RELAY, 17 | reactor=reactor, 18 | ) 19 | super().__init__(wormhole, delegate, transit) 20 | 21 | self._file_sender = FileSender(transit) 22 | self._send_file_deferred = None 23 | 24 | def send_offer(self, source_file): 25 | self._send_data( 26 | { 27 | "offer": { 28 | "file": { 29 | "filename": source_file.name, 30 | "filesize": source_file.final_bytes, 31 | }, 32 | } 33 | } 34 | ) 35 | 36 | def send_file(self, source_file, send_finished_handler): 37 | self._send_file_deferred = self._send_file(source_file) 38 | self._send_file_deferred.addErrback(self._on_deferred_error) 39 | self._send_file_deferred.addBoth(lambda _: send_finished_handler()) 40 | 41 | @defer.inlineCallbacks 42 | def _send_file(self, source_file): 43 | progress = Progress(self._delegate, source_file.id, source_file.transfer_bytes) 44 | 45 | yield self._file_sender.open() 46 | expected_hash = yield self._file_sender.send(source_file, progress) 47 | 48 | logging.info("File sent, awaiting confirmation") 49 | ack_hash = yield self._file_sender.wait_for_ack() 50 | if ack_hash is not None and ack_hash != expected_hash: 51 | raise SendFileError("Transfer failed (bad remote hash)") 52 | 53 | logging.info("Confirmation received, transfer complete") 54 | self._delegate.transit_complete(source_file.id, source_file.name) 55 | 56 | def close(self): 57 | super().close() 58 | 59 | self._file_sender.close() 60 | if self._send_file_deferred is not None: 61 | self._send_file_deferred.cancel() 62 | -------------------------------------------------------------------------------- /wormhole_ui/protocol/wormhole_protocol.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | from PySide2.QtCore import QObject, Signal, Slot 4 | from twisted.internet.defer import CancelledError 5 | 6 | from ..errors import RefusedError, RespondError 7 | from .file_transfer_protocol import FileTransferProtocol 8 | 9 | 10 | class WormholeSignals(QObject): 11 | code_received = Signal(str) 12 | versions_received = Signal(dict) 13 | wormhole_open = Signal() 14 | wormhole_closed = Signal() 15 | wormhole_shutdown = Signal() 16 | wormhole_shutdown_received = Signal() 17 | message_sent = Signal(bool) 18 | message_received = Signal(str) 19 | file_receive_pending = Signal(str, int) 20 | file_transfer_progress = Signal(int, int, int) 21 | file_transfer_complete = Signal(int, str) 22 | error = Signal(Exception, str) 23 | respond_error = Signal(Exception, str) 24 | 25 | 26 | class WormholeProtocol: 27 | def __init__(self, reactor): 28 | super().__init__() 29 | self.signals = WormholeSignals() 30 | self._protocol = FileTransferProtocol(reactor, self.signals) 31 | 32 | @Slot(str) 33 | def open(self, code=None): 34 | self._capture_errors(self._protocol.open, code) 35 | 36 | @Slot(str) 37 | def set_code(self, code): 38 | @Slot(str) 39 | def open_with_code(): 40 | self.signals.wormhole_closed.disconnect(open_with_code) 41 | self.open(code) 42 | 43 | self.signals.wormhole_closed.connect(open_with_code) 44 | self.close() 45 | 46 | @Slot() 47 | def close(self): 48 | self._capture_errors(self._protocol.close) 49 | 50 | @Slot() 51 | def shutdown(self): 52 | self._capture_errors(self._protocol.shutdown) 53 | 54 | @Slot() 55 | def send_message(self, message): 56 | self._capture_errors(self._protocol.send_message, message) 57 | 58 | @Slot(str, str) 59 | def send_file(self, id, file_path): 60 | self._capture_errors(self._protocol.send_file, id, file_path) 61 | 62 | @Slot(str, str) 63 | def receive_file(self, id, dest_path): 64 | self._capture_errors(self._protocol.receive_file, id, dest_path) 65 | 66 | @Slot() 67 | def reject_file(self): 68 | self.signals.respond_error.emit( 69 | RefusedError("The file was refused by the user"), None 70 | ) 71 | 72 | def is_receiving_file(self): 73 | return self._protocol.is_receiving_file() 74 | 75 | def is_sending_file(self): 76 | return self._protocol.is_sending_file() 77 | 78 | def _capture_errors(self, command, *args, **kwds): 79 | try: 80 | command(*args, **kwds) 81 | except CancelledError: 82 | pass 83 | except RespondError as exception: 84 | self.signals.respond_error.emit(exception.cause, traceback.format.exc()) 85 | except Exception as exception: 86 | self.signals.error.emit(exception, traceback.format_exc()) 87 | -------------------------------------------------------------------------------- /wormhole_ui/resources/README.md: -------------------------------------------------------------------------------- 1 | `check.svg` and `times.svg` are from [FontAwesome](https://fontawesome.com) under the [CC BY 4.0 License](https://creativecommons.org/licenses/by/4.0). -------------------------------------------------------------------------------- /wormhole_ui/resources/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 28 | 49 | 53 | 54 | -------------------------------------------------------------------------------- /wormhole_ui/resources/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 22 | 24 | 27 | 31 | 35 | 36 | 39 | 43 | 47 | 48 | 58 | 62 | 67 | 73 | 78 | 83 | 89 | 94 | 99 | 100 | 108 | 113 | 119 | 124 | 129 | 135 | 136 | 140 | 145 | 151 | 156 | 161 | 167 | 168 | 178 | 179 | 202 | 209 | 210 | 212 | 213 | 215 | image/svg+xml 216 | 218 | 219 | 220 | 221 | 222 | 226 | 233 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /wormhole_ui/resources/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon128.png -------------------------------------------------------------------------------- /wormhole_ui/resources/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon16.png -------------------------------------------------------------------------------- /wormhole_ui/resources/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon24.png -------------------------------------------------------------------------------- /wormhole_ui/resources/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon256.png -------------------------------------------------------------------------------- /wormhole_ui/resources/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon32.png -------------------------------------------------------------------------------- /wormhole_ui/resources/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon48.png -------------------------------------------------------------------------------- /wormhole_ui/resources/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/icon64.png -------------------------------------------------------------------------------- /wormhole_ui/resources/times.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 52 | 57 | 58 | -------------------------------------------------------------------------------- /wormhole_ui/resources/wormhole.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/wormhole.icns -------------------------------------------------------------------------------- /wormhole_ui/resources/wormhole.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneakypete81/wormhole-ui/5859a0ea5f79ed9d317082440282196351ae65e1/wormhole_ui/resources/wormhole.ico -------------------------------------------------------------------------------- /wormhole_ui/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | SHELL_FOLDERS = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders" 5 | DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}" 6 | RESOURCES_PATH = Path(__file__).parent / "resources" 7 | 8 | 9 | def get_download_path_or_cwd(): 10 | download_path = get_download_path() 11 | if download_path is None or not Path(download_path).exists(): 12 | return Path.cwd() 13 | else: 14 | return download_path.resolve() 15 | 16 | 17 | def get_download_path(): 18 | if os.name == "nt": 19 | try: 20 | import winreg 21 | 22 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, SHELL_FOLDERS) as key: 23 | return Path(winreg.QueryValueEx(key, DOWNLOADS_GUID)[0]) 24 | except Exception: 25 | return None 26 | else: 27 | return Path.home() / "Downloads" 28 | 29 | 30 | def get_icon_path(): 31 | if os.name == "darwin": 32 | return str(RESOURCES_PATH / "wormhole.icns") 33 | else: 34 | return str(RESOURCES_PATH / "wormhole.ico") 35 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/connect_dialog.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from PySide2.QtCore import Slot 4 | from PySide2.QtWidgets import QDialog 5 | 6 | from .ui_dialog import UiDialog 7 | 8 | 9 | class ConnectDialog(UiDialog): 10 | def __init__(self, parent, wormhole): 11 | super().__init__(parent, "ConnectDialog.ui") 12 | 13 | self.wormhole = wormhole 14 | self.code = None 15 | 16 | self.set_code_button.clicked.connect(self._on_set_code_button) 17 | self.quit_button.clicked.connect(self.reject) 18 | 19 | # MacOS requires a 'Quit' button, since there's no native way of closing a 20 | # sheet. https://forum.qt.io/topic/27182/solved-qdialog-mac-os-setwindowflags 21 | # has another possible solution. 22 | if platform.system() != "Darwin": 23 | self.quit_button.hide() 24 | 25 | wormhole.signals.wormhole_open.connect(self._on_wormhole_open) 26 | wormhole.signals.code_received.connect(self._on_code_received) 27 | wormhole.signals.error.connect(self._on_error) 28 | wormhole.signals.wormhole_closed.connect(self._on_wormhole_closed) 29 | 30 | def open(self): 31 | self._request_new_code() 32 | super().open() 33 | 34 | @Slot() 35 | def _on_wormhole_open(self): 36 | """ 37 | Close the dialog if the wormhole is opened successfully. 38 | """ 39 | self.accept() 40 | 41 | @Slot() 42 | def _on_wormhole_closed(self): 43 | """ 44 | Open the dialog and attempt to repen the wormhole if the wormhole is closed. 45 | Do nothing if the dialog was manually closed. 46 | """ 47 | if self.result() != QDialog.Rejected: 48 | self.open() 49 | 50 | @Slot(str) 51 | def _on_code_received(self, code): 52 | self.set_code_button.setEnabled(True) 53 | self.code = code[:100] 54 | self._refresh() 55 | 56 | @Slot() 57 | def _on_set_code_button(self): 58 | self.set_code_button.setDisabled(True) 59 | self.wormhole.set_code(self.code_edit.text().strip()) 60 | 61 | @Slot(Exception, str) 62 | def _on_error(self, exception, traceback): 63 | self.set_code_button.setEnabled(True) 64 | 65 | def _request_new_code(self): 66 | # Don't allow code to be changed until one has been allocated 67 | self.code_edit.setText("") 68 | self.set_code_button.setDisabled(True) 69 | 70 | self.code = None 71 | self._refresh() 72 | self.wormhole.open() 73 | 74 | def _refresh(self): 75 | code = self.code 76 | if code is None: 77 | code = "[obtaining code...]" 78 | self.code_label.setText(code) 79 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/errors.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.error import ConnectionClosed 2 | 3 | from wormhole.errors import ServerConnectionError 4 | 5 | from ..errors import RemoteError 6 | 7 | 8 | EXCEPTION_CLASS_MAP = { 9 | ServerConnectionError: "Could not connect to the Magic Wormhole server", 10 | ConnectionClosed: "The wormhole connection has closed", 11 | } 12 | 13 | 14 | EXCEPTION_MESSAGE_MAP = { 15 | "Exception: Consumer asked us to stop producing": ( 16 | "The wormhole connection has closed" 17 | ) 18 | } 19 | 20 | 21 | def get_error_text(exception): 22 | exception_message = f"{exception.__class__.__name__}: {exception}" 23 | if exception_message in EXCEPTION_MESSAGE_MAP: 24 | return EXCEPTION_MESSAGE_MAP[exception_message] 25 | if exception.__class__ in EXCEPTION_CLASS_MAP: 26 | return EXCEPTION_CLASS_MAP[exception.__class__] 27 | if exception.__class__ == RemoteError: 28 | return str(exception) 29 | 30 | return exception_message 31 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/main_window.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import platform 3 | 4 | from PySide2.QtCore import Slot 5 | from PySide2.QtWidgets import ( 6 | QApplication, 7 | QDialog, 8 | QFileDialog, 9 | QMainWindow, 10 | ) 11 | 12 | from .connect_dialog import ConnectDialog 13 | from .errors import get_error_text 14 | from .message_table import MessageTable 15 | from .save_file_dialog import SaveFileDialog 16 | from .shutdown_message import ShutdownMessage 17 | from .ui import CustomWidget, load_ui 18 | 19 | WIN_STYLESHEET = """ 20 | * { 21 | font-family: "Calibri"; 22 | font-size: 12pt; 23 | } 24 | QPushButton { 25 | padding-top: 4px; 26 | padding-bottom: 4px; 27 | padding-left: 15px; 28 | padding-right: 15px; 29 | } 30 | """ 31 | 32 | 33 | class MainWindow(QMainWindow): 34 | def __init__(self, wormhole): 35 | super().__init__() 36 | load_ui( 37 | "MainWindow.ui", 38 | base_instance=self, 39 | custom_widgets=[CustomWidget(MessageTable, wormhole=wormhole)], 40 | ) 41 | 42 | if platform.system() == "Windows": 43 | self.setStyleSheet(WIN_STYLESHEET) 44 | 45 | self.wormhole = wormhole 46 | 47 | self._hide_error() 48 | self.show() 49 | 50 | def run(self): 51 | self.connect_dialog = ConnectDialog(self, self.wormhole) 52 | self.save_file_dialog = SaveFileDialog(self) 53 | 54 | self.message_edit.returnPressed.connect(self.send_message_button.clicked) 55 | self.send_message_button.clicked.connect(self._on_send_message_button) 56 | self.send_files_button.clicked.connect(self._on_send_files_button) 57 | self.message_table.send_file.connect(self._on_send_file) 58 | 59 | self.connect_dialog.rejected.connect(self.close) 60 | 61 | self.save_file_dialog.finished.connect(self._on_save_file_dialog_finished) 62 | 63 | s = self.wormhole.signals 64 | s.wormhole_open.connect(self._hide_error) 65 | s.message_sent.connect(self._on_message_sent) 66 | s.message_received.connect(self._on_message_received) 67 | s.file_receive_pending.connect(self._on_file_receive_pending) 68 | s.file_transfer_progress.connect(self._on_file_transfer_progress) 69 | s.file_transfer_complete.connect(self._on_file_transfer_complete) 70 | s.error.connect(self._on_error) 71 | s.wormhole_shutdown_received.connect(self._on_wormhole_shutdown_received) 72 | s.wormhole_shutdown.connect(QApplication.quit) 73 | 74 | self.connect_dialog.open() 75 | 76 | def closeEvent(self, event): 77 | self.wormhole.signals.error.disconnect(self._on_error) 78 | self.wormhole.shutdown() 79 | 80 | @Slot() 81 | def _on_send_message_button(self): 82 | self._disable_message_entry() 83 | self.wormhole.send_message(self.message_edit.text()) 84 | 85 | @Slot() 86 | def _on_send_files_button(self): 87 | dialog = QFileDialog(self, "Send") 88 | dialog.setFileMode(QFileDialog.ExistingFiles) 89 | dialog.filesSelected.connect(self._on_send_files_selected) 90 | dialog.open() 91 | 92 | @Slot(str) 93 | def _on_send_files_selected(self, filepaths): 94 | for filepath in filepaths: 95 | self.message_table.send_file_pending(filepath) 96 | 97 | @Slot(int, str) 98 | def _on_send_file(self, id, filepath): 99 | self.wormhole.send_file(id, filepath) 100 | 101 | @Slot() 102 | def _on_message_sent(self, success): 103 | self._enable_message_entry() 104 | if success: 105 | message = self.message_edit.text() 106 | self.message_table.add_sent_message(message) 107 | self.message_edit.clear() 108 | 109 | @Slot(str) 110 | def _on_message_received(self, message): 111 | self.message_table.add_received_message(message) 112 | 113 | @Slot(str, int) 114 | def _on_file_receive_pending(self, filename, size): 115 | self.save_file_dialog.open(filename, size) 116 | 117 | @Slot(int) 118 | def _on_save_file_dialog_finished(self, result): 119 | if result == QDialog.Accepted: 120 | id = self.message_table.receiving_file(self.save_file_dialog.filename) 121 | self.wormhole.receive_file( 122 | id, self.save_file_dialog.get_destination_directory() 123 | ) 124 | else: 125 | self.wormhole.reject_file() 126 | 127 | @Slot(int, int, int) 128 | def _on_file_transfer_progress(self, id, transferred_bytes, total_bytes): 129 | self.message_table.transfer_progress(id, transferred_bytes, total_bytes) 130 | 131 | @Slot(int, str) 132 | def _on_file_transfer_complete(self, id, filename): 133 | self.message_table.transfer_complete(id, filename) 134 | 135 | @Slot(Exception, str) 136 | def _on_error(self, exception, traceback): 137 | logging.error(f"Caught Exception: {repr(exception)}") 138 | if traceback: 139 | logging.error(f"Traceback: {traceback}") 140 | 141 | self.message_table.transfers_failed() 142 | 143 | self.error_label.setText(get_error_text(exception)) 144 | self.error_label.show() 145 | 146 | self.wormhole.close() 147 | 148 | @Slot() 149 | def _hide_error(self): 150 | self.error_label.hide() 151 | 152 | @Slot() 153 | def _on_wormhole_shutdown_received(self): 154 | ShutdownMessage(parent=self).exec_() 155 | 156 | def _disable_message_entry(self): 157 | self.message_edit.setDisabled(True) 158 | self.send_message_button.setDisabled(True) 159 | 160 | def _enable_message_entry(self): 161 | self.message_edit.setEnabled(True) 162 | self.send_message_button.setEnabled(True) 163 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/message_table.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from pathlib import Path 3 | 4 | from PySide2.QtCore import Qt, Signal 5 | from PySide2.QtWidgets import ( 6 | QHeaderView, 7 | QHBoxLayout, 8 | QProgressBar, 9 | QTableWidget, 10 | QTableWidgetItem, 11 | QWidget, 12 | ) 13 | from PySide2.QtSvg import QSvgWidget 14 | 15 | from ..util import RESOURCES_PATH 16 | 17 | ICON_COLUMN = 0 18 | TEXT_COLUMN = 1 19 | ICON_COLUMN_WIDTH = 32 20 | 21 | 22 | class MessageTable(QTableWidget): 23 | send_file = Signal(int, str) 24 | 25 | def __init__(self, parent, wormhole): 26 | super().__init__(parent=parent) 27 | self.setAcceptDrops(True) 28 | self.setFocusPolicy(Qt.NoFocus) 29 | 30 | self._send_files_pending = OrderedDict() 31 | self._wormhole = wormhole 32 | 33 | self._setup_columns() 34 | 35 | def _setup_columns(self): 36 | self.setColumnCount(2) 37 | header = self.horizontalHeader() 38 | header.setSectionResizeMode(ICON_COLUMN, QHeaderView.Fixed) 39 | header.setSectionResizeMode(TEXT_COLUMN, QHeaderView.Stretch) 40 | header.resizeSection(ICON_COLUMN, ICON_COLUMN_WIDTH) 41 | 42 | def add_sent_message(self, message): 43 | self._append_item(SendItem(f"Sent: {message}")) 44 | 45 | def add_received_message(self, message): 46 | self._append_item(ReceiveItem(message)) 47 | 48 | def send_file_pending(self, filepath): 49 | id = self.rowCount() 50 | self._send_files_pending[id] = filepath 51 | self._append_item(SendFile(Path(filepath).name)) 52 | self._draw_progress(id, 0) 53 | 54 | if not self._wormhole.is_sending_file(): 55 | self._send_next_file() 56 | 57 | return id 58 | 59 | def receiving_file(self, filepath): 60 | id = self.rowCount() 61 | item = ReceiveFile(Path(filepath).name) 62 | item.transfer_started() 63 | self._append_item(item) 64 | self._draw_progress(id, 0) 65 | 66 | return id 67 | 68 | def transfer_progress(self, id, transferred_bytes, total_bytes): 69 | if total_bytes == 0: 70 | percent = 100 71 | else: 72 | percent = (100 * transferred_bytes) // total_bytes 73 | self._draw_progress(id, percent) 74 | 75 | def transfer_complete(self, id, filename): 76 | self.item(id, TEXT_COLUMN).transfer_complete(filename) 77 | self._draw_icon(id, "check.svg") 78 | 79 | if not self._wormhole.is_sending_file(): 80 | self._send_next_file() 81 | 82 | def transfers_failed(self): 83 | for id in range(self.rowCount()): 84 | item = self.item(id, TEXT_COLUMN) 85 | if item.in_progress: 86 | item.transfer_failed() 87 | self._draw_icon(id, "times.svg") 88 | 89 | def _send_next_file(self): 90 | if self._send_files_pending: 91 | id, filepath = self._send_files_pending.popitem(last=False) 92 | self.item(id, TEXT_COLUMN).transfer_started() 93 | self.send_file.emit(id, filepath) 94 | 95 | def _append_item(self, item): 96 | item.setFlags(Qt.ItemIsEnabled) 97 | id = self.rowCount() 98 | self.insertRow(id) 99 | self.setItem(id, TEXT_COLUMN, item) 100 | self.resizeRowsToContents() 101 | 102 | def _draw_progress(self, id, percent): 103 | if self.cellWidget(id, ICON_COLUMN) is None: 104 | bar = QProgressBar() 105 | bar.setTextVisible(False) 106 | bar.setFixedSize(ICON_COLUMN_WIDTH, self.rowHeight(id)) 107 | 108 | self.setCellWidget(id, ICON_COLUMN, bar) 109 | 110 | if isinstance(self.cellWidget(id, ICON_COLUMN), QProgressBar): 111 | self.cellWidget(id, ICON_COLUMN).setValue(percent) 112 | 113 | def _draw_icon(self, id, svg_filename): 114 | svg = QSvgWidget(str(RESOURCES_PATH / svg_filename)) 115 | height = self.cellWidget(id, ICON_COLUMN).size().height() 116 | svg.setFixedSize(height, height) 117 | 118 | container = QWidget() 119 | layout = QHBoxLayout(container) 120 | layout.addWidget(svg) 121 | layout.setAlignment(Qt.AlignCenter) 122 | layout.setContentsMargins(0, 0, 0, 0) 123 | container.setLayout(layout) 124 | 125 | self.setCellWidget(id, ICON_COLUMN, container) 126 | 127 | def dragEnterEvent(self, event): 128 | if event.mimeData().hasUrls(): 129 | self.setStyleSheet("background-color: rgba(51, 153, 255, 0.2);") 130 | event.accept() 131 | 132 | def dragLeaveEvent(self, event): 133 | self.setStyleSheet("") 134 | event.accept() 135 | 136 | def dragMoveEvent(self, event): 137 | if event.mimeData().hasUrls: 138 | event.setDropAction(Qt.CopyAction) 139 | event.accept() 140 | 141 | def dropEvent(self, event): 142 | self.setStyleSheet("") 143 | if event.mimeData().hasUrls: 144 | event.setDropAction(Qt.CopyAction) 145 | event.accept() 146 | 147 | for url in event.mimeData().urls(): 148 | self.send_file_pending(url.toLocalFile()) 149 | 150 | 151 | class ReceiveItem(QTableWidgetItem): 152 | def __init__(self, message): 153 | super().__init__(message) 154 | self.in_progress = False 155 | 156 | 157 | class SendItem(QTableWidgetItem): 158 | def __init__(self, message): 159 | super().__init__(message) 160 | self.in_progress = False 161 | 162 | font = self.font() 163 | font.setItalic(True) 164 | self.setFont(font) 165 | 166 | 167 | class ReceiveFile(ReceiveItem): 168 | def __init__(self, filename): 169 | self.in_progress = False 170 | self._filename = filename 171 | super().__init__(f"Queued: {self._filename}...") 172 | 173 | def transfer_started(self): 174 | self.in_progress = True 175 | self.setText(f"Receiving: {self._filename}...") 176 | 177 | def transfer_complete(self, filename): 178 | self.in_progress = False 179 | self._filename = filename 180 | self.setText(f"Received: {filename}") 181 | 182 | def transfer_failed(self): 183 | self.in_progress = False 184 | self.setText(f"Failed to receive {self._filename}") 185 | 186 | 187 | class SendFile(SendItem): 188 | def __init__(self, filename): 189 | self.in_progress = False 190 | self._filename = filename 191 | super().__init__(f"Queued: {self._filename}...") 192 | 193 | def transfer_started(self): 194 | self.in_progress = True 195 | self.setText(f"Sending: {self._filename}...") 196 | 197 | def transfer_complete(self, filename): 198 | self.in_progress = False 199 | self._filename = filename 200 | self.setText(f"Sent: {filename}") 201 | 202 | def transfer_failed(self): 203 | self.in_progress = False 204 | self.setText(f"Failed to send {self._filename}") 205 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/save_file_dialog.py: -------------------------------------------------------------------------------- 1 | from humanize import naturalsize 2 | from PySide2.QtCore import Slot 3 | from PySide2.QtWidgets import QDialog, QFileDialog 4 | 5 | from .ui_dialog import UiDialog 6 | from ..util import get_download_path_or_cwd 7 | 8 | 9 | class SaveFileDialog(UiDialog): 10 | def __init__(self, parent): 11 | super().__init__(parent, "SaveFile.ui") 12 | 13 | self.filename = None 14 | self.destination_edit.setText(str(get_download_path_or_cwd())) 15 | 16 | self.browse_button.clicked.connect(self._on_browse_button) 17 | self.button_box.accepted.connect(self.accept) 18 | self.button_box.rejected.connect(self.reject) 19 | 20 | def open(self, filename, size): 21 | self.filename = filename 22 | 23 | if self.remember_checkbox.isChecked(): 24 | self.finished.emit(QDialog.Accepted) 25 | return 26 | 27 | truncated_filename = self.truncate(filename) 28 | self.filename_label.setText(f"{truncated_filename} [{naturalsize(size)}]") 29 | super().open() 30 | 31 | def get_destination_directory(self): 32 | return self.destination_edit.text() 33 | 34 | @Slot() 35 | def _on_browse_button(self): 36 | directory = QFileDialog.getExistingDirectory( 37 | self, "Download Location", self.destination_edit.text() 38 | ) 39 | if directory != "": 40 | self.destination_edit.setText(directory) 41 | 42 | @staticmethod 43 | def truncate(filename, max_chars=40): 44 | if len(filename) <= max_chars: 45 | return filename 46 | 47 | stem, suffixes = filename.split(".", maxsplit=1) 48 | return stem[: max_chars - 3] + "..." + suffixes 49 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/shutdown_message.py: -------------------------------------------------------------------------------- 1 | from PySide2.QtWidgets import QMessageBox 2 | 3 | MIN_WIDTH = 450 4 | MIN_HEIGHT = 120 5 | 6 | 7 | class ShutdownMessage(QMessageBox): 8 | def exec_(self): 9 | self.setText("The remote computer has closed the connection.") 10 | self.setIcon(QMessageBox.Information) 11 | self.setStandardButtons(QMessageBox.Close) 12 | self.setDefaultButton(QMessageBox.Close) 13 | super().exec_() 14 | 15 | def resizeEvent(self, event): 16 | super().resizeEvent(event) 17 | if self.width() < MIN_WIDTH: 18 | self.setFixedWidth(MIN_WIDTH) 19 | if self.height() < MIN_HEIGHT: 20 | self.setFixedHeight(MIN_HEIGHT) 21 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/ui/ConnectDialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 600 10 | 200 11 | 12 | 13 | 14 | Magic Wormhole - Connect 15 | 16 | 17 | 18 | 19 | 20 | Qt::Vertical 21 | 22 | 23 | 24 | 20 25 | 40 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 0 35 | 0 36 | 37 | 38 | 39 | font-size: 20pt 40 | 41 | 42 | QFrame::StyledPanel 43 | 44 | 45 | QFrame::Sunken 46 | 47 | 48 | [code] 49 | 50 | 51 | Qt::PlainText 52 | 53 | 54 | false 55 | 56 | 57 | Qt::AlignHCenter|Qt::AlignTop 58 | 59 | 60 | Qt::TextSelectableByMouse 61 | 62 | 63 | 64 | 65 | 66 | 67 | Qt::Vertical 68 | 69 | 70 | 71 | 20 72 | 40 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | To connect, enter the code from another computer: 81 | 82 | 83 | Qt::AlignCenter 84 | 85 | 86 | true 87 | 88 | 89 | 90 | 91 | 92 | 93 | QLayout::SetDefaultConstraint 94 | 95 | 96 | 10 97 | 98 | 99 | 10 100 | 101 | 102 | 10 103 | 104 | 105 | 106 | 107 | 108 | 0 109 | 0 110 | 111 | 112 | 113 | Code: 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | Set Code 124 | 125 | 126 | 127 | 128 | 129 | 130 | Quit 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | Qt::Vertical 140 | 141 | 142 | 143 | 20 144 | 40 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/ui/MainWindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 700 10 | 400 11 | 12 | 13 | 14 | Magic Wormhole 15 | 16 | 17 | 18 | 19 | 20 | 21 | QAbstractItemView::NoSelection 22 | 23 | 24 | false 25 | 26 | 27 | false 28 | 29 | 30 | false 31 | 32 | 33 | 34 | 35 | 36 | 37 | true 38 | 39 | 40 | background-color: rgba(255, 0, 0, 96); 41 | 42 | 43 | QFrame::StyledPanel 44 | 45 | 46 | QFrame::Sunken 47 | 48 | 49 | Error Label 50 | 51 | 52 | Qt::PlainText 53 | 54 | 55 | Qt::AlignCenter 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 1024 65 | 66 | 67 | 68 | 69 | 70 | 71 | Send Message 72 | 73 | 74 | 75 | 76 | 77 | 78 | Send Files... 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | MessageTable 90 | QTableWidget 91 |
messagetable.h
92 |
93 |
94 | 95 | 96 |
97 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/ui/SaveFile.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 450 10 | 250 11 | 12 | 13 | 14 | Save To 15 | 16 | 17 | 18 | 19 | 20 | Qt::Vertical 21 | 22 | 23 | 24 | 20 25 | 33 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | You are being sent a file: 34 | 35 | 36 | 37 | 38 | 39 | 40 | font-size: 18pt 41 | 42 | 43 | example_filename.ext [128MB] 44 | 45 | 46 | 47 | 48 | 49 | 50 | Qt::Vertical 51 | 52 | 53 | 54 | 20 55 | 40 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | true 66 | 67 | 68 | 69 | 70 | 71 | 72 | Browse... 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Don't show again for this session 82 | 83 | 84 | 85 | 86 | 87 | 88 | QDialogButtonBox::Cancel|QDialogButtonBox::Save 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/ui/__init__.py: -------------------------------------------------------------------------------- 1 | # Adapted from https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 2 | # 3 | # Copyright (c) 2011 Sebastian Wiesner 4 | # Modifications by Charl Botha 5 | # found this here: 6 | # https://github.com/lunaryorn/snippets/blob/master/qt4/designer/pyside_dynamic.py 7 | # Permission is hereby granted, free of charge, to any person obtaining a 8 | # copy of this software and associated documentation files (the "Software"), 9 | # to deal in the Software without restriction, including without limitation 10 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 11 | # and/or sell copies of the Software, and to permit persons to whom the 12 | # Software is furnished to do so, subject to the following conditions: 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 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 18 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | # DEALINGS IN THE SOFTWARE. 22 | 23 | from pathlib import Path 24 | 25 | from PySide2.QtCore import QFile 26 | from PySide2.QtUiTools import QUiLoader 27 | 28 | 29 | class CustomWidget: 30 | def __init__(self, Widget, *args, **kwds): 31 | self.Widget = Widget 32 | self.name = self.Widget.__name__ 33 | self.args = args 34 | self.kwds = kwds 35 | 36 | def create(self, parent): 37 | return self.Widget(parent, *self.args, **self.kwds) 38 | 39 | 40 | class CustomUiLoader(QUiLoader): 41 | def __init__(self, base_instance=None, custom_widgets=None): 42 | super().__init__() 43 | self.base_instance = base_instance 44 | if custom_widgets is None: 45 | self.custom_widgets = {} 46 | else: 47 | self.custom_widgets = {w.name: w for w in custom_widgets} 48 | 49 | def createWidget(self, className, parent=None, name=""): 50 | if parent is None and self.base_instance: 51 | # No parent, this is the top-level widget 52 | return self.base_instance 53 | 54 | if className in QUiLoader.availableWidgets(self): 55 | widget = super().createWidget(className, parent, name) 56 | else: 57 | if className in self.custom_widgets: 58 | widget = self.custom_widgets[className].create(parent) 59 | else: 60 | raise KeyError("Unknown widget '%s'" % className) 61 | 62 | if self.base_instance: 63 | # Set an attribute for the new child widget on the base instance 64 | setattr(self.base_instance, name, widget) 65 | 66 | return widget 67 | 68 | 69 | def load_ui(filename, base_instance=None, custom_widgets=None): 70 | ui_file = QFile(str(Path(__file__).parent / filename)) 71 | ui_file.open(QFile.ReadOnly) 72 | 73 | loader = CustomUiLoader(base_instance, custom_widgets) 74 | ui = loader.load(ui_file) 75 | ui_file.close() 76 | 77 | return ui 78 | -------------------------------------------------------------------------------- /wormhole_ui/widgets/ui_dialog.py: -------------------------------------------------------------------------------- 1 | from PySide2.QtWidgets import QDialog 2 | 3 | from .ui import load_ui 4 | 5 | 6 | class UiDialog(QDialog): 7 | def __init__(self, parent, ui_name): 8 | super().__init__(parent) 9 | load_ui(ui_name, base_instance=self) 10 | 11 | def open(self): 12 | self._position_over_parent(self.parent()) 13 | super().open() 14 | 15 | def _position_over_parent(self, parent): 16 | parent_y = parent.window().pos().y() 17 | parent_center = parent.window().mapToGlobal(parent.window().rect().center()) 18 | 19 | self.move(parent_center.x() - self.width() / 2, parent_y) 20 | --------------------------------------------------------------------------------