]> git.lizzy.rs Git - torbrowser-launcher.git/blobdiff - torbrowser_launcher/launcher.py
Make downloading over Tor work
[torbrowser-launcher.git] / torbrowser_launcher / launcher.py
index e2ac46e82e4f7c560ea08d1c82ea9df2fca4f718..959a2aca66a22b95f3406859689735b3683e83bd 100644 (file)
@@ -37,8 +37,8 @@ import threading
 import re
 import unicodedata
 import requests
+import socks
 import gpg
-import OpenSSL
 import xml.etree.ElementTree as ET
 
 from PyQt5 import QtCore, QtWidgets, QtGui
@@ -81,16 +81,6 @@ class Launcher(QtWidgets.QMainWindow):
 
         # If Tor Browser is not installed, detect latest version, download, and install
         if not self.common.settings['installed'] or not self.check_min_version():
-            # If downloading over Tor, include txsocksx
-            if self.common.settings['download_over_tor']:
-                try:
-                    import txsocksx
-                    print(_('Downloading over Tor'))
-                except ImportError:
-                    Alert(self.common, _("The python-txsocksx package is missing, downloads will not happen over tor"))
-                    self.common.settings['download_over_tor'] = False
-                    self.common.save_settings()
-
             # Different message if downloading for the first time, or because your installed version is too low
             download_message = ""
             if not self.common.settings['installed']:
@@ -109,6 +99,9 @@ class Launcher(QtWidgets.QMainWindow):
                           'extract',
                           'run'])
 
+            if self.common.settings['download_over_tor']:
+                print(_('Downloading over Tor'))
+
         else:
             # Tor Browser is already installed, so run
             self.run(False)
@@ -139,11 +132,14 @@ class Launcher(QtWidgets.QMainWindow):
             self.start_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
             self.start_button.clicked.connect(self.start)
             self.cancel_button = QtWidgets.QPushButton()
-            self.start_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton))
+            self.cancel_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton))
             self.cancel_button.clicked.connect(self.close)
             buttons_layout = QtWidgets.QHBoxLayout()
+            buttons_layout.addStretch()
+            buttons_layout.addWidget(self.yes_button)
             buttons_layout.addWidget(self.start_button)
             buttons_layout.addWidget(self.cancel_button)
+            buttons_layout.addStretch()
 
             # Layout
             layout = QtWidgets.QVBoxLayout()
@@ -273,182 +269,67 @@ class Launcher(QtWidgets.QMainWindow):
             print(_('Starting download over again'))
             self.start_over()
 
