1
0
Fork 0
mirror of synced 2024-06-02 02:34:40 +12:00

Nice Login window

This commit is contained in:
Dummerle 2020-11-25 22:23:06 +01:00
parent 7f3565e8c1
commit b17f2e52fe
10 changed files with 329 additions and 229 deletions

View file

@ -1,66 +1,6 @@
import os
from PyQt5.QtWidgets import QDialog, QStackedLayout, QHBoxLayout, QWidget, QVBoxLayout, QPushButton, QLineEdit, QLabel
from Rare.Login import ImportWidget, LoginWidget
class LoginDialog(QDialog):
def __init__(self):
self.code = 1
super(LoginDialog, self).__init__()
self.layout = QStackedLayout()
self.widget = QWidget()
self.import_widget = ImportWidget()
self.login_widget = LoginWidget()
self.login_widget.signal.connect(self.login_success)
self.import_widget.signal.connect(self.login_success)
self.initWidget()
self.layout.insertWidget(0, self.widget)
self.layout.insertWidget(1, self.login_widget)
self.layout.insertWidget(2, self.import_widget)
self.setLayout(self.layout)
self.layout.setCurrentIndex(0)
self.show()
def initWidget(self):
self.widget_layout = QVBoxLayout()
self.login_button = QPushButton("Login via Browser")
self.import_button = QPushButton("Import from existing EGL installation")
self.close_button = QPushButton("Exit")
# self.login_button.clicked.connect(self.login)
self.import_button.clicked.connect(self.set_import_widget)
self.login_button.clicked.connect(self.set_login_widget)
self.close_button.clicked.connect(self.exit_login)
self.widget_layout.addWidget(self.login_button)
self.widget_layout.addWidget(self.import_button)
self.widget_layout.addWidget(self.close_button)
self.widget.setLayout(self.widget_layout)
def login_success(self):
self.code = 0
self.close()
def get_login(self):
self.exec_()
return self.code
def set_login_widget(self):
self.layout.setCurrentIndex(1)
def set_import_widget(self):
self.layout.setCurrentIndex(2)
def exit_login(self):
self.code = 1
self.close()
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QVBoxLayout, QPushButton, QLineEdit, QLabel
class InstallDialog(QDialog):

View file

@ -1,3 +1,4 @@
import os
import subprocess
from logging import getLogger
@ -46,8 +47,12 @@ class GameWidget(QWidget):
# self.dev =
self.game_running = False
self.layout = QHBoxLayout()
pixmap = QPixmap(f"../images/{game.app_name}/FinalArt.png")
if os.path.exists(f"../images/{game.app_name}/FinalArt.png"):
pixmap = QPixmap(f"../images/{game.app_name}/FinalArt.png")
elif os.path.exists(f"../images/{game.app_name}/DieselGameBoxTall.png"):
pixmap = QPixmap(f"../images/{game.app_name}/DieselGameBoxTall.png")
elif os.path.exists(f"../images/{game.app_name}/DieselGameBoxLogo.png"):
pixmap = QPixmap(f"../images/{game.app_name}/DieselGameBoxLogo.png")
pixmap = pixmap.scaled(180, 240)
self.image = QLabel()
self.image.setPixmap(pixmap)
@ -99,7 +104,7 @@ class GameWidget(QWidget):
self.game_running = False
def kill(self):
self.proc.kill()
self.proc.terminate()
self.launch_button.setText("Launch")
self.game_running = False
logger.info("Killing Game")
@ -124,6 +129,8 @@ class UninstalledGameWidget(QWidget):
self.layout = QHBoxLayout()
self.game = game
pixmap = QPixmap(f"../images/{game.app_name}/UninstalledArt.png")
pixmap = pixmap.scaled(120, 160)
self.image = QLabel()

View file

