├── .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 | 
6 |
7 | [](https://pypi.python.org/pypi/wormhole-ui)
8 | [](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 |
54 |
--------------------------------------------------------------------------------
/wormhole_ui/resources/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
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 |
--------------------------------------------------------------------------------