-    def response_received(self, response):
-        class FileDownloader(Protocol):
-            def __init__(self, common, file, url, total, progress, done_cb):
-                self.file = file
-                self.total = total
-                self.so_far = 0
-                self.progress = progress
-                self.all_done = done_cb
-
-                if response.code != 200:
-                    if common.settings['mirror'] != common.default_mirror:
-                        raise TryDefaultMirrorException(
-                            (_("Download Error:") + " {0} {1}\n\n" + _("You are currently using a non-default mirror")
-                             + ":\n{2}\n\n" + _("Would you like to switch back to the default?")).format(
-                                response.code, response.phrase, common.settings['mirror']
-                            )
-                        )
-                    elif common.language != 'en-US' and not common.settings['force_en-US']:
-                        raise TryForcingEnglishException(
-                            (_("Download Error:") + " {0} {1}\n\n"
-                             + _("Would you like to try the English version of Tor Browser instead?")).format(
-                                response.code, response.phrase
-                            )
-                        )
-                    else:
-                        raise DownloadErrorException(
-                            (_("Download Error:") + " {0} {1}").format(response.code, response.phrase)
-                        )
-
-            def dataReceived(self, bytes):
-                self.file.write(bytes)
-                self.so_far += len(bytes)
-                percent = float(self.so_far) / float(self.total)
-                self.progress.setValue(percent)
-                amount = float(self.so_far)
-                units = "bytes"
-                for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
-                    if amount > size:
-                        units = unit
-                        amount /= float(size)
-                        break
-
-                self.progress.setFormat(_('Downloaded')+(' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units)))
-
-            def connectionLost(self, reason):
-                self.all_done(reason)
-
-        if hasattr(self, 'current_download_url'):
-            url = self.current_download_url
-        else:
-            url = None
-
-        dl = FileDownloader(
-            self.common, self.file_download, url, response.length, self.progress_bar, self.response_finished
-        )
-        response.deliverBody(dl)
-
-    def response_finished(self, msg):
-        if msg.check(ResponseDone):
-            self.file_download.close()
-            delattr(self, 'current_download_path')
-            delattr(self, 'current_download_url')
-
-            # next task!
-            self.run_task()
-
-        else:
-            print("FINISHED", msg)
-            ## FIXME handle errors
-
-    def download_error(self, f):
-        print(_("Download Error:"), f.value, type(f.value))
-
-        if isinstance(f.value, TryStableException):
-            f.trap(TryStableException)
-            self.set_state('error_try_stable', str(f.value), [], False)
-
-        elif isinstance(f.value, TryDefaultMirrorException):
-            f.trap(TryDefaultMirrorException)
-            self.set_state('error_try_default_mirror', str(f.value), [], False)
-
-        elif isinstance(f.value, TryForcingEnglishException):
-            f.trap(TryForcingEnglishException)
-            self.set_state('error_try_forcing_english', str(f.value), [], False)
-
-        elif isinstance(f.value, DownloadErrorException):
-            f.trap(DownloadErrorException)
-            self.set_state('error', str(f.value), [], False)
-
-        elif isinstance(f.value, DNSLookupError):
-            f.trap(DNSLookupError)
-            if common.settings['mirror'] != common.default_mirror:
-                self.set_state('error_try_default_mirror', (_("DNS Lookup Error") + "\n\n" +
-                                                          _("You are currently using a non-default mirror")
-                                                          + ":\n{0}\n\n"
-                                                          + _("Would you like to switch back to the default?")
-                                                          ).format(common.settings['mirror']), [], False)
-            else:
-                self.set_state('error', str(f.value), [], False)
-
-        elif isinstance(f.value, ResponseFailed):
-            for reason in f.value.reasons:
-                if isinstance(reason.value, OpenSSL.SSL.Error):
-                    # TODO: add the ability to report attack by posting bug to trac.torproject.org
-                    if not self.common.settings['download_over_tor']:
-                        self.set_state('error_try_tor',
-                                     _('The SSL certificate served by https://www.torproject.org is invalid! You may '
-                                       'be under attack.') + " " + _('Try the download again using Tor?'), [], False)
-                    else:
-                        self.set_state('error', _('The SSL certificate served by https://www.torproject.org is invalid! '
-                                                'You may be under attack.'), [], False)
-
-        elif isinstance(f.value, ConnectionRefusedError) and self.common.settings['download_over_tor']:
-            # If we're using Tor, we'll only get this error when we fail to
-            # connect to the SOCKS server.  If the connection fails at the
-            # remote end, we'll get txsocksx.errors.ConnectionRefused.
-            addr = self.common.settings['tor_socks_address']
-            self.set_state('error', _("Error connecting to Tor at {0}").format(addr), [], False)
-
-        else:
-            self.set_state('error', _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(f.value), [], False)
-
-        self.update()
-
     def download(self, name, url, path):
-        # Keep track of current download
-        self.current_download_path = path
-        self.current_download_url = url.encode()
-
-        mirror_url = url.format(self.common.settings['mirror'])
-        mirror_url = mirror_url.encode()
+        # Download from the selected mirror
+        mirror_url = url.format(self.common.settings['mirror']).encode()
 
         # Initialize the progress bar
         self.progress_bar.setValue(0)
         self.progress_bar.setMaximum(100)
-        self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
-
         if self.common.settings['download_over_tor']:
-            # TODO: make requests work over SOCKS5 proxy
-            # this is the proxy to use: self.common.settings['tor_socks_address']
-            pass
+            self.progress_bar.setFormat(_('Downloading') + ' {0} '.format(name) + _('(over Tor)') + ', %p%')
+        else:
+            self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
+
+        def progress_update(total_bytes, bytes_so_far):
+            percent = float(bytes_so_far) / float(total_bytes)
+            amount = float(bytes_so_far)
+            units = "bytes"
+            for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
+                if amount > size:
+                    units = unit
+                    amount /= float(size)
+                    break
+
+            message = _('Downloaded') + (' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units))
+            if self.common.settings['download_over_tor']:
+                message += ' ' + _('(over Tor)')
 
-        with open(self.current_download_path, "wb") as f:
-            # Start the request
-            r = requests.get(mirror_url, headers={'User-Agent': 'torbrowser-launcher'}, stream=True)
-            total_length = r.headers.get('content-length')
+            self.progress_bar.setMaximum(total_bytes)
+            self.progress_bar.setValue(bytes_so_far)
+            self.progress_bar.setFormat(message)
 
-            if total_length is None: # no content length header
-                f.write(r.content)
-            else:
-                dl = 0
-                total_length = int(total_length)
-                for data in r.iter_content(chunk_size=4096):
-                    dl += len(data)
-                    f.write(data)
-                    done = int(50 * dl / total_length)
-                    print('{} / {}'.format(dl, total_length), end='\r')
+        def download_complete():
+            # Download complete, next task
+            self.run_task()
 
-        # Download complete, next task
-        self.run_task()
+        def download_error(gui, message):
+            print(message)
+            self.set_state(gui, message, [], False)
+            self.update()
+
+        t = DownloadThread(self.common, mirror_url, path)
+        t.progress_update.connect(progress_update)
+        t.download_complete.connect(download_complete)
+        t.download_error.connect(download_error)
+        t.start()
+        time.sleep(0.2)
 
-    def try_default_mirror(self, widget, data=None):
+    def try_default_mirror(self):
         # change mirror to default and relaunch TBL
         self.common.settings['mirror'] = self.common.default_mirror
         self.common.save_settings()
         subprocess.Popen([self.common.paths['tbl_bin']])
         self.close()
 
-    def try_forcing_english(self, widget, data=None):
+    def try_forcing_english(self):
         # change force english to true and relaunch TBL
         self.common.settings['force_en-US'] = True
         self.common.save_settings()
         subprocess.Popen([self.common.paths['tbl_bin']])
         self.close()
 
-    def try_tor(self, widget, data=None):
+    def try_tor(self):
         # set download_over_tor to true and relaunch TBL
         self.common.settings['download_over_tor'] = True
         self.common.save_settings()
@@ -472,13 +353,14 @@ class Launcher(QtWidgets.QMainWindow):
     def verify(self):
         self.progress_bar.setValue(0)
         self.progress_bar.setMaximum(0)
-        self.progress_bar.setFormat(_('Verifying Signature'))
         self.progress_bar.show()
 
-        def gui_raise_sigerror(self, sigerror='MissingErr'):
-            """
-            :type sigerror: str
-            """
+        self.label.setText(_('Verifying Signature'))
+
+        def success():
+            self.run_task()
+
+        def error(message):
             sigerror = 'SIGNATURE VERIFICATION FAILED!\n\nError Code: {0}\n\nYou might be under attack, there might' \
                        ' be a network\nproblem, or you may be missing a recently added\nTor Browser verification key.' \
                        '\nClick Start to refresh the keyring and try again. If the message persists report the above' \
@@ -487,54 +369,31 @@ class Launcher(QtWidgets.QMainWindow):
             self.set_state('task', sigerror, ['start_over'], False)
             self.update()
 
-        with gpg.Context() as c:
-            c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
-
-            sig = gpg.Data(file=self.common.paths['sig_file'])
-            signed = gpg.Data(file=self.common.paths['tarball_file'])
-
-            try:
-                c.verify(signature=sig, signed_data=signed)
-            except gpg.errors.BadSignatures as e:
-                result = str(e).split(": ")
-                if result[1] == 'Bad signature':
-                    gui_raise_sigerror(self, str(e))
-                elif result[1] == 'No public key':
-                    self.common.refresh_keyring(result[0])
-                    gui_raise_sigerror(self, str(e))
-            else:
-                self.run_task()
+        t = VerifyThread(self.common)
+        t.error.connect(error)
+        t.success.connect(success)
+        t.start()
+        time.sleep(0.2)
 
     def extract(self):
-        # initialize the progress bar
         self.progress_bar.setValue(0)
         self.progress_bar.setMaximum(0)
-        self.progress_bar.setFormat(_('Installing'))
         self.progress_bar.show()
 
-        extracted = False
-        try:
-            if self.common.paths['tarball_file'][-2:] == 'xz':
-                # if tarball is .tar.xz
-                xz = lzma.LZMAFile(self.common.paths['tarball_file'])
-                tf = tarfile.open(fileobj=xz)
-                tf.extractall(self.common.paths['tbb']['dir'])
-                extracted = True
-            else:
-                # if tarball is .tar.gz
-                if tarfile.is_tarfile(self.common.paths['tarball_file']):
-                    tf = tarfile.open(self.common.paths['tarball_file'])
-                    tf.extractall(self.common.paths['tbb']['dir'])
-                    extracted = True
-        except:
-            pass
+        self.label.setText(_('Installing'))
 
-        if not extracted:
+        def success():
+            self.run_task()
+
+        def error(message):
             self.set_state('task', _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])), ['start_over'], False)
             self.update()