@ -1,88 +1,166 @@
import os
import webbrowser
from getpass import getuser
from json import loads
from logging import getLogger
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QLineEdit, QPushButton, QCheckBox, QVBoxLayout, QWidget, QLabel
from PyQt5.QtCore import QUrl, pyqtSignal
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QDialog, QWidget, QVBoxLayout, QLabel, QPushButton, QStackedLayout
from legendary.core import LegendaryCore
from Rare.utils import legendaryUtils
logger = getLogger("LoginWindow")
logger = getLogger(name="login")
class LoginBrowser(QWebEngineView):
def __init__(self):
super(LoginBrowser, self).__init__()
def createWindow(self, QWebEnginePage_WebWindowType):
return self
class ImportWidget(QWidget):
signal = pyqtSignal()
signal = pyqtSignal(bool)
def __init__(self, core: LegendaryCore):
def __init__(self):
super(ImportWidget, self).__init__()
self.wine_paths = []
self.layout = QVBoxLayout()
self.core = core
self.use_lutris_button = QCheckBox("Use from Lutris")
self.insert_wine_prefix = QLineEdit()
self.info_text = QLabel("Use login from an existing EGL installation. You will logged out there")
self.import_text = QLabel("<h2>Import from existing Epic Games Launcher installation</h2>\nYou will get "
"logged out there")
self.import_accept_button = QPushButton("Import")
self.import_accept_button.clicked.connect(self.auth)
self.set_appdata_path()
self.appdata_path_text = QLabel(f"Appdata path: {self.core.egl.appdata_path}")
self.layout.addWidget(self.info_text)
if os.name != "nt":
self.insert_wine_prefix.setPlaceholderText("Or insert path of wineprefix")
self.layout.addWidget(self.use_lutris_button)
self.layout.addWidget(self.insert_wine_prefix)
self.import_button = QPushButton("Import Login from EGL")
self.import_button.clicked.connect(self.import_key)
self.text = QLabel()
self.layout.addWidget(self.text)
self.layout.addWidget(self.import_button)
self.layout.addWidget(self.import_text)
self.layout.addWidget(self.appdata_path_text)
self.layout.addStretch(1)
self.layout.addWidget(self.import_accept_button)
self.setLayout(self.layout)
def import_key(self):
wine_prefix = self.insert_wine_prefix.text()
if wine_prefix == "":
wine_prefix = None
def set_appdata_path(self):
if self.core.egl.appdata_path:
self.wine_paths.append(self.core.egl.appdata_path)
logger.info("Import Key")
if self.use_lutris_button.isChecked():
if legendaryUtils.auth_import(wine_prefix=wine_prefix,
lutris=self.use_lutris_button.isChecked()):
logger.info("Successful")
self.signal.emit()
else:
self.text.setText("Failed")
self.import_button.setText("Das hat nicht funktioniert")
else: # Linux
wine_paths = []
if os.path.exists(os.path.expanduser('~/Games/epic-games-store/drive_c/users')):
wine_paths.append(os.path.expanduser('~/Games/epic-games-store/drive_c/users'))
if os.path.exists(os.path.expanduser('~/.wine/drive_c/users')):
wine_paths.append(os.path.expanduser('~/.wine/drive_c/users'))
appdata_dirs = [os.path.join(i, getuser(),
'Local Settings/Application Data/EpicGamesLauncher',
'Saved/Config/Windows') for i in wine_paths]
if appdata_dirs == 0:
return
for i in appdata_dirs:
if os.path.exists(i):
self.wine_paths.append(i)
self.core.egl.appdata_path = wine_paths[0]
def auth(self):
self.import_accept_button.setDisabled(True)
for i, path in enumerate(self.wine_paths):
self.appdata_path_text.setText(f"Appdata path: {self.core.egl.appdata_path}")
self.core.egl.appdata_path = path
try:
if self.core.auth_import():
logger.info(f"Logged in as {self.core.lgd.userdata['displayName']}")
self.signal.emit(True)
return
else:
logger.warning("Error: No valid session found")
except:
logger.warning("Error: No valid session found")
self.signal.emit(False)
class LoginWidget(QWidget):
signal = pyqtSignal()
class LoginWindow(QDialog):
def __init__(self, core: LegendaryCore):
super(LoginWindow, self).__init__()
self.core = core
self.success_code = False
self.widget = QWidget()
self.setGeometry(0, 0, 200, 300)
self.welcome_layout = QVBoxLayout()
self.title = QLabel(
"<h2>Welcome to Rare the graphical interface for Legendary, an open source Epic Games alternative.</h2>\n<h3>Select one Option to Login</h3>")
self.browser_btn = QPushButton("Use browser to login")
self.browser_btn.clicked.connect(self.browser_login)
self.import_btn = QPushButton("Import from existing Epic Games installation")
def __init__(self):
super(LoginWidget, self).__init__()
self.layout = QVBoxLayout()
self.open_browser_button = QPushButton("Open Browser to get sid")
self.open_browser_button.clicked.connect(self.open_browser)
self.sid_field = QLineEdit()
self.sid_field.setPlaceholderText("Enter sid from the Browser")
self.login_button = QPushButton("Login")
self.login_button.clicked.connect(self.login)
self.login_text = QLabel("")
self.layout.addWidget(self.open_browser_button)
self.layout.addWidget(self.sid_field)
self.layout.addWidget(self.login_button)
self.layout.addWidget(self.login_text)
self.import_btn.clicked.connect(self.import_login)
self.text = QLabel("")
self.exit_btn = QPushButton("Exit App")
self.exit_btn.clicked.connect(self.exit_login)
self.welcome_layout.addWidget(self.title)
self.welcome_layout.addWidget(self.browser_btn)
self.welcome_layout.addWidget(self.import_btn)
self.welcome_layout.addWidget(self.text)
self.welcome_layout.addWidget(self.exit_btn)
self.widget.setLayout(self.welcome_layout)
self.browser = LoginBrowser()
self.browser.loadFinished.connect(self.check_for_sid_page)
self.import_widget = ImportWidget(self.core)
self.import_widget.signal.connect(self.import_resp)
self.layout = QStackedLayout()
self.layout.addWidget(self.widget)
self.layout.addWidget(self.browser)
self.layout.addWidget(self.import_widget)
self.setLayout(self.layout)
self.show()
def import_resp(self, b: bool):
if b:
self.success()
else:
self.layout.setCurrentIndex(0)
self.text.setText("<h4 style='color: red'>No valid session found</h4>")
def login(self):
print("Try to login")
self.login_text.setText("Try to login")
self.login_button.setDisabled(True)
if legendaryUtils.login(self.sid_field.text()):
self.signal.emit()
else:
self.login_text.setText("Login failed")
self.login_button.setDisabled(False)
self.exec_()
return self.success_code
def open_browser(self):
webbrowser.open(
'https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect'
)
def success(self):
self.success_code = True
self.close()
def retry(self):
self.__init__(self.core)
def exit_login(self):
self.code = 1
self.close()
def browser_login(self):
self.setGeometry(0, 0, 800, 600)
self.browser.load(QUrl(
'https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect'))
self.layout.setCurrentIndex(1)
def import_login(self):
self.layout.setCurrentIndex(2)
def check_for_sid_page(self):
if self.browser.url() == QUrl("https://www.epicgames.com/id/api/redirect"):
self.browser.page().toPlainText(self.browser_auth)
def browser_auth(self, json):
token = self.core.auth_sid(loads(json)["sid"])
if self.core.auth_code(token):
logger.info(f"Successfully logged in as {self.core.lgd.userdata['displayName']}")
self.success()
else:
self.layout.setCurrentIndex(0)
logger.warning("Login failed")
self.browser.close()
self.text.setText("Login Failed")

