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