-            return
 
-        self.run_task()
+        t = ExtractThread(self.common)
+        t.error.connect(error)
+        t.success.connect(success)
+        t.start()
+        time.sleep(0.2)
 
     def check_min_version(self):
         installed_version = None
@@ -576,12 +435,13 @@ class Launcher(QtWidgets.QMainWindow):
         self.start(None)
 
     def closeEvent(self, event):
-        if hasattr(self, 'file_download'):
-            self.file_download.close()
-        if hasattr(self, 'current_download_path'):
-            os.remove(self.current_download_path)
-            delattr(self, 'current_download_path')
-            delattr(self, 'current_download_url')
+        # Clear the download cache
+        try:
+            os.remove(self.common.paths['version_check_file'])
+            os.remove(self.common.paths['sig_file'])
+            os.remove(self.common.paths['tarball_file'])
+        except:
+            pass
 
         super(Launcher, self).closeEvent(event)
 
@@ -601,3 +461,148 @@ class Alert(QtWidgets.QMessageBox):
 
         if autostart:
             self.exec_()
+
+
+class DownloadThread(QtCore.QThread):
+    """
+    Download a file in a separate thread.
+    """
+    progress_update = QtCore.pyqtSignal(int, int)
+    download_complete = QtCore.pyqtSignal()
+    download_error = QtCore.pyqtSignal(str, str)
+
+    def __init__(self, common, url, path):
+        super(DownloadThread, self).__init__()
+        self.common = common
+        self.url = url
+        self.path = path
+
+        # Use tor socks5 proxy, if enabled
+        if self.common.settings['download_over_tor']:
+            socks5_address = 'socks5://{}'.format(self.common.settings['tor_socks_address'])
+            self.proxies = {
+                'https': socks5_address,
+                'http': socks5_address
+            }
+        else:
+            self.proxies = None
+
+    def run(self):
+        with open(self.path, "wb") as f:
+            try:
+                # Start the request
+                r = requests.get(self.url,
+                                 headers={'User-Agent': 'torbrowser-launcher'},
+                                 stream=True, proxies=self.proxies)
+
+                # If status code isn't 200, something went wrong
+                if r.status_code != 200:
+                    # Should we use the default mirror?
+                    if self.common.settings['mirror'] != self.common.default_mirror:
+                        message = (_("Download Error:") +
+                                   " {0}\n\n" + _("You are currently using a non-default mirror") +
+                                   ":\n{1}\n\n" + _("Would you like to switch back to the default?")).format(r.status_code, self.common.settings['mirror'])
+                        self.download_error.emit('error_try_default_mirror', message)
+
+                    # Should we switch to English?
+                    elif self.common.language != 'en-US' and not self.common.settings['force_en-US']:
+                        message = (_("Download Error:") +
+                                   " {0}\n\n" + _("Would you like to try the English version of Tor Browser instead?")).format(r.status_code)
+                        self.download_error.emit('error_try_forcing_english', message)
+
+                    else:
+                        message = (_("Download Error:") + " {0}").format(r.status_code)
+                        self.download_error.emit('error', message)
+
+                    r.close()
+                    return
+
+                # Start streaming the download
+                total_bytes = int(r.headers.get('content-length'))
+                bytes_so_far = 0
+                for data in r.iter_content(chunk_size=4096):
+                    bytes_so_far += len(data)
+                    f.write(data)
+                    self.progress_update.emit(total_bytes, bytes_so_far)
+
+            except requests.exceptions.SSLError:
+                if not self.common.settings['download_over_tor']:
+                    message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.').format(self.url.decode()) + "\n\n" + _('Try the download again using Tor?')
+                    self.download_error.emit('error_try_tor', message)
+                else:
+                    message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.'.format(self.url.decode()))
+                    self.download_error.emit('error', message)
+                return
+
+            except requests.exceptions.ConnectionError:
+                # Connection error
+                message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(self.url.decode())
+                self.download_error.emit('error', message)
+                # TODO: check for SSL error, also check if connecting over Tor if there's a socks5 error
+                return
+
+        self.download_complete.emit()
+
+
+class VerifyThread(QtCore.QThread):
+    """
+    Verify the signature in a separate thread
+    """
+    success = QtCore.pyqtSignal()
+    error = QtCore.pyqtSignal(str)
+
+    def __init__(self, common):
+        super(VerifyThread, self).__init__()
+        self.common = common
+
+    def run(self):
+        with gpg.Context() as c:
+            c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
+
+            sig = gpg.Data(file=self.common.paths['sig_file'])
+            signed = gpg.Data(file=self.common.paths['tarball_file'])
+
+            try:
+                c.verify(signature=sig, signed_data=signed)
+            except gpg.errors.BadSignatures as e:
+                result = str(e).split(": ")
+                if result[1] == 'No public key':
+                    self.common.refresh_keyring(result[0])
+                self.error.emit(str(e))
+            else:
+                self.success.emit()
+
+
+class ExtractThread(QtCore.QThread):
+    """
+    Extract the tarball in a separate thread
+    """
+    success = QtCore.pyqtSignal()
+    error = QtCore.pyqtSignal()
+
+    def __init__(self, common):
+        super(ExtractThread, self).__init__()
+        self.common = common
+
+    def run(self):
+        extracted = False
+        try:
+            if self.common.paths['tarball_file'][-2:] == 'xz':
+                # if tarball is .tar.xz
+                xz = lzma.LZMAFile(self.common.paths['tarball_file'])
+                tf = tarfile.open(fileobj=xz)
+                tf.extractall(self.common.paths['tbb']['dir'])
+                extracted = True
+            else:
+                # if tarball is .tar.gz
+                if tarfile.is_tarfile(self.common.paths['tarball_file']):
+                    tf = tarfile.open(self.common.paths['tarball_file'])
+                    tf.extractall(self.common.paths['tbb']['dir'])
+                    extracted = True
+        except:
+            pass
+
+        if extracted:
+            self.success.emit()
+        else:
+            self.error.emit()