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
41 from PyQt5 import QtCore, QtWidgets, QtGui
44 class TryStableException(Exception):
48 class TryDefaultMirrorException(Exception):
52 class TryForcingEnglishException(Exception):
56 class DownloadErrorException(Exception):
60 class Launcher(QtWidgets.QMainWindow):
64 def __init__(self, common, app, url_list):
65 super(Launcher, self).__init__()
69 self.url_list = url_list
70 self.force_redownload = False
72 # This is the current version of Tor Browser, which should get updated with every release
73 self.min_version = '7.5.2'
76 self.set_state(None, '', [])
77 self.launch_gui = True
79 # If Tor Browser is not installed, detect latest version, download, and install
80 if not self.common.settings['installed'] or not self.check_min_version():
81 # Different message if downloading for the first time, or because your installed version is too low
83 if not self.common.settings['installed']:
84 download_message = _("Downloading Tor Browser for the first time.")
85 elif not self.check_min_version():
86 download_message = _("Your version of Tor Browser is out-of-date. "
87 "Downloading the newest version.")
89 # Download and install
90 print(download_message)
91 self.set_state('task', download_message,
92 ['download_version_check',
100 if self.common.settings['download_over_tor']:
101 print(_('Downloading over Tor'))
104 # Tor Browser is already installed, so run
105 launch_message = "Launching Tor Browser."
106 print(launch_message)
107 self.set_state('task', launch_message, ['run'])
109 # Build the rest of the UI
112 self.setWindowTitle(_("Tor Browser"))
113 self.setWindowIcon(QtGui.QIcon(self.common.paths['icon_file']))
116 self.label = QtWidgets.QLabel()
119 self.progress_bar = QtWidgets.QProgressBar()
120 self.progress_bar.setTextVisible(True)
121 self.progress_bar.setMinimum(0)
122 self.progress_bar.setMaximum(0)
123 self.progress_bar.setValue(0)
126 self.yes_button = QtWidgets.QPushButton()
127 self.yes_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
128 self.yes_button.clicked.connect(self.yes_clicked)
129 self.start_button = QtWidgets.QPushButton(_('Start'))
130 self.start_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
131 self.start_button.clicked.connect(self.start)
132 self.cancel_button = QtWidgets.QPushButton()
133 self.cancel_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton))
134 self.cancel_button.clicked.connect(self.close)
135 buttons_layout = QtWidgets.QHBoxLayout()
136 buttons_layout.addStretch()
137 buttons_layout.addWidget(self.yes_button)
138 buttons_layout.addWidget(self.start_button)
139 buttons_layout.addWidget(self.cancel_button)
140 buttons_layout.addStretch()
143 layout = QtWidgets.QVBoxLayout()
144 layout.addWidget(self.label)
145 layout.addWidget(self.progress_bar)
146 layout.addLayout(buttons_layout)
148 central_widget = QtWidgets.QWidget()
149 central_widget.setLayout(layout)
150 self.setCentralWidget(central_widget)
154 # Set the current state of Tor Browser Launcher
155 def set_state(self, gui, message, tasks, autostart=True):
157 self.gui_message = message
158 self.gui_tasks = tasks
160 self.gui_autostart = autostart
162 # Show and hide parts of the UI based on the current state
165 self.progress_bar.hide()
166 self.yes_button.hide()
167 self.start_button.hide()
169 if 'error' in self.gui:
171 self.label.setText(self.gui_message)
174 if self.gui != 'error':
175 self.yes_button.setText(_('Yes'))
176 self.yes_button.show()
179 self.cancel_button.setText(_('Exit'))
181 elif self.gui == 'task':
183 self.label.setText(self.gui_message)
186 self.progress_bar.show()
189 if not self.gui_autostart:
190 self.start_button.show()
193 self.cancel_button.setText(_('Cancel'))
198 if self.gui_autostart:
201 # Yes button clicked, based on the state decide what to do
202 def yes_clicked(self):
203 if self.gui == 'error_try_stable':
205 elif self.gui == 'error_try_default_mirror':
206 self.try_default_mirror()
207 elif self.gui == 'error_try_forcing_english':
208 self.try_forcing_english()
209 elif self.gui == 'error_try_tor':
212 # Start button clicked, begin tasks
213 def start(self, widget, data=None):
214 # Hide the start button
215 self.start_button.hide()
217 # Start running tasks
220 # Run the next task in the task list
222 if self.gui_task_i >= len(self.gui_tasks):
226 task = self.gui_tasks[self.gui_task_i]
228 # Get ready for the next task
231 if task == 'download_version_check':
232 print(_('Downloading'), self.common.paths['version_check_url'])
233 self.download('version check', self.common.paths['version_check_url'], self.common.paths['version_check_file'])
235 if task == 'set_version':
236 version = self.get_stable_version()
238 self.common.build_paths(self.get_stable_version())
239 print(_('Latest version: {}').format(version))
242 self.set_state('error', _("Error detecting Tor Browser version."), [], False)
245 elif task == 'download_sig':
246 print(_('Downloading'), self.common.paths['sig_url'].format(self.common.settings['mirror']))
247 self.download('signature', self.common.paths['sig_url'], self.common.paths['sig_file'])
249 elif task == 'download_tarball':
250 print(_('Downloading'), self.common.paths['tarball_url'].format(self.common.settings['mirror']))
251 if not self.force_redownload and os.path.exists(self.common.paths['tarball_file']):
254 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
256 elif task == 'verify':
257 print(_('Verifying Signature'))
260 elif task == 'extract':
261 print(_('Extracting'), self.common.paths['tarball_filename'])
265 print(_('Running'), self.common.paths['tbb']['start'])
268 elif task == 'start_over':
269 print(_('Starting download over again'))
272 def download(self, name, url, path):
273 # Download from the selected mirror
274 mirror_url = url.format(self.common.settings['mirror']).encode()
276 # Initialize the progress bar
277 self.progress_bar.setValue(0)
278 self.progress_bar.setMaximum(100)
279 if self.common.settings['download_over_tor']:
280 self.progress_bar.setFormat(_('Downloading') + ' {0} '.format(name) + _('(over Tor)') + ', %p%')
282 self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
284 def progress_update(total_bytes, bytes_so_far):
285 percent = float(bytes_so_far) / float(total_bytes)
286 amount = float(bytes_so_far)
288 for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
291 amount /= float(size)
294 message = _('Downloaded') + (' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units))
295 if self.common.settings['download_over_tor']:
296 message += ' ' + _('(over Tor)')
298 self.progress_bar.setMaximum(total_bytes)
299 self.progress_bar.setValue(bytes_so_far)
300 self.progress_bar.setFormat(message)
302 def download_complete():
303 # Download complete, next task
306 def download_error(gui, message):
308 self.set_state(gui, message, [], False)
311 t = DownloadThread(self.common, mirror_url, path)
312 t.progress_update.connect(progress_update)
313 t.download_complete.connect(download_complete)
314 t.download_error.connect(download_error)
318 def try_default_mirror(self):
319 # change mirror to default and relaunch TBL
320 self.common.settings['mirror'] = self.common.default_mirror
321 self.common.save_settings()
322 subprocess.Popen([self.common.paths['tbl_bin']])
325 def try_forcing_english(self):
326 # change force english to true and relaunch TBL
327 self.common.settings['force_en-US'] = True
328 self.common.save_settings()
329 subprocess.Popen([self.common.paths['tbl_bin']])
333 # set download_over_tor to true and relaunch TBL
334 self.common.settings['download_over_tor'] = True
335 self.common.save_settings()
336 subprocess.Popen([self.common.paths['tbl_bin']])
339 def get_stable_version(self):
340 tree = ET.parse(self.common.paths['version_check_file'])
341 for up in tree.getroot():
342 if up.tag == 'update' and up.attrib['appVersion']:
343 version = str(up.attrib['appVersion'])
345 # make sure the version does not contain directory traversal attempts
346 # e.g. "5.5.3", "6.0a", "6.0a-hardened" are valid but "../../../../.." is invalid
347 if not re.match(r'^[a-z0-9\.\-]+$', version):
354 self.progress_bar.setValue(0)
355 self.progress_bar.setMaximum(0)
356 self.progress_bar.show()
358 self.label.setText(_('Verifying Signature'))
364 # Make backup of tarball and sig
365 backup_tarball_filename = self.common.paths['tarball_file'] + '.verification_failed'
366 backup_sig_filename = self.common.paths['sig_file'] + '.verification_failed'
367 shutil.copyfile(self.common.paths['tarball_file'], backup_tarball_filename)
368 shutil.copyfile(self.common.paths['sig_file'], backup_sig_filename)
370 sigerror = 'SIGNATURE VERIFICATION FAILED!\n\n' \
371 'Error Code: {0}\n\n' \
372 'You might be under attack, there might be a network problem, or you may be missing a ' \
373 'recently added Tor Browser verification key.\n\n' \
374 'A copy of the Tor Browser files you downloaded have been saved here:\n' \
376 'Click Start to refresh the keyring and try again. If the message persists report the above ' \
377 'error code here:\nhttps://github.com/micahflee/torbrowser-launcher/issues'
378 sigerror = sigerror.format(message, backup_tarball_filename, backup_sig_filename)
380 self.set_state('task', sigerror, ['start_over'], False)
383 t = VerifyThread(self.common)
384 t.error.connect(error)
385 t.success.connect(success)
390 self.progress_bar.setValue(0)
391 self.progress_bar.setMaximum(0)
392 self.progress_bar.show()
394 self.label.setText(_('Installing'))
402 _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])),
403 ['start_over'], False
407 t = ExtractThread(self.common)
408 t.error.connect(error)
409 t.success.connect(success)
413 def check_min_version(self):
414 installed_version = None
415 for line in open(self.common.paths['tbb']['changelog'],'rb').readlines():
416 if line.startswith(b'Tor Browser '):
417 installed_version = line.split()[2].decode()
420 if self.min_version <= installed_version:
426 # Don't run if it isn't at least the minimum version
427 if not self.check_min_version():
428 message = _("The version of Tor Browser you have installed is earlier than it should be, which could be a "
429 "sign of an attack!")
432 Alert(self.common, message)
436 subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
439 # Start over and download TBB again
440 def start_over(self):
441 self.force_redownload = True # Overwrite any existing file
442 self.label.setText(_("Downloading Tor Browser over again."))
443 self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
447 def closeEvent(self, event):
448 # Clear the download cache
450 os.remove(self.common.paths['version_check_file'])
451 os.remove(self.common.paths['sig_file'])
452 os.remove(self.common.paths['tarball_file'])
456 super(Launcher, self).closeEvent(event)
459 class Alert(QtWidgets.QMessageBox):
463 def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True):
464 super(Alert, self).__init__(None)
466 self.setWindowTitle(_("Tor Browser Launcher"))
467 self.setWindowIcon(QtGui.QIcon(common.paths['icon_file']))
468 self.setText(message)
470 self.setStandardButtons(buttons)
476 class DownloadThread(QtCore.QThread):
478 Download a file in a separate thread.
480 progress_update = QtCore.pyqtSignal(int, int)
481 download_complete = QtCore.pyqtSignal()
482 download_error = QtCore.pyqtSignal(str, str)
484 def __init__(self, common, url, path):
485 super(DownloadThread, self).__init__()
490 # Use tor socks5 proxy, if enabled
491 if self.common.settings['download_over_tor']:
492 socks5_address = 'socks5h://{}'.format(self.common.settings['tor_socks_address'])
494 'https': socks5_address,
495 'http': socks5_address
501 with open(self.path, "wb") as f:
504 r = requests.get(self.url,
505 headers={'User-Agent': 'torbrowser-launcher'},
506 stream=True, proxies=self.proxies)
508 # If status code isn't 200, something went wrong
509 if r.status_code != 200:
510 # Should we use the default mirror?
511 if self.common.settings['mirror'] != self.common.default_mirror:
512 message = (_("Download Error:") +
513 " {0}\n\n" + _("You are currently using a non-default mirror") +
514 ":\n{1}\n\n" + _("Would you like to switch back to the default?")).format(
515 r.status_code, self.common.settings['mirror']
517 self.download_error.emit('error_try_default_mirror', message)
519 # Should we switch to English?
520 elif self.common.language != 'en-US' and not self.common.settings['force_en-US']:
521 message = (_("Download Error:") +
523 _("Would you like to try the English version of Tor Browser instead?")).format(
526 self.download_error.emit('error_try_forcing_english', message)
529 message = (_("Download Error:") + " {0}").format(r.status_code)
530 self.download_error.emit('error', message)
535 # Start streaming the download
536 total_bytes = int(r.headers.get('content-length'))
538 for data in r.iter_content(chunk_size=4096):
539 bytes_so_far += len(data)
541 self.progress_update.emit(total_bytes, bytes_so_far)
543 except requests.exceptions.SSLError:
544 message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.').format(self.url.decode())
545 if not self.common.settings['download_over_tor']:
546 message += "\n\n" + _('Try the download again using Tor?')
547 self.download_error.emit('error_try_tor', message)
549 self.download_error.emit('error', message)
552 except requests.exceptions.ConnectionError:
554 if self.common.settings['download_over_tor']:
555 message = _("Error starting download:\n\n{0}\n\nTrying to download over Tor. "
556 "Are you sure Tor is configured correctly and running?").format(self.url.decode())
557 self.download_error.emit('error', message)
559 message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(
562 self.download_error.emit('error', message)
566 self.download_complete.emit()
569 class VerifyThread(QtCore.QThread):
571 Verify the signature in a separate thread
573 success = QtCore.pyqtSignal()
574 error = QtCore.pyqtSignal(str)
576 def __init__(self, common):
577 super(VerifyThread, self).__init__()
581 def verify(second_try=False):
582 with gpg.Context() as c:
583 c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
585 sig = gpg.Data(file=self.common.paths['sig_file'])
586 signed = gpg.Data(file=self.common.paths['tarball_file'])
589 c.verify(signature=sig, signed_data=signed)
590 except gpg.errors.BadSignatures as e:
592 self.error.emit(str(e))
602 # If it fails, refresh the keyring and try again
603 self.common.refresh_keyring()
607 class ExtractThread(QtCore.QThread):
609 Extract the tarball in a separate thread
611 success = QtCore.pyqtSignal()
612 error = QtCore.pyqtSignal()
614 def __init__(self, common):
615 super(ExtractThread, self).__init__()
621 if self.common.paths['tarball_file'][-2:] == 'xz':
622 # if tarball is .tar.xz
623 xz = lzma.LZMAFile(self.common.paths['tarball_file'])
624 tf = tarfile.open(fileobj=xz)
625 tf.extractall(self.common.paths['tbb']['dir'])
628 # if tarball is .tar.gz
629 if tarfile.is_tarfile(self.common.paths['tarball_file']):
630 tf = tarfile.open(self.common.paths['tarball_file'])
631 tf.extractall(self.common.paths['tbb']['dir'])