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