View file

@ -1,71 +1,37 @@
import logging
import sys
from PyQt5.QtWidgets import QTabWidget, QMainWindow, QWidget, QApplication
from PyQt5.QtWidgets import QApplication
from legendary.core import LegendaryCore
from Rare.Dialogs import LoginDialog
from Rare.TabWidgets import Settings, GameListInstalled, BrowserTab, GameListUninstalled, UpdateList
from Rare.utils import legendaryUtils, RareConfig
from Rare.utils.RareUtils import download_images
from Rare.Login import LoginWindow
from Rare.MainWindow import MainWindow
logging.basicConfig(
format='[%(name)s] %(levelname)s: %(message)s',
level=logging.INFO
)
logger = logging.getLogger("Rare")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Rare - GUI for legendary-gl")
self.setGeometry(0, 0, 1200, 900)
self.setCentralWidget(TabWidget(self))
self.show()
class TabWidget(QTabWidget):
def __init__(self, parent):
super(QWidget, self).__init__(parent)
self.game_list = GameListInstalled(self)
self.addTab(self.game_list, "Games")
self.uninstalled_games = GameListUninstalled(self)
self.addTab(self.uninstalled_games, "Install Games")
self.update_tab = UpdateList(self)
self.addTab(self.update_tab, "Updates")
self.browser = BrowserTab(self)
self.addTab(self.browser, "Store")
self.settings = Settings(self)
self.addTab(self.settings, "Settings")
core = LegendaryCore()
def main():
app = QApplication(sys.argv)
# print(RareConfig.get_config())
logger.info("Try if you are logged in")
try:
if legendaryUtils.core.login():
logger.info("Login credentials found")
except:
logger.info("No login data found")
dia = LoginDialog()
code = dia.get_login()
if code == 1:
app.closeAllWindows()
logger.info("Exit login")
exit(0)
elif code == 0:
logger.info("Login successfully")
download_images()
window = MainWindow()
app.exec_()
core.login()
logger.info("You are logged in")
except ValueError:
logger.info("You ar not logged in. Open Login Window")
login_window = LoginWindow(core)
if not login_window.login():
return
mainwindow = MainWindow(core)
sys.exit(app.exec_())
if __name__ == '__main__':

