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