diff --git a/app/common/config.py b/app/common/config.py index b276bc0f..31044abf 100644 --- a/app/common/config.py +++ b/app/common/config.py @@ -4,7 +4,8 @@ from PyQt5.QtCore import QLocale from qfluentwidgets import (qconfig, QConfig, ConfigItem, FolderValidator, BoolValidator, - OptionsConfigItem, OptionsValidator, ConfigSerializer, RangeConfigItem, RangeValidator) + OptionsConfigItem, OptionsValidator, ConfigSerializer, RangeConfigItem, RangeValidator, + EnumSerializer) class Language(Enum): @@ -76,6 +77,15 @@ class Config(QConfig): enableCloseToTray = ConfigItem( "General", "EnableCloseToTray", None, OptionsValidator([None, True, False])) + + searchHistory = ConfigItem( + "Other", "SearchHistory", "" + ) + + enableCheckUpdate = ConfigItem("General", + "EnableCheckUpdate", True, + BoolValidator()) + # enableCopyPlayersInfo = ConfigItem("Functions", "EnableCopyPlayersInfo", # False, BoolValidator()) diff --git a/app/common/icons.py b/app/common/icons.py index dd077a32..3d77845f 100644 --- a/app/common/icons.py +++ b/app/common/icons.py @@ -42,6 +42,7 @@ class Icon(FluentIconBase, Enum): TEAM = 'Team' SETTING = 'Setting' FILTER = 'Filter' + UPDATE = 'Update' def path(self, theme=Theme.AUTO): return f'./app/resource/icons/{self.value}_{getIconColor(theme)}.svg' diff --git a/app/common/util.py b/app/common/util.py new file mode 100644 index 00000000..960e357e --- /dev/null +++ b/app/common/util.py @@ -0,0 +1,28 @@ +import requests + +from app.common.config import cfg, VERSION + + +class Github: + def __init__(self, user="Zzaphkiel", repositories="Seraphine"): + self.API = "http://api.github.com" + + self.user = user + self.repositories = repositories + self.sess = requests.session() + + def getReleasesInfo(self): + url = f"{self.API}/repos/{self.user}/{self.repositories}/releases/latest" + return self.sess.get(url, verify=False).json() + + def checkUpdate(self): + """ + 检查版本更新 + @return: 有更新 -> info, 无更新 -> None + """ + info = self.getReleasesInfo() + if info.get("tag_name")[1:] != VERSION: + return info + return None + +github = Github() diff --git a/app/components/search_line_edit.py b/app/components/search_line_edit.py new file mode 100644 index 00000000..6ea29a75 --- /dev/null +++ b/app/components/search_line_edit.py @@ -0,0 +1,91 @@ +from PyQt5.QtCore import Qt, QEvent, QAbstractItemModel +from PyQt5.QtWidgets import QCompleter, QAction +from PyQt5.uic.properties import QtCore, QtGui +from qfluentwidgets import SearchLineEdit as QSearchLineEdit +from qfluentwidgets.components.widgets.line_edit import CompleterMenu + +from app.common.config import cfg + + +class MyCompleterMenu(CompleterMenu): + + def eventFilter(self, obj, e): + if e.type() != QEvent.KeyPress: + return super().eventFilter(obj, e) + + # redirect input to line edit + self.lineEdit.event(e) + self.view.event(e) + + if e.key() == Qt.Key_Escape: + self.close() + if e.key() in [Qt.Key_Enter, Qt.Key_Return]: + self.lineEdit.searchButton.click() + self.close() + + return True + + def setCompletion(self, model: QAbstractItemModel): + """ set the completion model """ + items = [] + for i in range(model.rowCount()): + for j in range(model.columnCount()): + items.append(model.data(model.index(i, j))) + + if self.items == items and self.isVisible(): + return False + + self.clear() + self.items = items + + # add items + for i in items: + self.addAction(QAction(i, triggered=lambda c, x=i: self.__onItemSelected(x))) + + return True + + def __onItemSelected(self, text): + self.lineEdit.setText(text) + self.activated.emit(text) + self.lineEdit.searchButton.click() + + +class SearchLineEdit(QSearchLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + + completer = QCompleter([], self) + completer.setCaseSensitivity(Qt.CaseInsensitive) + completer.setMaxVisibleItems(10) + completer.setFilterMode(Qt.MatchFlag.MatchFixedString) + completer.setCompletionRole(Qt.DisplayRole) + completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) + self.setCompleter(completer) + + def _showCompleterMenu(self): + if not self.completer(): + return + + model = cfg.get(cfg.searchHistory) + if not model: + return + + model = model.split(",") + self.completer().model().setStringList(model) + + # create menu + if not self._completerMenu: + self._completerMenu = MyCompleterMenu(self) + self._completerMenu.activated.connect(self._completer.activated) + + # add menu items + changed = self._completerMenu.setCompletion(self.completer().completionModel()) + self._completerMenu.setMaxVisibleItems(self.completer().maxVisibleItems()) + + # show menu + if changed: + self._completerMenu.popup() + + def focusInEvent(self, e): + self._showCompleterMenu() + super().focusInEvent(e) diff --git a/app/components/update_message_box.py b/app/components/update_message_box.py new file mode 100644 index 00000000..e86c8cb7 --- /dev/null +++ b/app/components/update_message_box.py @@ -0,0 +1,32 @@ +from PyQt5.QtWidgets import QLabel +from qfluentwidgets import MessageBox, MessageBoxBase, SmoothScrollArea, SubtitleLabel, BodyLabel, TextEdit, TitleLabel, \ + CheckBox + +from app.common.config import VERSION, cfg + + +class UpdateMessageBox(MessageBoxBase): + def __init__(self, info, parent=None): + super().__init__(parent=parent) + self.titleLabel = TitleLabel(self.tr('Update detected'), self) + self.titleLabel.setContentsMargins(5, 0, 5, 0) + self.content = BodyLabel(self.tr(f'{VERSION} -> {info.get("tag_name")[1:]}'), self) + self.content.setContentsMargins(8, 0, 5, 0) + + textEdit = TextEdit(self) + textEdit.setFixedWidth(int(self.width() * .6)) + textEdit.setMarkdown(self.tr(info.get("body"))) + textEdit.setReadOnly(True) + + checkBox = CheckBox() + checkBox.setText("Don't remind me again") + checkBox.clicked.connect(lambda: cfg.set(cfg.enableCheckUpdate, not checkBox.isChecked(), True)) + + self.viewLayout.addWidget(self.titleLabel) + self.viewLayout.addWidget(self.content) + self.viewLayout.addWidget(textEdit) + self.viewLayout.addWidget(checkBox) + + self.yesButton.setText("Download") + + diff --git a/app/lol/connector.py b/app/lol/connector.py index 60d6b5bd..47b250a0 100644 --- a/app/lol/connector.py +++ b/app/lol/connector.py @@ -12,11 +12,41 @@ requests.packages.urllib3.disable_warnings() +def slowly(): + def decorator(func): + def wrapper(*args, **kwargs): + while connector.tackleFlag.is_set(): + time.sleep(.2) + + connector.slowlyFlag.set() + res = func(*args, **kwargs) + connector.slowlyFlag.clear() + return res + return wrapper + return decorator + + +def tackle(): + def decorator(func): + def wrapper(*args, **kwargs): + connector.tackleFlag.set() + res = func(*args, **kwargs) + connector.tackleFlag.clear() + return res + return wrapper + return decorator + + def retry(count=5, retry_sep=0.5): def decorator(func): def wrapper(*args, **kwargs): for _ in range(count): try: + # 低优先级请求未结束时, 避免server队列过长 + # 若负载过高导致请求失败, 则在触发 retry 间隙为高优先级请求让行 + while connector.slowlyFlag.is_set(): + time.sleep(.2) + res = func(*args, **kwargs) except: time.sleep(retry_sep) @@ -24,6 +54,8 @@ def wrapper(*args, **kwargs): else: break else: + # FIXME 任何异常都将以 timeout 抛出 + connector.timeoutApi = func.__name__ raise Exception("Exceeded maximum retry attempts.") return res @@ -39,9 +71,12 @@ def __init__(self): self.token = None self.url = None - self.flag = threading.Event() + self.tackleFlag = threading.Event() + self.slowlyFlag = threading.Event() self.manager = None + self.timeoutApi = None + def start(self, pid): process = psutil.Process(pid) cmdline = process.cmdline() @@ -206,11 +241,9 @@ def getSummonerByPuuid(self, puuid): return res - @retry() + @slowly() + @retry(10, 1) def getSummonerGamesByPuuidSlowly(self, puuid, begIndex=0, endIndex=4): - while self.flag.is_set(): - time.sleep(.2) - params = {"begIndex": begIndex, "endIndex": endIndex} res = self.__get( f"/lol-match-history/v1/products/lol/{puuid}/matches", params @@ -221,9 +254,9 @@ def getSummonerGamesByPuuidSlowly(self, puuid, begIndex=0, endIndex=4): return res["games"] + @tackle() @retry() def getSummonerGamesByPuuid(self, puuid, begIndex=0, endIndex=4): - self.flag.set() params = {"begIndex": begIndex, "endIndex": endIndex} res = self.__get( f"/lol-match-history/v1/products/lol/{puuid}/matches", params @@ -232,7 +265,6 @@ def getSummonerGamesByPuuid(self, puuid, begIndex=0, endIndex=4): if "games" not in res: raise SummonerGamesNotFound() - self.flag.clear() return res["games"] @retry() diff --git a/app/view/main_window.py b/app/view/main_window.py index bc368907..f10573f6 100644 --- a/app/view/main_window.py +++ b/app/view/main_window.py @@ -3,6 +3,7 @@ import sys import traceback import time +import webbrowser from collections import Counter from PyQt5.QtCore import Qt, pyqtSignal, QSize, QAbstractAnimation @@ -20,10 +21,12 @@ from .search_interface import SearchInterface from .game_info_interface import GameInfoInterface from .auxiliary_interface import AuxiliaryInterface +from ..common.util import Github, github from ..components.avatar_widget import NavigationAvatarWidget from ..components.temp_system_tray_menu import TmpSystemTrayMenu from ..common.icons import Icon -from ..common.config import cfg +from ..common.config import cfg, VERSION +from ..components.update_message_box import UpdateMessageBox from ..lol.entries import Summoner from ..lol.listener import (LolProcessExistenceListener, LolClientEventListener, getLolProcessPid) @@ -38,6 +41,9 @@ class MainWindow(FluentWindow): nameOrIconChanged = pyqtSignal(str, str) lolInstallFolderChanged = pyqtSignal(str) + showUpdateMessageBox = pyqtSignal(dict) + checkUpdateFailed = pyqtSignal() + showLcuConnectTimeout = pyqtSignal(str) def __init__(self): super().__init__() @@ -71,6 +77,8 @@ def __init__(self): self.__conncetSignalToSlot() self.splashScreen.finish() + threading.Thread(target=self.checkUpdate).start() + threading.Thread(target=self.pollingConnectTimeout).start() def __initInterface(self): self.__lockInterface() @@ -134,6 +142,9 @@ def __conncetSignalToSlot(self): self.nameOrIconChanged.connect(self.__onNameOrIconChanged) self.lolInstallFolderChanged.connect(self.__onLolInstallFolderChanged) + self.showUpdateMessageBox.connect(self.__onShowUpdateMessageBox) + self.checkUpdateFailed.connect(self.__onCheckUpdateFailed) + self.showLcuConnectTimeout.connect(self.__onShowLcuConnectTimeout) self.careerInterface.searchButton.clicked.connect( self.__onCareerInterfaceHistoryButtonClicked) @@ -195,6 +206,47 @@ def __initWindow(self): self.oldHook = sys.excepthook sys.excepthook = self.exceptHook + def __onShowLcuConnectTimeout(self, api): + InfoBar.error( + self.tr("LCU request timeout"), + self.tr(f"Connect API {api} request timeout."), + duration=5000, + parent=self, + position=InfoBarPosition.BOTTOM_RIGHT + ) + + def checkUpdate(self): + if cfg.get(cfg.enableCheckUpdate): + try: + releasesInfo = github.checkUpdate() + except: + self.checkUpdateFailed.emit() + else: + if releasesInfo: + self.showUpdateMessageBox.emit(releasesInfo) + + def __onCheckUpdateFailed(self): + InfoBar.warning( + self.tr("Check Update Failed"), + self.tr("Failed to check for updates, possibly unable to connect to Github."), + duration=5000, + parent=self, + position=InfoBarPosition.BOTTOM_RIGHT + ) + + def __onShowUpdateMessageBox(self, info): + msgBox = UpdateMessageBox(info, self.window()) + if msgBox.exec(): + webbrowser.open(info.get("zipball_url")) + + def pollingConnectTimeout(self): + while True: + if connector.timeoutApi: + self.showLcuConnectTimeout.emit(connector.timeoutApi) + connector.timeoutApi = None + + time.sleep(.5) + def __initSystemTray(self): self.trayIcon = QSystemTrayIcon(self) self.trayIcon.setToolTip("Seraphine") @@ -491,13 +543,13 @@ def __onCareerInterfaceHistoryButtonClicked(self): summonerName = self.careerInterface.name.text() self.searchInterface.searchLineEdit.setText(summonerName) - self.searchInterface.searchButton.clicked.emit() + self.searchInterface.searchLineEdit.searchButton.clicked.emit() self.checkAndSwitchTo(self.searchInterface) def __onGameInfoInterfaceGamesSummonerNameClicked(self, name): self.searchInterface.searchLineEdit.setText(name) - self.searchInterface.searchButton.clicked.emit() + self.searchInterface.searchLineEdit.searchButton.clicked.emit() self.checkAndSwitchTo(self.searchInterface) @@ -1076,7 +1128,7 @@ def __onCareerInterfaceGameInfoBarClicked(self, gameId): name = self.careerInterface.name.text() self.searchInterface.searchLineEdit.setText(name) self.searchInterface.gamesView.gamesTab.triggerGameId = gameId - self.searchInterface.searchButton.click() + self.searchInterface.searchLineEdit.searchButton.click() def __onCareerInterfaceRefreshButtonClicked(self): self.__onSearchInterfaceSummonerNameClicked( diff --git a/app/view/search_interface.py b/app/view/search_interface.py index 1f3324d4..b2315bb4 100644 --- a/app/view/search_interface.py +++ b/app/view/search_interface.py @@ -3,7 +3,7 @@ import pyperclip from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QFrame, - QSpacerItem, QSizePolicy, QLabel, QStackedWidget, QWidget) + QSpacerItem, QSizePolicy, QLabel, QStackedWidget, QWidget, QCompleter) from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QPixmap from qfluentwidgets import (SmoothScrollArea, LineEdit, PushButton, ToolButton, InfoBar, @@ -16,6 +16,7 @@ from ..common.config import cfg from ..components.champion_icon_widget import RoundIcon from ..components.mode_filter_widget import ModeFilterWidget +from ..components.search_line_edit import SearchLineEdit from ..components.summoner_name_button import SummonerName from ..lol.connector import LolClientConnector, connector from ..lol.tools import processGameData, processGameDetailData @@ -999,8 +1000,9 @@ def __init__(self, parent=None): self.vBoxLayout = QVBoxLayout(self) self.searchLayout = QHBoxLayout() - self.searchLineEdit = LineEdit() - self.searchButton = PushButton(self.tr("Search 🔍")) + # self.searchLineEdit = LineEdit() + self.searchLineEdit = SearchLineEdit() + # self.searchButton = PushButton(self.tr("Search 🔍")) self.careerButton = PushButton(self.tr("Career")) self.filterComboBox = ComboBox() @@ -1019,7 +1021,11 @@ def __initWidget(self): self.careerButton.setEnabled(False) self.filterComboBox.setEnabled(False) - self.searchButton.setShortcut("Return") + # self.searchLineEdit.searchButton.setShortcut("Return") + # self.searchLineEdit.searchButton.setShortcut("Key_Enter") + # self.searchLineEdit.searchButton.setShortcut(Qt.Key_Return) + self.searchLineEdit.searchButton.setShortcut(Qt.Key_Enter) + # self.searchButton.setShortcut("Return") StyleSheet.SEARCH_INTERFACE.apply(self) @@ -1032,10 +1038,11 @@ def __initWidget(self): ]) self.filterComboBox.setCurrentIndex(0) + def __initLayout(self): self.searchLayout.addWidget(self.searchLineEdit) self.searchLayout.addSpacing(5) - self.searchLayout.addWidget(self.searchButton) + # self.searchLayout.addWidget(self.searchButton) self.searchLayout.addWidget(self.careerButton) self.searchLayout.addWidget(self.filterComboBox) @@ -1051,6 +1058,12 @@ def __onSearchButtonClicked(self): if targetName == "": return + history = cfg.get(cfg.searchHistory).split(",") + if targetName in history: + history.remove(targetName) + history.insert(0, targetName) + cfg.set(cfg.searchHistory, ",".join([t for t in history if t])[:10], True) # 过滤空值, 只存十个 + if self.loadGamesThread and self.loadGamesThread.is_alive(): self.loadGamesThreadStop.set() @@ -1121,7 +1134,8 @@ def __onSummonerPuuidGetted(self, puuid): self.__showSummonerNotFoundMessage() def __connectSignalToSlot(self): - self.searchButton.clicked.connect(self.__onSearchButtonClicked) + self.searchLineEdit.searchButton.clicked.connect(self.__onSearchButtonClicked) + # self.searchButton.clicked.connect(self.__onSearchButtonClicked) self.summonerPuuidGetted.connect(self.__onSummonerPuuidGetted) self.filterComboBox.currentIndexChanged.connect( self.__onFilterComboBoxChanged) @@ -1147,7 +1161,7 @@ def setEnabled(self, a0: bool) -> None: self.searchLineEdit.clear() self.searchLineEdit.setEnabled(a0) - self.searchButton.setEnabled(a0) + self.searchLineEdit.searchButton.setEnabled(a0) if not a0: self.filterComboBox.setEnabled(a0) diff --git a/app/view/setting_interface.py b/app/view/setting_interface.py index dfceab32..17223104 100644 --- a/app/view/setting_interface.py +++ b/app/view/setting_interface.py @@ -70,7 +70,6 @@ def __init__(self, parent=None): tr("Setting the maximum number of games shows in the career interface" ), self.functionGroup) - # TODO 逻辑 self.gameInfoFilterCard = SwitchSettingCard( Icon.FILTER, self.tr("Rank filter other mode"), self.tr( @@ -91,6 +90,14 @@ def __init__(self, parent=None): self.tr("Client Path"), cfg.get(cfg.lolFolder), self.generalGroup) + + self.checkUpdateCard = SwitchSettingCard( + Icon.UPDATE, self.tr("Check for updates"), + self.tr( + "Automatically check for updates when software starts"), + cfg.enableCheckUpdate + ) + # self.enableStartWithComputer = SwitchSettingCard( # Icon.DESKTOPRIGHT, # self.tr("Auto-start on boot"), @@ -209,6 +216,7 @@ def __initLayout(self): self.generalGroup.addSettingCard(self.enableStartLolWithApp) self.generalGroup.addSettingCard(self.deleteResourceCard) self.generalGroup.addSettingCard(self.enableCloseToTray) + self.generalGroup.addSettingCard(self.checkUpdateCard) self.personalizationGroup.addSettingCard(self.micaCard) self.personalizationGroup.addSettingCard(self.themeCard)