93
Rare/MainOld.py Normal file
View file

@ -0,0 +1,93 @@
import logging
import sys
from PyQt5.QtCore import QThread, pyqtSignal
from PyQt5.QtWidgets import QTabWidget, QMainWindow, QWidget, QApplication, QDialog, QLabel, QProgressBar, QVBoxLayout
from Rare.Dialogs import LoginDialog
from Rare.TabWidgets import Settings, GameListInstalled, BrowserTab, GameListUninstalled, UpdateList
from Rare.utils import legendaryUtils
from Rare.utils.RareUtils import download_images
logging.basicConfig(
format='[%(name)s] %(levelname)s: %(message)s',
level=logging.INFO
)
logger = logging.getLogger("Rare")
class LaunchThread(QThread):
download_progess = pyqtSignal(int)
action = pyqtSignal(str)
def __init__(self, parent=None):
super(LaunchThread, self).__init__(parent)
def run(self):
self.action.emit("Login")
self.action.emit("Downloading Images")
download_images(self.download_progess)
self.action.emit("finish")
class LaunchDialog(QDialog):
def __init__(self):
super(LaunchDialog, self).__init__()
try:
if legendaryUtils.core.login():
self.title = QLabel("<h3>Launching Rare</h3>")
self.thread = LaunchThread(self)
self.thread.download_progess.connect(self.update_pb)
self.thread.action.connect(self.info)
self.info_pb = QProgressBar()
self.info_pb.setMaximum(len(legendaryUtils.get_games()))
self.info_text = QLabel("Logging in")
self.layout = QVBoxLayout()
self.layout.addWidget(self.title)
self.layout.addWidget(self.info_pb)
self.layout.addWidget(self.info_text)
self.setLayout(self.layout)
self.thread.start()
except:
logger.info("No login data found")
dia = LoginDialog()
code = dia.get_login()
if code == 1:
self.app.closeAllWindows()
logger.info("Exit login")
exit(0)
elif code == 0:
logger.info("Login successfully")
def update_pb(self, i: int):
self.info_pb.setValue(i)
def info(self, text: str):
if text == "finish":
self.close()
self.info_text.setText(text)
class Main():
def __init__(self):
self.app = QApplication(sys.argv)
self.launch_dia = LaunchDialog()
self.launch_dia.exec_()
self.window = MainWindow()
self.app.exec_()
def start(self):
pass
def main():
Main()
if __name__ == '__main__':
main()

34
Rare/MainWindow.py Normal file
View file

