]> git.lizzy.rs Git - torbrowser-launcher.git/blob - torbrowser_launcher/launcher.py
cb6899bddb0e567305b0de466b41b2d0d2228f15
[torbrowser-launcher.git] / torbrowser_launcher / launcher.py
1 """
2 Tor Browser Launcher
3 https://github.com/micahflee/torbrowser-launcher/
4
5 Copyright (c) 2013-2017 Micah Lee <micah@micahflee.com>
6
7 Permission is hereby granted, free of charge, to any person
8 obtaining a copy of this software and associated documentation
9 files (the "Software"), to deal in the Software without
10 restriction, including without limitation the rights to use,
11 copy, modify, merge, publish, distribute, sublicense, and/or sell
12 copies of the Software, and to permit persons to whom the
13 Software is furnished to do so, subject to the following
14 conditions:
15
16 The above copyright notice and this permission notice shall be
17 included in all copies or substantial portions of the Software.
18
19 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24 WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26 OTHER DEALINGS IN THE SOFTWARE.
27 """
28
29 import os
30 import sys
31 import subprocess
32 import time
33 import tarfile
34 import lzma
35 import re
36 import requests
37 import gpg
38 import shutil
39 import xml.etree.ElementTree as ET
40 from packaging import version
41
42 from PyQt5 import QtCore, QtWidgets, QtGui
43
44
45 class TryStableException(Exception):
46     pass
47
48
49 class TryDefaultMirrorException(Exception):
50     pass
51
52
53 class TryForcingEnglishException(Exception):
54     pass
55
56
57 class DownloadErrorException(Exception):
58     pass
59
60
61 class Launcher(QtWidgets.QMainWindow):
62     """
63     Launcher window.
64     """
65     def __init__(self, common, app, url_list):
66         super(Launcher, self).__init__()
67         self.common = common
68         self.app = app
69
70         self.url_list = url_list
71         self.force_redownload = False
72
73         # This is the current version of Tor Browser, which should get updated with every release
74         self.min_version = '7.5.2'
75
76         # Init launcher
77         self.set_state(None, '', [])
78         self.launch_gui = True
79
80         # If Tor Browser is not installed, detect latest version, download, and install
81         if not self.common.settings['installed'] or not self.check_min_version():
82             # Different message if downloading for the first time, or because your installed version is too low
83             download_message = ""
84             if not self.common.settings['installed']:
85                 download_message = _("Downloading Tor Browser for the first time.")
86             elif not self.check_min_version():
87                 download_message = _("Your version of Tor Browser is out-of-date. "
88                                      "Downloading the newest version.")
89
90             # Download and install
91             print(download_message)
92             self.set_state('task', download_message,
93                            ['download_version_check',
94                             'set_version',
95                             'download_sig',
96                             'download_tarball',
97                             'verify',
98                             'extract',
99                             'run'])
100
101             if self.common.settings['download_over_tor']:
102                 print(_('Downloading over Tor'))
103
104         else:
105             # Tor Browser is already installed, so run
106             launch_message = "Launching Tor Browser."
107             print(launch_message)
108             self.set_state('task', launch_message, ['run'])
109
110         # Build the rest of the UI
111
112         # Set up the window
113         self.setWindowTitle(_("Tor Browser"))
114         self.setWindowIcon(QtGui.QIcon(self.common.paths['icon_file']))
115
116         # Label
117         self.label = QtWidgets.QLabel()
118
119         # Progress bar
120         self.progress_bar = QtWidgets.QProgressBar()
121         self.progress_bar.setTextVisible(True)
122         self.progress_bar.setMinimum(0)
123         self.progress_bar.setMaximum(0)
124         self.progress_bar.setValue(0)
125
126         # Buttons
127         self.yes_button = QtWidgets.QPushButton()
128         self.yes_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
129         self.yes_button.clicked.connect(self.yes_clicked)
130         self.start_button = QtWidgets.QPushButton(_('Start'))
131         self.start_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
132         self.start_button.clicked.connect(self.start)
133         self.cancel_button = QtWidgets.QPushButton()
134         self.cancel_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton))
135         self.cancel_button.clicked.connect(self.close)
136         buttons_layout = QtWidgets.QHBoxLayout()
137         buttons_layout.addStretch()
138         buttons_layout.addWidget(self.yes_button)
139         buttons_layout.addWidget(self.start_button)
140         buttons_layout.addWidget(self.cancel_button)
141         buttons_layout.addStretch()
142
143         # Layout
144         layout = QtWidgets.QVBoxLayout()
145         layout.addWidget(self.label)
146         layout.addWidget(self.progress_bar)
147         layout.addLayout(buttons_layout)
148
149         central_widget = QtWidgets.QWidget()
150         central_widget.setLayout(layout)
151         self.setCentralWidget(central_widget)
152
153         self.update()
154
155     # Set the current state of Tor Browser Launcher
156     def set_state(self, gui, message, tasks, autostart=True):
157         self.gui = gui
158         self.gui_message = message
159         self.gui_tasks = tasks
160         self.gui_task_i = 0
161         self.gui_autostart = autostart
162
163     # Show and hide parts of the UI based on the current state
164     def update(self):
165         # Hide widgets
166         self.progress_bar.hide()
167         self.yes_button.hide()
168         self.start_button.hide()
169
170         if 'error' in self.gui:
171             # Label
172             self.label.setText(self.gui_message)
173
174             # Yes button
175             if self.gui != 'error':
176                 self.yes_button.setText(_('Yes'))
177                 self.yes_button.show()
178
179             # Exit button
180             self.cancel_button.setText(_('Exit'))
181
182         elif self.gui == 'task':
183             # Label
184             self.label.setText(self.gui_message)
185
186             # Progress bar
187             self.progress_bar.show()
188
189             # Start button
190             if not self.gui_autostart:
191                 self.start_button.show()
192
193             # Cancel button
194             self.cancel_button.setText(_('Cancel'))
195
196         # Resize the window
197         self.adjustSize()
198
199         if self.gui_autostart:
200             self.start(None)
201
202     # Yes button clicked, based on the state decide what to do
203     def yes_clicked(self):
204         if self.gui == 'error_try_stable':
205             self.try_stable()
206         elif self.gui == 'error_try_default_mirror':
207             self.try_default_mirror()
208         elif self.gui == 'error_try_forcing_english':
209             self.try_forcing_english()
210         elif self.gui == 'error_try_tor':
211             self.try_tor()
212
213     # Start button clicked, begin tasks
214     def start(self, widget, data=None):
215         # Hide the start button
216         self.start_button.hide()
217
218         # Start running tasks
219         self.run_task()
220
221     # Run the next task in the task list
222     def run_task(self):
223         if self.gui_task_i >= len(self.gui_tasks):
224             self.close()
225             return
226
227         task = self.gui_tasks[self.gui_task_i]
228
229         # Get ready for the next task
230         self.gui_task_i += 1
231
232         if task == 'download_version_check':
233             print(_('Downloading'), self.common.paths['version_check_url'])
234             self.download('version check', self.common.paths['version_check_url'], self.common.paths['version_check_file'])
235
236         if task == 'set_version':
237             version = self.get_stable_version()
238             if version:
239                 self.common.build_paths(self.get_stable_version())
240                 print(_('Latest version: {}').format(version))
241                 self.run_task()
242             else:
243                 self.set_state('error', _("Error detecting Tor Browser version."), [], False)
244                 self.update()
245
246         elif task == 'download_sig':
247             print(_('Downloading'), self.common.paths['sig_url'].format(self.common.settings['mirror']))
248             self.download('signature', self.common.paths['sig_url'], self.common.paths['sig_file'])
249
250         elif task == 'download_tarball':
251             print(_('Downloading'), self.common.paths['tarball_url'].format(self.common.settings['mirror']))
252             if not self.force_redownload and os.path.exists(self.common.paths['tarball_file']):
253                 self.run_task()
254             else:
255                 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
256
257         elif task == 'verify':
258             print(_('Verifying Signature'))
259             self.verify()
260
261         elif task == 'extract':
262             print(_('Extracting'), self.common.paths['tarball_filename'])
263             self.extract()
264
265         elif task == 'run':
266             print(_('Running'), self.common.paths['tbb']['start'])
267             self.run()
268
269         elif task == 'start_over':
270             print(_('Starting download over again'))
271             self.start_over()
272
273     def download(self, name, url, path):
274         # Download from the selected mirror
275         mirror_url = url.format(self.common.settings['mirror']).encode()
276
277         # Initialize the progress bar
278         self.progress_bar.setValue(0)
279         self.progress_bar.setMaximum(100)
280         if self.common.settings['download_over_tor']:
281             self.progress_bar.setFormat(_('Downloading') + ' {0} '.format(name) + _('(over Tor)') + ', %p%')
282         else:
283             self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
284
285         def progress_update(total_bytes, bytes_so_far):
286             percent = float(bytes_so_far) / float(total_bytes)
287             amount = float(bytes_so_far)
288             units = "bytes"
289             for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
290                 if amount > size:
291                     units = unit
292                     amount /= float(size)
293                     break
294
295             message = _('Downloaded') + (' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units))
296             if self.common.settings['download_over_tor']:
297                 message += ' ' + _('(over Tor)')
298
299             self.progress_bar.setMaximum(total_bytes)
300             self.progress_bar.setValue(bytes_so_far)
301             self.progress_bar.setFormat(message)
302
303         def download_complete():
304             # Download complete, next task
305             self.run_task()
306
307         def download_error(gui, message):
308             print(message)
309             self.set_state(gui, message, [], False)
310             self.update()
311
312         t = DownloadThread(self.common, mirror_url, path)
313         t.progress_update.connect(progress_update)
314         t.download_complete.connect(download_complete)
315         t.download_error.connect(download_error)
316         t.start()
317         time.sleep(0.2)
318
319     def try_default_mirror(self):
320         # change mirror to default and relaunch TBL
321         self.common.settings['mirror'] = self.common.default_mirror
322         self.common.save_settings()
323         subprocess.Popen([self.common.paths['tbl_bin']])
324         self.close()
325
326     def try_forcing_english(self):
327         # change force english to true and relaunch TBL
328         self.common.settings['force_en-US'] = True
329         self.common.save_settings()
330         subprocess.Popen([self.common.paths['tbl_bin']])
331         self.close()
332
333     def try_tor(self):
334         # set download_over_tor to true and relaunch TBL
335         self.common.settings['download_over_tor'] = True
336         self.common.save_settings()
337         subprocess.Popen([self.common.paths['tbl_bin']])
338         self.close()
339
340     def get_stable_version(self):
341         tree = ET.parse(self.common.paths['version_check_file'])
342         for up in tree.getroot():
343             if up.tag == 'update' and up.attrib['appVersion']:
344                 version = str(up.attrib['appVersion'])
345
346                 # make sure the version does not contain directory traversal attempts
347                 # e.g. "5.5.3", "6.0a", "6.0a-hardened" are valid but "../../../../.." is invalid
348                 if not re.match(r'^[a-z0-9\.\-]+$', version):
349                     return None
350
351                 return version
352         return None
353
354     def verify(self):
355         self.progress_bar.setValue(0)
356         self.progress_bar.setMaximum(0)
357         self.progress_bar.show()
358
359         self.label.setText(_('Verifying Signature'))
360
361         def success():
362             self.run_task()
363
364         def error(message):
365             # Make backup of tarball and sig
366             backup_tarball_filename = self.common.paths['tarball_file'] + '.verification_failed'
367             backup_sig_filename = self.common.paths['sig_file'] + '.verification_failed'
368             shutil.copyfile(self.common.paths['tarball_file'], backup_tarball_filename)
369             shutil.copyfile(self.common.paths['sig_file'], backup_sig_filename)
370
371             sigerror = 'SIGNATURE VERIFICATION FAILED!\n\n' \
372                        'Error Code: {0}\n\n' \
373                        'You might be under attack, there might be a network problem, or you may be missing a ' \
374                        'recently added Tor Browser verification key.\n\n' \
375                        'A copy of the Tor Browser files you downloaded have been saved here:\n' \
376                        '{1}\n{2}\n\n' \
377                        'Click Start to refresh the keyring and try again. If the message persists report the above ' \
378                        'error code here:\nhttps://github.com/micahflee/torbrowser-launcher/issues'
379             sigerror = sigerror.format(message, backup_tarball_filename, backup_sig_filename)
380
381             self.set_state('task', sigerror, ['start_over'], False)
382             self.update()
383
384         t = VerifyThread(self.common)
385         t.error.connect(error)
386         t.success.connect(success)
387         t.start()
388         time.sleep(0.2)
389
390     def extract(self):
391         self.progress_bar.setValue(0)
392         self.progress_bar.setMaximum(0)
393         self.progress_bar.show()
394
395         self.label.setText(_('Installing'))
396
397         def success():
398             self.run_task()
399
400         def error(message):
401             self.set_state(
402                 'task',
403                 _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])),
404                 ['start_over'], False
405             )
406             self.update()
407
408         t = ExtractThread(self.common)
409         t.error.connect(error)
410         t.success.connect(success)
411         t.start()
412         time.sleep(0.2)
413
414     def check_min_version(self):
415         installed_version = None
416         for line in open(self.common.paths['tbb']['changelog'],'rb').readlines():
417             if line.startswith(b'Tor Browser '):
418                 installed_version = line.split()[2].decode()
419                 break
420
421         if version.parse(self.min_version) <= version.parse(installed_version):
422             return True
423
424         return False
425
426     def run(self):
427         # Don't run if it isn't at least the minimum version
428         if not self.check_min_version():
429             message = _("The version of Tor Browser you have installed is earlier than it should be, which could be a "
430                         "sign of an attack!")
431             print(message)
432
433             Alert(self.common, message)
434             return
435
436         # Run Tor Browser
437         subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
438         sys.exit(0)
439
440     # Start over and download TBB again
441     def start_over(self):
442         self.force_redownload = True  # Overwrite any existing file
443         self.label.setText(_("Downloading Tor Browser over again."))
444         self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
445         self.gui_task_i = 0
446         self.start(None)
447
448     def closeEvent(self, event):
449         # Clear the download cache
450         try:
451             os.remove(self.common.paths['version_check_file'])
452             os.remove(self.common.paths['sig_file'])
453             os.remove(self.common.paths['tarball_file'])
454         except:
455             pass
456
457         super(Launcher, self).closeEvent(event)
458
459
460 class Alert(QtWidgets.QMessageBox):
461     """
462     An alert box dialog.
463     """
464     def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True):
465         super(Alert, self).__init__(None)
466
467         self.setWindowTitle(_("Tor Browser Launcher"))
468         self.setWindowIcon(QtGui.QIcon(common.paths['icon_file']))
469         self.setText(message)
470         self.setIcon(icon)
471         self.setStandardButtons(buttons)
472
473         if autostart:
474             self.exec_()
475
476
477 class DownloadThread(QtCore.QThread):
478     """
479     Download a file in a separate thread.
480     """
481     progress_update = QtCore.pyqtSignal(int, int)
482     download_complete = QtCore.pyqtSignal()
483     download_error = QtCore.pyqtSignal(str, str)
484
485     def __init__(self, common, url, path):
486         super(DownloadThread, self).__init__()
487         self.common = common
488         self.url = url
489         self.path = path
490
491         # Use tor socks5 proxy, if enabled
492         if self.common.settings['download_over_tor']:
493             socks5_address = 'socks5://{}'.format(self.common.settings['tor_socks_address'])
494             self.proxies = {
495                 'https': socks5_address,
496                 'http': socks5_address
497             }
498         else:
499             self.proxies = None
500
501     def run(self):
502         with open(self.path, "wb") as f:
503             try:
504                 # Start the request
505                 r = requests.get(self.url,
506                                  headers={'User-Agent': 'torbrowser-launcher'},
507                                  stream=True, proxies=self.proxies)
508
509                 # If status code isn't 200, something went wrong
510                 if r.status_code != 200:
511                     # Should we use the default mirror?
512                     if self.common.settings['mirror'] != self.common.default_mirror:
513                         message = (_("Download Error:") +
514                                    " {0}\n\n" + _("You are currently using a non-default mirror") +
515                                    ":\n{1}\n\n" + _("Would you like to switch back to the default?")).format(
516                                        r.status_code, self.common.settings['mirror']
517                                    )
518                         self.download_error.emit('error_try_default_mirror', message)
519
520                     # Should we switch to English?
521                     elif self.common.language != 'en-US' and not self.common.settings['force_en-US']:
522                         message = (_("Download Error:") +
523                                    " {0}\n\n" +
524                                    _("Would you like to try the English version of Tor Browser instead?")).format(
525                                        r.status_code
526                                    )
527                         self.download_error.emit('error_try_forcing_english', message)
528
529                     else:
530                         message = (_("Download Error:") + " {0}").format(r.status_code)
531                         self.download_error.emit('error', message)
532
533                     r.close()
534                     return
535
536                 # Start streaming the download
537                 total_bytes = int(r.headers.get('content-length'))
538                 bytes_so_far = 0
539                 for data in r.iter_content(chunk_size=4096):
540                     bytes_so_far += len(data)
541                     f.write(data)
542                     self.progress_update.emit(total_bytes, bytes_so_far)
543
544             except requests.exceptions.SSLError:
545                 message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.').format(self.url.decode())
546                 if not self.common.settings['download_over_tor']:
547                     message += "\n\n" + _('Try the download again using Tor?')
548                     self.download_error.emit('error_try_tor', message)
549                 else:
550                     self.download_error.emit('error', message)
551                 return
552
553             except requests.exceptions.ConnectionError:
554                 # Connection error
555                 if self.common.settings['download_over_tor']:
556                     message = _("Error starting download:\n\n{0}\n\nTrying to download over Tor. "
557                                 "Are you sure Tor is configured correctly and running?").format(self.url.decode())
558                     self.download_error.emit('error', message)
559                 else:
560                     message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(
561                         self.url.decode()
562                     )
563                     self.download_error.emit('error', message)
564
565                 return
566
567         self.download_complete.emit()
568
569
570 class VerifyThread(QtCore.QThread):
571     """
572     Verify the signature in a separate thread
573     """
574     success = QtCore.pyqtSignal()
575     error = QtCore.pyqtSignal(str)
576
577     def __init__(self, common):
578         super(VerifyThread, self).__init__()
579         self.common = common
580
581     def run(self):
582         def verify(second_try=False):
583             with gpg.Context() as c:
584                 c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
585
586                 sig = gpg.Data(file=self.common.paths['sig_file'])
587                 signed = gpg.Data(file=self.common.paths['tarball_file'])
588
589                 try:
590                     c.verify(signature=sig, signed_data=signed)
591                 except gpg.errors.BadSignatures as e:
592                     if second_try:
593                         self.error.emit(str(e))
594                     else:
595                         raise Exception
596                 else:
597                     self.success.emit()
598
599         try:
600             # Try verifying
601             verify()
602         except:
603             # If it fails, refresh the keyring and try again
604             self.common.refresh_keyring()
605             verify(True)
606
607
608 class ExtractThread(QtCore.QThread):
609     """
610     Extract the tarball in a separate thread
611     """
612     success = QtCore.pyqtSignal()
613     error = QtCore.pyqtSignal()
614
615     def __init__(self, common):
616         super(ExtractThread, self).__init__()
617         self.common = common
618
619     def run(self):
620         extracted = False
621         try:
622             if self.common.paths['tarball_file'][-2:] == 'xz':
623                 # if tarball is .tar.xz
624                 xz = lzma.LZMAFile(self.common.paths['tarball_file'])
625                 tf = tarfile.open(fileobj=xz)
626                 tf.extractall(self.common.paths['tbb']['dir'])
627                 extracted = True
628             else:
629                 # if tarball is .tar.gz
630                 if tarfile.is_tarfile(self.common.paths['tarball_file']):
631                     tf = tarfile.open(self.common.paths['tarball_file'])
632                     tf.extractall(self.common.paths['tbb']['dir'])
633                     extracted = True
634         except:
635             pass
636
637         if extracted:
638             self.success.emit()
639         else:
640             self.error.emit()