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