@ -0,0 +1,34 @@
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QWidget
from Rare.TabWidgets import GameListInstalled, GameListUninstalled, UpdateList, BrowserTab, Settings
class MainWindow(QMainWindow):
def __init__(self, core):
super().__init__()
self.setWindowTitle("Rare - GUI for legendary-gl")
self.setGeometry(0, 0, 1200, 900)
self.setCentralWidget(TabWidget(self, core))
self.show()
class TabWidget(QTabWidget):
def __init__(self, parent, core):
super(QWidget, self).__init__(parent)
self.game_list = GameListInstalled(self)
self.addTab(self.game_list, "Games")
self.uninstalled_games = GameListUninstalled(self)
self.addTab(self.uninstalled_games, "Install Games")
self.update_tab = UpdateList(self)
self.addTab(self.update_tab, "Updates")
self.browser = BrowserTab(self)
self.addTab(self.browser, "Store")
self.settings = Settings(self)
self.addTab(self.settings, "Settings")

View file

@ -3,8 +3,7 @@ import signal
from logging import getLogger
from PyQt5.QtCore import QUrl, Qt
from PyQt5.QtNetwork import QNetworkCookie
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea, QLineEdit, QPushButton, QFormLayout, QGroupBox, \
QComboBox, QHBoxLayout, QTableWidget, QTableWidgetItem
@ -18,34 +17,13 @@ logger = getLogger("TabWidgets")
class BrowserTab(QWebEngineView):
def __init__(self, parent):
super(BrowserTab, self).__init__(parent=parent)
self.profile = QWebEngineProfile("storage", self)
self.cookie_store = self.profile.cookieStore()
self.cookie_store.cookieAdded.connect(self.on_cookie_added)
self.cookies = []
self.webpage = QWebEnginePage(self.profile, self)
self.setPage(self.webpage)
self.load(QUrl("https://www.epicgames.com/store/"))
self.show()
print(self.cookies)
def createWindow(self, QWebEnginePage_WebWindowType):
return self
def on_cookie_added(self, keks):
for c in self.cookies:
if c.hasSameIdentifier(keks):
return
self.cookies.append(QNetworkCookie(keks))
self.toJson()
def toJson(self):
cookies_list_info = []
for c in self.cookies:
data = {"name": bytearray(c.name()).decode(), "domain": c.domain(), "value": bytearray(c.value()).decode(),
"path": c.path(), "expirationDate": c.expirationDate().toString(Qt.ISODate), "secure": c.isSecure(),
"httponly": c.isHttpOnly()}
cookies_list_info.append(data)
class Settings(QScrollArea):
def __init__(self, parent):
@ -197,6 +175,7 @@ class GameListInstalled(QScrollArea):
def remove_game(self, app_name: str):
logger.info(f"Uninstall {app_name}")
self.widgets[app_name].setVisible(False)
self.layout.removeWidget(self.widgets[app_name])
self.widgets[app_name].deleteLater()
self.widgets.pop(app_name)
@ -213,8 +192,7 @@ class GameListUninstalled(QScrollArea):
self.filter = QLineEdit()
self.filter.textChanged.connect(self.filter_games)
self.filter.setPlaceholderText("Search game TODO")
# TODO Search Game
self.filter.setPlaceholderText("Filter Games")
self.layout.addWidget(self.filter)
self.widgets_uninstalled = []

View file

