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.
38 import xml.etree.ElementTree as ET
40 from PyQt5 import QtCore, QtWidgets, QtGui
43 class TryStableException(Exception):
47 class TryDefaultMirrorException(Exception):
51 class TryForcingEnglishException(Exception):
55 class DownloadErrorException(Exception):
59 class Launcher(QtWidgets.QMainWindow):
63 def __init__(self, common, app, url_list):
64 super(Launcher, self).__init__()
68 self.url_list = url_list
69 self.force_redownload = False
71 # This is the current version of Tor Browser, which should get updated with every release
72 self.min_version = '7.5.2'
75 self.set_state(None, '', [])
76 self.launch_gui = True
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
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.")
88 # Download and install
89 print(download_message)
90 self.set_state('task', download_message,
91 ['download_version_check',
99 if self.common.settings['download_over_tor']:
100 print(_('Downloading over Tor'))
103 # Tor Browser is already installed, so run
105 self.launch_gui = False
108 # Build the rest of the UI
111 self.setWindowTitle(_("Tor Browser"))
112 self.setWindowIcon(QtGui.QIcon(self.common.paths['icon_file']))
115 self.label = QtWidgets.QLabel()
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)
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()
142 layout = QtWidgets.QVBoxLayout()
143 layout.addWidget(self.label)
144 layout.addWidget(self.progress_bar)
145 layout.addLayout(buttons_layout)
147 central_widget = QtWidgets.QWidget()
148 central_widget.setLayout(layout)
149 self.setCentralWidget(central_widget)
153 # Set the current state of Tor Browser Launcher
154 def set_state(self, gui, message, tasks, autostart=True):
156 self.gui_message = message
157 self.gui_tasks = tasks
159 self.gui_autostart = autostart
161 # Show and hide parts of the UI based on the current state
164 self.progress_bar.hide()
165 self.yes_button.hide()
166 self.start_button.hide()
168 if 'error' in self.gui:
170 self.label.setText(self.gui_message)
173 if self.gui != 'error':
174 self.yes_button.setText(_('Yes'))
175 self.yes_button.show()
178 self.cancel_button.setText(_('Exit'))
180 elif self.gui == 'task':
182 self.label.setText(self.gui_message)
185 self.progress_bar.show()
188 if not self.gui_autostart:
189 self.start_button.show()
192 self.cancel_button.setText(_('Cancel'))
197 if self.gui_autostart:
200 # Yes button clicked, based on the state decide what to do
201 def yes_clicked(self):
202 if self.gui == 'error_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':
211 # Start button clicked, begin tasks
212 def start(self, widget, data=None):
213 # Hide the start button
214 self.start_button.hide()
216 # Start running tasks
219 # Run the next task in the task list
221 if self.gui_task_i >= len(self.gui_tasks):
225 task = self.gui_tasks[self.gui_task_i]
227 # Get ready for the next task
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'])
234 if task == 'set_version':
235 version = self.get_stable_version()
237 self.common.build_paths(self.get_stable_version())
238 print(_('Latest version: {}').format(version))
241 self.set_state('error', _("Error detecting Tor Browser version."), [], False)
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'])
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']):
253 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
255 elif task == 'verify':
256 print(_('Verifying Signature'))
259 elif task == 'extract':
260 print(_('Extracting'), self.common.paths['tarball_filename'])
264 print(_('Running'), self.common.paths['tbb']['start'])
267 elif task == 'start_over':
268 print(_('Starting download over again'))
271 def download(self, name, url, path):
272 # Download from the selected mirror
273 mirror_url = url.format(self.common.settings['mirror']).encode()
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%')
281 self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
283 def progress_update(total_bytes, bytes_so_far):
284 percent = float(bytes_so_far) / float(total_bytes)
285 amount = float(bytes_so_far)
287 for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
290 amount /= float(size)
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)')
297 self.progress_bar.setMaximum(total_bytes)
298 self.progress_bar.setValue(bytes_so_far)
299 self.progress_bar.setFormat(message)
301 def download_complete():
302 # Download complete, next task
305 def download_error(gui, message):
307 self.set_state(gui, message, [], False)
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)
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']])
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']])
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']])
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'])
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):
353 self.progress_bar.setValue(0)
354 self.progress_bar.setMaximum(0)
355 self.progress_bar.show()
357 self.label.setText(_('Verifying Signature'))
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)
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' \
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)
379 self.set_state('task', sigerror, ['start_over'], False)
382 t = VerifyThread(self.common)
383 t.error.connect(error)
384 t.success.connect(success)
389 self.progress_bar.setValue(0)
390 self.progress_bar.setMaximum(0)
391 self.progress_bar.show()
393 self.label.setText(_('Installing'))
401 _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])),
402 ['start_over'], False
406 t = ExtractThread(self.common)
407 t.error.connect(error)
408 t.success.connect(success)
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()
419 if self.min_version <= installed_version:
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!")
431 Alert(self.common, message)
434 # Hide the TBL window (#151)
438 subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
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']
451 def closeEvent(self, event):
452 # Clear the download cache
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'])
460 super(Launcher, self).closeEvent(event)
463 class Alert(QtWidgets.QMessageBox):
467 def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True):
468 super(Alert, self).__init__(None)
470 self.setWindowTitle(_("Tor Browser Launcher"))
471 self.setWindowIcon(QtGui.QIcon(common.paths['icon_file']))
472 self.setText(message)
474 self.setStandardButtons(buttons)
480 class DownloadThread(QtCore.QThread):
482 Download a file in a separate thread.
484 progress_update = QtCore.pyqtSignal(int, int)
485 download_complete = QtCore.pyqtSignal()
486 download_error = QtCore.pyqtSignal(str, str)
488 def __init__(self, common, url, path):
489 super(DownloadThread, self).__init__()
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'])
498 'https': socks5_address,
499 'http': socks5_address
505 with open(self.path, "wb") as f:
508 r = requests.get(self.url,
509 headers={'User-Agent': 'torbrowser-launcher'},
510 stream=True, proxies=self.proxies)
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']
521 self.download_error.emit('error_try_default_mirror', message)
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:") +
527 _("Would you like to try the English version of Tor Browser instead?")).format(
530 self.download_error.emit('error_try_forcing_english', message)
533 message = (_("Download Error:") + " {0}").format(r.status_code)
534 self.download_error.emit('error', message)
539 # Start streaming the download
540 total_bytes = int(r.headers.get('content-length'))
542 for data in r.iter_content(chunk_size=4096):
543 bytes_so_far += len(data)
545 self.progress_update.emit(total_bytes, bytes_so_far)
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)
553 self.download_error.emit('error', message)
556 except requests.exceptions.ConnectionError:
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)
563 message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(
566 self.download_error.emit('error', message)
570 self.download_complete.emit()
573 class VerifyThread(QtCore.QThread):
575 Verify the signature in a separate thread
577 success = QtCore.pyqtSignal()
578 error = QtCore.pyqtSignal(str)
580 def __init__(self, common):
581 super(VerifyThread, self).__init__()
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'])
589 sig = gpg.Data(file=self.common.paths['sig_file'])
590 signed = gpg.Data(file=self.common.paths['tarball_file'])
593 c.verify(signature=sig, signed_data=signed)
594 except gpg.errors.BadSignatures as e:
596 self.error.emit(str(e))
606 # If it fails, refresh the keyring and try again
607 self.common.refresh_keyring()
611 class ExtractThread(QtCore.QThread):
613 Extract the tarball in a separate thread
615 success = QtCore.pyqtSignal()
616 error = QtCore.pyqtSignal()
618 def __init__(self, common):
619 super(ExtractThread, self).__init__()
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'])
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'])