diff --git a/.gitignore b/.gitignore index 2ce535a..2be6b01 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea/ .vscode/ +working/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -15,6 +16,7 @@ __pycache__/ # Distribution / packaging .Python +.venv/ env/ venv/ bin/ @@ -65,3 +67,4 @@ docs/_build/ wiki/ old/ nparse.config.json + diff --git a/README.md b/README.md index 155023a..be186a2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,6 @@ Building ======== Currently only python 3.8.x is supported. Python 3.10.x has known issues that must be resolved. -Install `pyinstaller==4.3` and `pyinstaller-hooks-contrib==2020.7` +Install `pyinstaller==5.3` -Run: `pyinstaller --onefile nparse_py.spec` \ No newline at end of file +Run: `pyinstaller nparse_py.spec` \ No newline at end of file diff --git a/data/maps/map_files/Crystal_1.txt b/data/maps/map_files/Crystal_1.txt index b16d2de..070355c 100644 --- a/data/maps/map_files/Crystal_1.txt +++ b/data/maps/map_files/Crystal_1.txt @@ -1,4 +1,4 @@ -P -298.0000, 184.0000, 0.0000, 127, 64, 0, 2, Bank +P -298.0000, 192.0000, -384, 127, 64, 0, 2, Bank P -758.0000, 265.0000, 0.0000, 127, 64, 0, 2, Broken_Bridge P 939.1472, 589.2308, -538.4664, 127, 0, 0, 2, Queen P -692.0000, 176.0000, 0.0000, 127, 64, 0, 2, Waterfall diff --git a/helpers/__init__.py b/helpers/__init__.py index a0b67ca..36a94f8 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -3,8 +3,12 @@ import math import requests import json + +import psutil + from datetime import datetime, timedelta +from .parser import Parser # noqa: F401 from .parser import ParserWindow # noqa: F401 @@ -100,3 +104,31 @@ def text_time_to_seconds(text_time): pass return timedelta(hours=hours, minutes=minutes, seconds=seconds).total_seconds() + + +def get_eqgame_pid_list() -> list[int]: + """ + get list of process ID's for eqgame.exe, using psutil module + + Returns: + object: list of process ID's (in case multiple versions of eqgame.exe are somehow running) + """ + + pid_list = list() + for p in psutil.process_iter(['name']): + if p.info['name'] == 'eqgame.exe': + pid_list.append(p.pid) + return pid_list + + +def starprint(line: str) -> None: + """ + utility function to print with leading and trailing ** indicators + + Args: + line: line to be printed + + Returns: + None: + """ + print(f'** {line.rstrip():<100} **') diff --git a/helpers/config.py b/helpers/config.py index 2b539b1..e885d1e 100644 --- a/helpers/config.py +++ b/helpers/config.py @@ -5,8 +5,10 @@ from glob import glob import json +# global data data = {} _filename = '' +char_name = '' def load(filename): @@ -287,7 +289,22 @@ def verify_settings(): False ) - + # deathloopvaccine + data['deathloopvaccine'] = data.get('deathloopvaccine', {}) + data['deathloopvaccine']['toggled'] = get_setting( + data['deathloopvaccine'].get('toggled', True), + True + ) + data['deathloopvaccine']['deaths'] = get_setting( + data['deathloopvaccine'].get('deaths', 4), + 4 + ) + data['deathloopvaccine']['seconds'] = get_setting( + data['deathloopvaccine'].get('seconds', 120), + 120 + ) + + def get_setting(setting, default, func=None): try: assert(type(setting) == type(default)) diff --git a/helpers/location_service.py b/helpers/location_service.py index 7a3612b..bb87e56 100644 --- a/helpers/location_service.py +++ b/helpers/location_service.py @@ -1,6 +1,7 @@ import json import logging import ssl +import threading import time from PyQt5.QtCore import QObject, QRunnable, pyqtSignal, pyqtSlot @@ -24,7 +25,8 @@ class LocationSignals(QObject): config_updated = pyqtSignal() -RUN = True +RUN = threading.Event() +RUN.set() SIGNALS = LocationSignals() THREADPOOL = QThreadPool() _LSC = None @@ -50,8 +52,8 @@ def start_location_service(update_func): def stop_location_service(): - global RUN - RUN = False + RUN.clear() + print("Stopping location service.") lsc = get_location_service_connection() lsc.enabled = False lsc.configure_socket() @@ -103,7 +105,7 @@ def configure_socket(self): @pyqtSlot() def run(self): - while RUN: + while RUN.is_set(): try: self.configure_socket() except: @@ -113,8 +115,8 @@ def run(self): try: self._socket.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) except: - print("Socket connection brokem continuing...") - if RUN: + print("Socket connection broken, continuing...") + if RUN.is_set(): time.sleep(self.reconnect_delay) @property diff --git a/helpers/logreader.py b/helpers/logreader.py index a9ed677..d1b84e4 100644 --- a/helpers/logreader.py +++ b/helpers/logreader.py @@ -45,9 +45,9 @@ def _file_changed_safe_wrap(self, changed_file): def _file_changed(self, changed_file): if changed_file != self._stats['log_file']: self._stats['log_file'] = changed_file - char_name = os.path.basename(changed_file).split("_")[1] + config.char_name = os.path.basename(changed_file).split("_")[1] if not config.data['sharing']['player_name_override']: - config.data['sharing']['player_name'] = char_name + config.data['sharing']['player_name'] = config.char_name location_service.SIGNALS.config_updated.emit() with open(self._stats['log_file'], 'rb') as log: log.seek(0, os.SEEK_END) diff --git a/helpers/parser.py b/helpers/parser.py index 30cf48d..1709c7b 100644 --- a/helpers/parser.py +++ b/helpers/parser.py @@ -3,9 +3,54 @@ QPushButton, QVBoxLayout, QWidget) from helpers import config +from datetime import datetime -class ParserWindow(QFrame): +class Parser: + + def __init__(self): + super().__init__() + self.name = 'Parser' + self._visible = False + + def isVisible(self) -> bool: + return self._visible + + def hide(self): + self._visible = False + + def show(self): + self._visible = True + + # main parsing logic here - derived classed should override this to perform their particular parsing tasks + def parse(self, timestamp: datetime, text: str) -> None: + + # default behavior = simply print passed info + # this strftime mask will recreate the EQ log file timestamp format + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text + print(f'[{self.name}]:{line}') + + def toggle(self, _=None) -> None: + if self.isVisible(): + self.hide() + config.data[self.name]['toggled'] = False + else: + self.set_flags() + self.show() + config.data[self.name]['toggled'] = True + config.save() + + def shutdown(self) -> None: + pass + + def set_flags(self) -> None: + pass + + def settings_updated(self) -> None: + pass + + +class ParserWindow(QFrame, Parser): def __init__(self): super().__init__() @@ -108,16 +153,6 @@ def _toggle_frame(self): def set_title(self, title): self._title.setText(title) - def toggle(self, _=None): - if self.isVisible(): - self.hide() - config.data[self.name]['toggled'] = False - else: - self.set_flags() - self.show() - config.data[self.name]['toggled'] = True - config.save() - def closeEvent(self, _): config.data[self.name]['toggled'] = False config.save() @@ -129,9 +164,3 @@ def enterEvent(self, event): def leaveEvent(self, event): self._menu.setVisible(False) QFrame.leaveEvent(self, event) - - def shutdown(self): - pass - - def settings_updated(self): - pass diff --git a/nparse.py b/nparse.py index 2d17860..a832ac0 100644 --- a/nparse.py +++ b/nparse.py @@ -12,6 +12,14 @@ from helpers import config, logreader, resource_path, get_version, location_service from helpers.settings import SettingsWindow +try: + import pyi_splash # noqa + + pyi_splash.update_text('Done!') + pyi_splash.close() +except: # noqa + pass + config.load('nparse.config.json') # validate settings file config.verify_settings() @@ -20,7 +28,7 @@ config.data['general']['qt_scale_factor'] / 100) -CURRENT_VERSION = '0.6.4' +CURRENT_VERSION = '0.6.5' if config.data['general']['update_check']: ONLINE_VERSION = get_version() else: @@ -31,6 +39,7 @@ class NomnsParse(QApplication): """Application Control.""" def __init__(self, *args): + self.setAttribute(Qt.AA_EnableHighDpiScaling) super().__init__(*args) # Updates @@ -67,11 +76,13 @@ def _load_parsers(self): "maps": parsers.Maps(), "spells": parsers.Spells(), "discord": parsers.Discord(), + "deathloopvaccine": parsers.DeathLoopVaccine(), } self._parsers = [ self._parsers_dict["maps"], self._parsers_dict["spells"], self._parsers_dict["discord"], + self._parsers_dict["deathloopvaccine"], ] for parser in self._parsers: if parser.name in config.data.keys() and 'geometry' in config.data[parser.name].keys(): @@ -125,7 +136,6 @@ def _menu(self, event): menu = QMenu() menu.setAttribute(Qt.WA_DeleteOnClose) # check online for new version - new_version_text = "" if self.new_version_available(): new_version_text = "Update Available {}".format(ONLINE_VERSION) else: @@ -181,13 +191,16 @@ def _menu(self, event): elif action == quit_action: if self._toggled: self._toggle() + else: + location_service.stop_location_service() # save parser geometry for parser in self._parsers: - g = parser.geometry() - config.data[parser.name]['geometry'] = [ - g.x(), g.y(), g.width(), g.height() - ] + if parser.name in config.data.keys() and 'geometry' in config.data[parser.name].keys(): + g = parser.geometry() + config.data[parser.name]['geometry'] = [ + g.x(), g.y(), g.width(), g.height() + ] config.save() self._system_tray.setVisible(False) @@ -220,7 +233,6 @@ def new_version_available(self): APP.setStyleSheet(open(resource_path('data/ui/_.css')).read()) APP.setWindowIcon(QIcon(resource_path('data/ui/icon.png'))) APP.setQuitOnLastWindowClosed(False) - APP.setAttribute(Qt.AA_EnableHighDpiScaling) QFontDatabase.addApplicationFont( resource_path('data/fonts/NotoSans-Regular.ttf')) QFontDatabase.addApplicationFont( diff --git a/nparse_py.spec b/nparse_py.spec index 6b71ba8..eefd8dd 100644 --- a/nparse_py.spec +++ b/nparse_py.spec @@ -3,34 +3,58 @@ block_cipher = None -a = Analysis(['nparse.py'], - pathex=['D:\\nomns.github.com\\nparse'], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - runtime_hooks=['set_qt_conf.py'], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher +a = Analysis( + ['nparse.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, ) + from glob import glob a.datas += [(filename, filename, '.') for filename in glob('data/fonts/*')] a.datas += [('data/ui/_.css', 'data/ui/_.css', '.')] a.datas += [('data/ui/icon.png', 'data/ui/icon.png', '.')] -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) -exe = EXE(pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - name='nparse', - debug=False, - strip=False, - upx=False, - runtime_tmpdir=None, - console=False, - icon='data/ui/icon.ico' - ) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +splash = Splash( + 'splash.png', + binaries=a.binaries, + datas=a.datas, + text_pos=(5, 20), + text_size=12, + text_color='black', + minify_script=True, + always_on_top=True, +) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + splash, + splash.binaries, + name='nparse', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='data/ui/icon.ico' +) diff --git a/parsers/__init__.py b/parsers/__init__.py index 427a74c..5a7b78e 100644 --- a/parsers/__init__.py +++ b/parsers/__init__.py @@ -2,3 +2,4 @@ from .maps import Maps # noqa: F401 from .spells import Spells # noqa: F401 from .discord import Discord # noqa: F401 +from .deathloopvaccine import DeathLoopVaccine # noqa: F401 diff --git a/parsers/deathloopvaccine.py b/parsers/deathloopvaccine.py new file mode 100644 index 0000000..b1c7e64 --- /dev/null +++ b/parsers/deathloopvaccine.py @@ -0,0 +1,241 @@ +import datetime +import re +import os +import signal + + +from datetime import datetime +from helpers import Parser, config, get_eqgame_pid_list, starprint + + +# +# simple utility to prevent Everquest Death Loop +# +# The utility functions by parsing the current (most recent) Everquest log file, and if it detects +# Death Loop symptoms, it will respond by initiating a system process kill of all "eqgame.exe" +# processes (there should usually only be one). +# +# We will define a death loop as any time a player experiences X deaths in Y seconds, and no player +# activity during that time. The values for X and Y are configurable, via the DeathLoopVaccine.ini file. +# +# For testing purposes, there is a back door feature, controlled by sending a tell to the following +# non-existent player: +# +# death_loop: Simulates a player death. +# +# Note however that this also sets a flag that disarms the conceptual +# "process-killer gun", which will allow every bit of the code to +# execute and be tested, but will stop short of actually killing any +# process +# +# The "process-killer gun" will then be armed again after the simulated +# player deaths trigger the simulated process kill, or after any simulated +# player death events "scroll off" the death loop monitoring window. +# +class DeathLoopVaccine(Parser): + + """Tracks for DL symptoms""" + + def __init__(self): + + super().__init__() + self.name = 'deathloopvaccine' + + # parameters that define a deathloop condition, i.e. D deaths in T seconds, + # with no player activity in the interim + # todo - make the deathloop.deaths and deathloop.seconds values configarable via the UI? + + # list of death messages + # this will function as a scrolling queue, with the oldest message at position 0, + # newest appended to the other end. Older messages scroll off the list when more + # than deathloop_seconds have elapsed. The list is also flushed any time + # player activity is detected (i.e. player is not AFK). + # + # if/when the length of this list meets or exceeds deathloop.deaths, then + # the deathloop response is triggered + self._death_list = list() + + # flag indicating whether the "process killer" gun is armed + self._kill_armed = True + + def reset(self) -> None: + """ + Utility function to clear the death_list and reset the armed flag + + Returns: + None: + """ + self._death_list.clear() + self._kill_armed = True + + # main parsing logic here + def parse(self, timestamp: datetime, text: str) -> None: + """ + Parse a single line from the logfile + + Args: + timestamp: A datetime.datetime object, created from the timestamp text of the raw logfile line + text: The text following the everquest timestamp + + Returns: + None: + """ + + self.check_for_death(timestamp, text) + self.check_not_afk(timestamp, text) + self.deathloop_response() + + def check_for_death(self, timestamp: datetime, text: str) -> None: + """ + check for indications the player just died, and if we find it, + save the message for later processing + + Args: + timestamp: A datetime.datetime object, created from the timestamp text of the raw logfile line + text: The text following the everquest timestamp + + Returns: + None: + """ + + trunc_line = text + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text + + # does this line contain a death message + slain_regexp = r'^You have been slain' + m = re.match(slain_regexp, trunc_line) + if m: + # add this message to the list of death messages + self._death_list.append(line) + starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') + + # a way to test - send a tell to death_loop + slain_regexp = r'^death_loop' + m = re.match(slain_regexp, trunc_line) + if m: + # add this message to the list of death messages + # since this is just for testing, disarm the kill-gun + self._death_list.append(line) + starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') + self._kill_armed = False + + # only do the list-purging if there are already some death messages in the list, else skip this + if len(self._death_list) > 0: + + # create a datetime object for this line, using the very capable datetime.strptime() + now = timestamp + + # now purge any death messages that are too old + done = False + while not done: + # if the list is empty, we're done + if len(self._death_list) == 0: + self.reset() + done = True + # if the list is not empty, check if we need to purge some old entries + else: + oldest_line = self._death_list[0] + oldest_time = datetime.strptime(oldest_line[0:26], '[%a %b %d %H:%M:%S %Y]') + elapsed_seconds = now - oldest_time + + if elapsed_seconds.total_seconds() > config.data['deathloopvaccine']['seconds']: + # that death message is too old, purge it + self._death_list.pop(0) + starprint(f'DeathLoopVaccine: Death count = {len(self._death_list)}') + else: + # the oldest death message is inside the window, so we're done purging + done = True + + def check_not_afk(self, timestamp: datetime, text: str) -> None: + """ + check for "proof of life" indications the player is really not AFK + + Args: + timestamp: A datetime.datetime object, created from the timestamp text of the raw logfile line + text: The text following the everquest timestamp + + Returns: + None: + """ + + # only do the proof of life checks if there are already some death messages in the list, else skip this + if len(self._death_list) > 0: + + # check for proof of life, things that indicate the player is not actually AFK + # begin by assuming the player is AFK + afk = True + + trunc_line = text + line = f'[{timestamp.strftime("%a %b %d %H:%M:%S %Y")}] ' + text + + # does this line contain a proof of life - casting + regexp = r'^You begin casting' + m = re.match(regexp, trunc_line) + if m: + # player is not AFK + afk = False + starprint(f'DeathLoopVaccine: Player Not AFK: {line}') + + # does this line contain a proof of life - communication + # this captures tells, say, group, auction, and shout channels + regexp = f'^(You told|You say|You tell|You auction|You shout|{config.char_name} ->)' + m = re.match(regexp, trunc_line) + if m: + # player is not AFK + afk = False + starprint(f'DeathLoopVaccine: Player Not AFK: {line}') + + # does this line contain a proof of life - melee + regexp = r'^You( try to)? (hit|slash|pierce|crush|claw|bite|sting|maul|gore|punch|kick|backstab|bash|slice)' + m = re.match(regexp, trunc_line) + if m: + # player is not AFK + afk = False + starprint(f'DeathLoopVaccine: Player Not AFK: {line}') + + # if they are not AFK, then go ahead and purge any death messages from the list + if not afk: + self.reset() + + def deathloop_response(self) -> None: + """ + are we death looping? if so, kill the process + + Returns: + None: + """ + + deaths = config.data['deathloopvaccine']['deaths'] + seconds = config.data['deathloopvaccine']['seconds'] + + # if the death_list contains more deaths than the limit, then trigger the process kill + if len(self._death_list) >= deaths: + + starprint('---------------------------------------------------') + starprint('DeathLoopVaccine - Killing all eqgame.exe processes') + starprint('---------------------------------------------------') + starprint('DeathLoopVaccine has detected deathloop symptoms:') + starprint(f' {deaths} deaths in less than {seconds} seconds, with no player activity') + + # show all the death messages + starprint('Death Messages:') + for line in self._death_list: + starprint(' ' + line) + + # get the list of eqgame.exe process ID's, and show them + pid_list = get_eqgame_pid_list() + starprint(f'eqgame.exe process id list = {pid_list}') + + # kill the eqgame.exe process / processes + for pid in pid_list: + starprint(f'Killing process [{pid}]') + + # for testing the actual kill process using simulated player deaths, uncomment the following line + # self._kill_armed = True + if self._kill_armed: + os.kill(pid, signal.SIGTERM) + else: + starprint('(Note: Process Kill only simulated, since death(s) were simulated)') + + # purge any death messages from the list + self.reset() diff --git a/parsers/discord.py b/parsers/discord.py index db5ed8e..34ade80 100644 --- a/parsers/discord.py +++ b/parsers/discord.py @@ -41,7 +41,7 @@ color: white; }} #ParserWindowMenu QPushButton {{ - color: rgb(255, 255, 255, {alpha}); + color: rgba(255, 255, 255, {alpha}); }} #ParserWindowMenuReal {{ background-color:rgba({red},{green},{blue},{alpha}) @@ -53,7 +53,7 @@ color: rgba(255,255,255,{alpha}) }} #ParserWindowTitle {{ - color: rgb(200, 200, 200, {alpha}) + color: rgba(200, 200, 200, {alpha}) }}""" HTML_NO_CONFIG = """ diff --git a/requirements.txt b/requirements.txt index 96ba78b..83ba159 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ websocket-client==0.58.0 requests colorhash pathvalidate -PyQtWebEngine \ No newline at end of file +PyQtWebEngine +psutil diff --git a/set_qt_conf.py b/set_qt_conf.py deleted file mode 100644 index 9766193..0000000 --- a/set_qt_conf.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import sys - -if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - print('running in a PyInstaller bundle') - qt_prefix = os.path.join(sys._MEIPASS, 'PyQt5', 'Qt').replace("\\", "\\\\") - with open('qt.conf', 'w') as qtconf: - qtconf.writelines(['[Paths]\n', 'Prefix = %s\n' % qt_prefix]) -else: - print('running in a normal Python process') diff --git a/splash.png b/splash.png new file mode 100644 index 0000000..7b0de78 Binary files /dev/null and b/splash.png differ diff --git a/splash.xcf b/splash.xcf new file mode 100644 index 0000000..8fc39f7 Binary files /dev/null and b/splash.xcf differ