@ -3,20 +3,22 @@ from logging import getLogger
import requests
from PIL import Image
from PyQt5.QtCore import pyqtSignal
from Rare.utils import legendaryUtils
logger = getLogger("Utils")
def download_images():
def download_images(signal: pyqtSignal):
IMAGE_DIR = "../images"
if not os.path.isdir(IMAGE_DIR):
os.mkdir(IMAGE_DIR)
logger.info("Create Image dir")
# Download Images
for game in legendaryUtils.get_games():
for i, game in enumerate(legendaryUtils.get_games()):
for image in game.metadata["keyImages"]:
if image["type"] == "DieselGameBoxTall" or image["type"] == "DieselGameBoxLogo":
if not os.path.isfile(f"{IMAGE_DIR}/{game.app_name}/{image['type']}.png"):
@ -29,12 +31,20 @@ def download_images():
f.write(requests.get(url).content)
f.close()
if not os.path.isfile(f"{IMAGE_DIR}/{game.app_name}/FinalArt.png"):
logger.info("Scaling cover for " + game.app_name)
if os.path.isfile(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo"):
bg: Image.Image = Image.open(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo")
if not os.path.isfile(f'{IMAGE_DIR}/' + game.app_name + '/UninstalledArt.png'):
if os.path.isfile(f'{IMAGE_DIR}/' + game.app_name + '/DieselGameBoxTall.png'):
# finalArt = Image.open(f'{IMAGE_DIR}/' + game.app_name + '/DieselGameBoxTall.png')
# finalArt.save(f'{IMAGE_DIR}/{game.app_name}/FinalArt.png')
# And same with the grayscale one
bg = Image.open(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png")
uninstalledArt = bg.convert('L')
uninstalledArt.save(f'{IMAGE_DIR}/{game.app_name}/UninstalledArt.png')
elif os.path.isfile(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png"):
bg: Image.Image = Image.open(f"{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png")
bg = bg.resize((int(bg.size[1] * 3 / 4), bg.size[1]))
logo = Image.open('images/' + game["app_name"] + '/DieselGameBoxLogo.png').convert('RGBA')
logo = Image.open(f'{IMAGE_DIR}/{game.app_name}/DieselGameBoxLogo.png').convert('RGBA')
wpercent = ((bg.size[0] * (3 / 4)) / float(logo.size[0]))
hsize = int((float(logo.size[1]) * float(wpercent)))
logo = logo.resize((int(bg.size[0] * (3 / 4)), hsize), Image.ANTIALIAS)
@ -42,10 +52,10 @@ def download_images():
pasteX = int((bg.size[0] - logo.size[0]) / 2)
pasteY = int((bg.size[1] - logo.size[1]) / 2)
# And finally copy the background and paste in the image
finalArt = bg.copy()
finalArt.paste(logo, (pasteX, pasteY), logo)
# finalArt = bg.copy()
# finalArt.paste(logo, (pasteX, pasteY), logo)
# Write out the file
finalArt.save(f'{IMAGE_DIR}/' + game.app_name + '/FinalArt.png')
# finalArt.save(f'{IMAGE_DIR}/' + game.app_name + '/FinalArt.png')
logoCopy = logo.copy()
logoCopy.putalpha(int(256 * 3 / 4))
logo.paste(logoCopy, logo)
@ -54,12 +64,5 @@ def download_images():
uninstalledArt = uninstalledArt.convert('L')
uninstalledArt.save(f'{IMAGE_DIR}/' + game.app_name + '/UninstalledArt.png')
else:
# We just open up the background and save that as the final image
if os.path.isfile(f'{IMAGE_DIR}/' + game.app_name + '/DieselGameBoxTall.png'):
finalArt = Image.open(f'{IMAGE_DIR}/' + game.app_name + '/DieselGameBoxTall.png')
finalArt.save(f'{IMAGE_DIR}/{game.app_name}/FinalArt.png')
# And same with the grayscale one
uninstalledArt = finalArt.convert('L')
uninstalledArt.save(f'{IMAGE_DIR}/{game.app_name}/UninstalledArt.png')
else:
logger.warning(f"File {IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png dowsn't exist")
logger.warning(f"File {IMAGE_DIR}/{game.app_name}/DieselGameBoxTall.png dowsn't exist")
signal.emit(i)

View file

@ -167,6 +167,7 @@ def get_name():
def uninstall(app_name: str):
return
core.uninstall_game(core.get_installed_game(app_name), True, True)
# logger.info("Uninstalling " + app_name)

View file

@ -1,4 +1,4 @@
requests==2.25.0
legendary_gl==0.20.1
Pillow==8.0.1
PyQt5==5.15.1
requests>=2.25.0
legendary_gl>=0.20.3
Pillow>=8.0.1
PyQt5>=5.15.2