3 https://github.com/micahflee/torbrowser-launcher/
5 Copyright (c) 2013-2017 Micah Lee <micah@micahflee.com>
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
16 The above copyright notice and this permission notice shall be
17 included in all copies or substantial portions of the Software.
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.
39 import xml.etree.ElementTree as ET
40 from packaging import version
42 from PyQt5 import QtCore, QtWidgets, QtGui
45 class TryStableException(Exception):
49 class TryDefaultMirrorException(Exception):
53 class TryForcingEnglishException(Exception):
57 class DownloadErrorException(Exception):
61 class Launcher(QtWidgets.QMainWindow):
65 def __init__(self, common, app, url_list):
66 super(Launcher, self).__init__()
70 self.url_list = url_list
71 self.force_redownload = False
73 # This is the current version of Tor Browser, which should get updated with every release
74 self.min_version = '7.5.2'
77 self.set_state(None, '', [])
78 self.launch_gui = True
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
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.")
90 # Download and install
91 print(download_message)
92 self.set_state('task', download_message,
93 ['download_version_check',
101 if self.common.settings['download_over_tor']:
102 print(_('Downloading over Tor'))
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'])
110 # Build the rest of the UI
113 self.setWindowTitle(_("Tor Browser"))
114 self.setWindowIcon(QtGui.QIcon(self.common.paths['icon_file']))
117 self.label = QtWidgets.QLabel()
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)
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()
144 layout = QtWidgets.QVBoxLayout()
145 layout.addWidget(self.label)
146 layout.addWidget(self.progress_bar)
147 layout.addLayout(buttons_layout)
149 central_widget = QtWidgets.QWidget()
150 central_widget.setLayout(layout)
151 self.setCentralWidget(central_widget)
155 # Set the current state of Tor Browser Launcher
156 def set_state(self, gui, message, tasks, autostart=True):
158 self.gui_message = message
159 self.gui_tasks = tasks
161 self.gui_autostart = autostart
163 # Show and hide parts of the UI based on the current state
166 self.progress_bar.hide()
167 self.yes_button.hide()
168 self.start_button.hide()
170 if 'error' in self.gui:
172 self.label.setText(self.gui_message)
175 if self.gui != 'error':
176 self.yes_button.setText(_('Yes'))
177 self.yes_button.show()
180 self.cancel_button.setText(_('Exit'))
182 elif self.gui == 'task':
184 self.label.setText(self.gui_message)
187 self.progress_bar.show()
190 if not self.gui_autostart:
191 self.start_button.show()
194 self.cancel_button.setText(_('Cancel'))
199 if self.gui_autostart:
202 # Yes button clicked, based on the state decide what to do
203 def yes_clicked(self):
204 if self.gui == 'error_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':
213 # Start button clicked, begin tasks
214 def start(self, widget, data=None):
215 # Hide the start button
216 self.start_button.hide()
218 # Start running tasks
221 # Run the next task in the task list
223 if self.gui_task_i >= len(self.gui_tasks):
227 task = self.gui_tasks[self.gui_task_i]
229 # Get ready for the next task
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'])
236 if task == 'set_version':
237 version = self.get_stable_version()
239 self.common.build_paths(self.get_stable_version())
240 print(_('Latest version: {}').format(version))
243 self.set_state('error', _("Error detecting Tor Browser version."), [], False)
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'])
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']):
255 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
257 elif task == 'verify':
258 print(_('Verifying Signature'))
261 elif task == 'extract':
262 print(_('Extracting'), self.common.paths['tarball_filename'])
266 print(_('Running'), self.common.paths['tbb']['start'])
269 elif task == 'start_over':
270 print(_('Starting download over again'))
273 def download(self, name, url, path):
274 # Download from the selected mirror
275 mirror_url = url.format(self.common.settings['mirror']).encode()
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%')
283 self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
285 def progress_update(total_bytes, bytes_so_far):
286 percent = float(bytes_so_far) / float(total_bytes)
287 amount = float(bytes_so_far)
289 for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
292 amount /= float(size)
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)')
299 self.progress_bar.setMaximum(total_bytes)
300 self.progress_bar.setValue(bytes_so_far)
301 self.progress_bar.setFormat(message)
303 def download_complete():
304 # Download complete, next task
307 def download_error(gui, message):
309 self.set_state(gui, message, [], False)
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)
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']])
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']])
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']])
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'])
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):
355 self.progress_bar.setValue(0)
356 self.progress_bar.setMaximum(0)
357 self.progress_bar.show()
359 self.label.setText(_('Verifying Signature'))
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)
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' \
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)
381 self.set_state('task', sigerror, ['start_over'], False)
384 t = VerifyThread(self.common)
385 t.error.connect(error)
386 t.success.connect(success)
391 self.progress_bar.setValue(0)
392 self.progress_bar.setMaximum(0)
393 self.progress_bar.show()
395 self.label.setText(_('Installing'))
403 _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])),
404 ['start_over'], False
408 t = ExtractThread(self.common)
409 t.error.connect(error)
410 t.success.connect(success)
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()
421 if version.parse(self.min_version) <= version.parse(installed_version):
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!")
433 Alert(self.common, message)
437 subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
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']
448 def closeEvent(self, event):
449 # Clear the download cache
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'])
457 super(Launcher, self).closeEvent(event)
460 class Alert(QtWidgets.QMessageBox):
464 def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True):
465 super(Alert, self).__init__(None)
467 self.setWindowTitle(_("Tor Browser Launcher"))
468 self.setWindowIcon(QtGui.QIcon(common.paths['icon_file']))
469 self.setText(message)
471 self.setStandardButtons(buttons)
477 class DownloadThread(QtCore.QThread):
479 Download a file in a separate thread.
481 progress_update = QtCore.pyqtSignal(int, int)
482 download_complete = QtCore.pyqtSignal()
483 download_error = QtCore.pyqtSignal(str, str)
485 def __init__(self, common, url, path):
486 super(DownloadThread, self).__init__()
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'])
495 'https': socks5_address,
496 'http': socks5_address
502 with open(self.path, "wb") as f:
505 r = requests.get(self.url,
506 headers={'User-Agent': 'torbrowser-launcher'},
507 stream=True, proxies=self.proxies)
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']
518 self.download_error.emit('error_try_default_mirror', message)
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:") +
524 _("Would you like to try the English version of Tor Browser instead?")).format(
527 self.download_error.emit('error_try_forcing_english', message)
530 message = (_("Download Error:") + " {0}").format(r.status_code)
531 self.download_error.emit('error', message)
536 # Start streaming the download
537 total_bytes = int(r.headers.get('content-length'))
539 for data in r.iter_content(chunk_size=4096):
540 bytes_so_far += len(data)
542 self.progress_update.emit(total_bytes, bytes_so_far)
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)
550 self.download_error.emit('error', message)
553 except requests.exceptions.ConnectionError:
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)
560 message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(
563 self.download_error.emit('error', message)
567 self.download_complete.emit()
570 class VerifyThread(QtCore.QThread):
572 Verify the signature in a separate thread
574 success = QtCore.pyqtSignal()
575 error = QtCore.pyqtSignal(str)
577 def __init__(self, common):
578 super(VerifyThread, self).__init__()
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'])
586 sig = gpg.Data(file=self.common.paths['sig_file'])
587 signed = gpg.Data(file=self.common.paths['tarball_file'])
590 c.verify(signature=sig, signed_data=signed)
591 except gpg.errors.BadSignatures as e:
593 self.error.emit(str(e))
603 # If it fails, refresh the keyring and try again
604 self.common.refresh_keyring()
608 class ExtractThread(QtCore.QThread):
610 Extract the tarball in a separate thread
612 success = QtCore.pyqtSignal()
613 error = QtCore.pyqtSignal()
615 def __init__(self, common):
616 super(ExtractThread, self).__init__()
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'])
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'])