├── .env ├── CHANGELOG.md ├── LICENSE ├── README.md ├── animatedSplashes ├── 103086.gif ├── 147001.gif ├── 147002.gif ├── 147003.gif ├── 21016.gif ├── 360030.gif ├── 37006.gif ├── 77003.gif ├── 81005.gif ├── 99007.gif └── skinList.json ├── assets ├── away.png ├── chat.png └── dnd.png ├── icon.ico ├── images ├── logo.png ├── previews │ ├── active.png │ ├── background.jpg │ ├── defeat.png │ ├── empty.png │ └── hover.png ├── screenshot.png └── screenshot2.png ├── main.py └── src ├── DetailedLoLRPC.py ├── __init__.py ├── cdngen.py ├── disabler.py ├── gamestats.py ├── gui.py ├── lcu.py ├── modes.py ├── tray_icon.py ├── updater.py └── utilities.py /.env: -------------------------------------------------------------------------------- 1 | CLIENTID=MTExODA2MjcxMTY4Nzg3MjU5Mw== -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # DetailedLoLRPC - Release Notes [v5.0.0] 2 | 3 | This major update brings a completely revamped experience with a brand new GUI, exciting new features, and significant improvements to stability and performance! 4 | 5 | ## ✨ New GUI & Features 6 | 7 | * **Completely New Graphical User Interface (GUI):** 8 | * The application now features a modern and intuitive settings interface, making it easier than ever to customize your Rich Presence. 9 | * **One-Click In-App Updates (New!):** 10 | * Update DetailedLoLRPC directly from the GUI! The "Check for Updates" button now handles the entire update process, including download and installation (via a CMD helper). 11 | * **Enhanced Idle Status Customization:** 12 | * **Profile Info Mode (New Options!):** 13 | * Choose to display your Riot ID (Summoner Name). 14 | * Choose to display your Tagline. 15 | * Choose to display your Summoner Level. 16 | * **Custom Idle Status (New!):** 17 | * Set a custom image (via URL) and custom text for your idle presence. 18 | * Optionally show your availability status circle and time elapsed. 19 | * **Loading Screen RPC (New!):** 20 | * Your Rich Presence will now display that you're on the loading screen when a match starts. 21 | * **Mute RPC Functionality (New!):** 22 | * Temporary mute and unmute Rich Presence via a new option in the tray icon menu and a dedicated button in the settings GUI. 23 | * **Choose from 5 Map Icon Styles (New!):** 24 | * Customize the map icon displayed in your Rich Presence by selecting from five different styles. 25 | * **Import/Export Settings (New!):** 26 | * Easily back up your DetailedLoLRPC configuration or transfer it to another installation using the new import and export settings feature. 27 | 28 | ## 🚀 Enhancements & Fixes 29 | 30 | * **Improved Timer Syncing (New!):** 31 | * In-game timers displayed in Rich Presence are now accurately synchronized. 32 | * **Immediate RPC Updates on Config Change:** 33 | * Any changes made to your configuration via the GUI or tray menu are now reflected in your Rich Presence instantly. 34 | * **Reliable RPC Clearing:** 35 | * Fixed an issue where Rich Presence would sometimes not clear correctly after exiting a game lobby. 36 | * **Instance Checking:** 37 | * Improved the mechanism to ensure only one instance of DetailedLoLRPC can run at a time. 38 | * **Update Reliability:** 39 | * Resolved an issue that would cause the Riot client to get stuck in an update loop. 40 | 41 | ## 🛠️ Internal Improvements 42 | 43 | * **Completely Rewritten Internal Code Structure:** 44 | * The application's core has been significantly refactored for better maintainability, scalability, and overall performance. 45 | * **Better Logging for Debugging:** 46 | * Logging capabilities have been greatly enhanced, providing more detailed information for easier troubleshooting and issue diagnosis. 47 | * **Comprehensive Error Handling:** 48 | * Implemented more robust error handling throughout the application to improve stability and prevent unexpected crashes. 49 | 50 | --- 51 | 52 | Thank you for using DetailedLoLRPC! I'm excited for you to try out these new features and improvements. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ria 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | Logo 6 | 7 |

DetailedLoLRPC

8 | 9 |

10 | A better Discord Rich Presence for League of Legends. 11 |
12 | Now with a brand new interface and more features! 13 |

14 | 15 |

16 | Stargazers 17 | Downloads 18 | Issues 19 | License 20 |

21 | 22 |

23 | Download Latest Release » 24 |

25 | 26 |

27 | About 28 | · 29 | Getting Started 30 | · 31 | Report Bug 32 |

33 |
34 | 35 | ## 📖 About The Project 36 | 37 | 38 | 39 | ![Screenshot1](images/screenshot.png) ![Screenshot2](images/screenshot2.png) 40 | 41 | DetailedLoLRPC enhances your League of Legends experience on Discord by providing a much more detailed and customizable Rich Presence. It replaces the default, outdated LoL Rich Presence with accurate champion information, skin splashes, in-game stats, and much more. 42 | 43 | With the release of **v5.0.0**, DetailedLoLRPC has been rebuilt from the ground up, featuring a modern graphical user interface (GUI) for easy configuration and a host of new capabilities. 44 | 45 | ## ✨ Features 46 | 47 | DetailedLoLRPC is packed with features to make your LoL presence on Discord shine: 48 | 49 | * **✨ Brand New GUI (v5.0.0):** A modern, intuitive interface for easy settings management. 50 | * **🚀 One-Click In-App Updates (v5.0.0):** Update the application directly from the GUI. 51 | * **🎨 Display Current Skin:** Shows the splash art and name of the skin you're using, not just the default. 52 | * **🖼️ Updated & Proper Splash Arts:** Uses correct and up-to-date splash arts for all champions, including newer ones. 53 | * **🎭 Animated Splash Arts:** Option to display animated splash arts for skins that have them (e.g., Ultimate skins). 54 | * **📄 Detailed Mode Texts:** Accurately displays game modes like Ranked Solo/Duo/Flex, TFT Double Up, Arena, etc. 55 | * **🎮 Full Game Mode Support:** Rich Presence is active for all modes, including Summoner's Rift, ARAM, TFT, and all rotating gamemodes. 56 | * **⏳ Loading Screen RPC (v5.0.0):** Shows when you're on the loading screen. 57 | * **🤫 Mute RPC Functionality (v5.0.0):** Temporarily mute/unmute Rich Presence via GUI or tray menu. 58 | * **📍 Customizable Map Icons (v5.0.0):** Choose from 5 different styles for the map icon. 59 | * **⚙️ Import/Export Settings (v5.0.0):** Easily backup and transfer your configurations. 60 | * **📊 In-Game Stats:** Display KDA, CS, and Level. 61 | * **🏆 Rank Display:** Show your rank for various modes (Solo, Flex, TFT, Double Up). 62 | * **📈 Ranked Stats:** Option to display LP, Wins, and Losses. 63 | * **🎉 Party Info:** Show the number of members in your party. 64 | * **👤 Enhanced Idle Status Customization (v5.0.0):** 65 | * **Profile Info Mode:** Display Riot ID, Tagline, or Summoner Level. 66 | * **Custom Idle Status:** Set a custom image (via URL) and text, with optional availability status and time elapsed. 67 | * **⏱️ Improved Timer Syncing (v5.0.0):** Accurate in-game timers in Rich Presence. 68 | * **⚡ Immediate RPC Updates:** Changes to configuration apply instantly. 69 | 70 | ## 🚀 Getting Started 71 | 72 | To get DetailedLoLRPC up and running: 73 | 74 | ### ✅ Prerequisites 75 | 76 | * Windows 7 and above 77 | * League of Legends client installed 78 | * Discord desktop application running 79 | 80 | ### 🛠️ Installation & Usage 81 | 82 | 1. **Download:** Grab the latest `DetailedLoLRPC.exe` from the [Releases page](https://github.com/developers192/DetailedLoLRPC/releases/latest). 83 | * *Note: Your browser or antivirus might flag the download. This is a false positive due to the application interacting with other processes (LoL and Discord). Please whitelist it if necessary.* 84 | 2. **Initial Run:** 85 | * Ensure League of Legends is **not** running. 86 | * Run `DetailedLoLRPC.exe`. 87 | 3. **Path Configuration:** 88 | * If the Riot Client path is not automatically detected, the application will prompt you to specify it through the new GUI. 89 | 4. **Launch LoL:** DetailedLoLRPC will start the Riot Client for you. Once League of Legends is running, DetailedLoLRPC will automatically replace its native Rich Presence. 90 | 5. **Customize:** Use the new settings GUI (accessible from the tray icon) to tailor your Rich Presence to your liking! 91 | 92 | ## ⚙️ Settings & Customization 93 | 94 | As of v5.0.0, most settings are managed through the new **Graphical User Interface (GUI)**, which can be opened by selecting "Open Settings" from the DetailedLoLRPC tray icon menu. Some quick toggles may also be available directly in the tray menu. 95 | 96 | Key customizable options include: 97 | 98 | * **General Presence:** 99 | * `Use skin's splash and name`: Display the current skin's splash and name. (Default: Enabled) 100 | * `Use animated splash if available`: Use animated splash art for eligible skins. 101 | * `Show "View splash art" button`: Display a button on Discord to view the current skin's splash. 102 | * `Show party info`: Display party member count. 103 | * `Map Icon Style`: Choose from 5 different map icon designs. 104 | * **Stats:** 105 | * `Ingame stats`: Select which stats to show (KDA, CS, Level). 106 | * `Show ranks`: Choose for which modes to display your rank (Solo, Flex, TFT, Double Up). 107 | * `Ranked stats`: Select which ranked stats to show (LP, Wins, Losses). 108 | * **Idle Status:** 109 | * Configure what's shown when you're not in a game. 110 | * **Profile Info Mode:** Display Profile icon, Riot ID, Tagline, or Summoner Level. 111 | * **Custom Idle Status:** Set a custom image and text, and toggle availability/time elapsed. 112 | * **Application Behavior:** 113 | * `Mute Rich Presence`: Temporarily disable RPC (toggle in GUI or tray). 114 | * `Import/Export Settings`: Manage your application configurations. 115 | * `Check for Updates`: Initiate the in-app update process. 116 | * **Utilities:** 117 | * `Reset preferences`: Reset all settings to their default values. (Useful if you move your Riot Games folder). 118 | * `Report bug`: Opens the GitHub issues page and the folder containing necessary logs. 119 | * `Exit`: Close DetailedLoLRPC. (LoL's native RPC will not be re-enabled until the next time you start LoL through the Riot Client without DetailedLoLRPC running). 120 | 121 | Settings changes are generally applied instantly or after a short delay. 122 | 123 | ## 💻 Resource Usage 124 | 125 | * **CPU:** Approximately 1-3% during active game state processing. Can be lower when idle. 126 | * **Memory:** Around 50MB. 127 | 128 | Resource usage is minimal and optimized to not impact game performance. 129 | 130 | ## 📝 To Do 131 | 132 | * [ ] Implement a "Join Lobby" button on Discord. 133 | * [ ] More support for spectator mode. 134 | 135 | Have an idea? [Request a Feature!](https://github.com/developers192/DetailedLoLRPC/issues) 136 | 137 | ## ⚠️ Will I get banned for using this? 138 | 139 | Theoretically, **no**. DetailedLoLRPC primarily uses the local API provided by the League of Legends client itself, which is intended for third-party tools. While it does modify the `plugin-manifest.json` file to disable the native Rich Presence, this method has been generally considered safe by the community. 140 | 141 | However, Riot Games' stance on any third-party application can change. While this tool is used by many (including me) without issue, **use it at your own discretion.** DetailedLoLRPC is designed to be as non-intrusive as possible. 142 | 143 | ## 📜 Changelog 144 | 145 | For a detailed list of changes in each version, please see the [CHANGELOG.md](CHANGELOG.md) file. 146 | **v5.0.0 is a major update!** Check out the changelog for all the exciting new features and improvements. 147 | 148 | ## 📄 License 149 | 150 | Distributed under the MIT License. See `LICENSE` for more information. 151 | 152 | ## 📢 Disclaimer 153 | 154 | DetailedLoLRPC was created under Riot Games' ["Legal Jibber Jabber"](https://www.riotgames.com/en/legal) policy using assets owned by Riot Games. This project is not endorsed by Riot Games and does not reflect the views or opinions of Riot Games or anyone officially involved in producing or managing Riot Games properties. Riot Games and all associated properties are trademarks or registered trademarks of Riot Games, Inc. 155 | 156 | --- 157 | 158 |

(back to top)

-------------------------------------------------------------------------------- /animatedSplashes/103086.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/103086.gif -------------------------------------------------------------------------------- /animatedSplashes/147001.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/147001.gif -------------------------------------------------------------------------------- /animatedSplashes/147002.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/147002.gif -------------------------------------------------------------------------------- /animatedSplashes/147003.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/147003.gif -------------------------------------------------------------------------------- /animatedSplashes/21016.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/21016.gif -------------------------------------------------------------------------------- /animatedSplashes/360030.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/360030.gif -------------------------------------------------------------------------------- /animatedSplashes/37006.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/37006.gif -------------------------------------------------------------------------------- /animatedSplashes/77003.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/77003.gif -------------------------------------------------------------------------------- /animatedSplashes/81005.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/81005.gif -------------------------------------------------------------------------------- /animatedSplashes/99007.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/animatedSplashes/99007.gif -------------------------------------------------------------------------------- /animatedSplashes/skinList.json: -------------------------------------------------------------------------------- 1 | [99007, 360030, 147001, 147002, 147003, 103086, 21016, 77003, 37006, 81005] -------------------------------------------------------------------------------- /assets/away.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/assets/away.png -------------------------------------------------------------------------------- /assets/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/assets/chat.png -------------------------------------------------------------------------------- /assets/dnd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/assets/dnd.png -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/icon.ico -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/logo.png -------------------------------------------------------------------------------- /images/previews/active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/previews/active.png -------------------------------------------------------------------------------- /images/previews/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/previews/background.jpg -------------------------------------------------------------------------------- /images/previews/defeat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/previews/defeat.png -------------------------------------------------------------------------------- /images/previews/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/previews/empty.png -------------------------------------------------------------------------------- /images/previews/hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/previews/hover.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/screenshot.png -------------------------------------------------------------------------------- /images/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/images/screenshot2.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import os 4 | from multiprocessing import freeze_support 5 | 6 | import nest_asyncio 7 | nest_asyncio.apply() 8 | 9 | try: 10 | from src.utilities import ( 11 | init as util_init, procPath, yesNoBox, logger, addLog, 12 | LEAGUE_CLIENT_EXECUTABLE, fetchConfig, APPDATA_DIRNAME, LOCK_FILENAME, 13 | check_and_create_lock, release_lock, 14 | tk 15 | ) 16 | from tkinter import messagebox 17 | from src.DetailedLoLRPC import DetailedLoLRPC 18 | import src.tray_icon as tray_module 19 | from src.gui import launch_settings_gui 20 | from src import updater 21 | except ImportError as e: 22 | try: 23 | import logging 24 | startup_logger = logging.getLogger("startup_critical") 25 | startup_logger.addHandler(logging.StreamHandler(sys.stdout)) 26 | startup_logger.setLevel(logging.CRITICAL) 27 | startup_logger.critical(f"CRITICAL: Failed to import necessary modules: {e}") 28 | startup_logger.critical("Ensure 'src' is a package (contains an __init__.py file) and all dependencies are installed.") 29 | startup_logger.critical("Application cannot start.") 30 | except: 31 | print(f"CRITICAL: Failed to import necessary modules: {e}") 32 | print("Ensure 'src' is a package (contains an __init__.py file) and all dependencies are installed.") 33 | print("Application cannot start.") 34 | 35 | try: 36 | input("Press Enter to exit.") 37 | except: 38 | pass 39 | sys.exit(1) 40 | 41 | 42 | async def main_application_runner(): 43 | """ 44 | Sets up and runs the DetailedLoLRPC application. 45 | Returns the intended exit code. 46 | """ 47 | 48 | app_name_for_lock_check = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else "DetailedLoLRPC.py") 49 | if not check_and_create_lock(): 50 | try: 51 | root = tk.Tk() 52 | root.withdraw() 53 | messagebox.showinfo("DetailedLoLRPC", "DetailedLoLRPC is already running.", parent=root) 54 | root.destroy() 55 | except Exception as e_tk_mb: 56 | print(f"DetailedLoLRPC is already running. (Could not show dialog: {e_tk_mb})") 57 | return 1 # Indicate failure to start due to existing instance 58 | 59 | util_init() 60 | logger.info("Utilities initialized by main.py.") 61 | 62 | exit_code_to_return = 0 # Default to success 63 | rpc_app = None # Initialize to None 64 | 65 | try: 66 | if procPath(LEAGUE_CLIENT_EXECUTABLE): 67 | should_continue = False 68 | try: 69 | loop = asyncio.get_running_loop() 70 | should_continue = await loop.run_in_executor(None, 71 | lambda: yesNoBox( 72 | "DetailedLoLRPC might not work optimally if opened after League of Legends. Continue?" 73 | ) 74 | ) 75 | except RuntimeError: 76 | logger.warning("No running asyncio loop for yesNoBox, attempting direct call.") 77 | should_continue = yesNoBox( 78 | "DetailedLoLRPC might not work optimally if opened after League of Legends. Continue?" 79 | ) 80 | except Exception as e: 81 | logger.error(f"Error during initial yesNoBox check: {e}") 82 | should_continue = True 83 | 84 | if not should_continue: 85 | logger.info("User chose to exit because League is already running. Program will now terminate.") 86 | return 0 87 | 88 | rpc_app = DetailedLoLRPC() 89 | if hasattr(tray_module, 'setup_rpc_app_reference'): 90 | tray_module.setup_rpc_app_reference(rpc_app) 91 | logger.info("rpc_app reference passed to tray_module.") 92 | else: 93 | logger.error("tray_module does not have setup_rpc_app_reference. Tray icon callbacks might not work as expected.") 94 | 95 | try: 96 | if fetchConfig("showWindowOnStartup"): 97 | logger.info("Configuration set to show settings window on startup.") 98 | launch_settings_gui( 99 | current_status_getter=rpc_app.get_current_app_status_for_gui, 100 | rpc_app_ref=rpc_app 101 | ) 102 | logger.info("Attempted to launch settings GUI on startup.") 103 | else: 104 | logger.info("Configuration set to NOT show settings window on startup.") 105 | except Exception as e_gui_startup: 106 | logger.error(f"Error launching settings GUI on startup: {e_gui_startup}", exc_info=True) 107 | addLog(f"GUI Error: Failed to launch settings on startup: {str(e_gui_startup)}", level="ERROR") 108 | 109 | main_app_task = asyncio.create_task(rpc_app.run()) 110 | await main_app_task 111 | exit_code_to_return = rpc_app._final_exit_code 112 | 113 | except asyncio.CancelledError: 114 | logger.info("Main application task was cancelled (likely during shutdown).") 115 | if rpc_app and hasattr(rpc_app, 'shutting_down') and not rpc_app.shutting_down: 116 | logger.warning("Main task cancelled but shutdown not in progress. Initiating shutdown.") 117 | await rpc_app.shutdown(exit_code=0) 118 | if rpc_app: exit_code_to_return = rpc_app._final_exit_code 119 | except KeyboardInterrupt: 120 | logger.info("Ctrl+C received by main_application_runner. Initiating shutdown...") 121 | print("\nCtrl+C received. Shutting down...") 122 | addLog("App Info: KeyboardInterrupt received by main_application_runner.", level="INFO") 123 | if rpc_app and hasattr(rpc_app, 'shutting_down') and not rpc_app.shutting_down: 124 | await rpc_app.shutdown(exit_code=0) 125 | if rpc_app: exit_code_to_return = rpc_app._final_exit_code 126 | except SystemExit as e: 127 | logger.info(f"Application exited via SystemExit with code {e.code} in main_application_runner.") 128 | exit_code_to_return = e.code 129 | if rpc_app and hasattr(rpc_app, 'shutting_down') and not rpc_app.shutting_down and e.code != 0 : 130 | logger.warning(f"SystemExit({e.code}) caught, attempting graceful shutdown.") 131 | await rpc_app.shutdown(exit_code=e.code) 132 | exit_code_to_return = rpc_app._final_exit_code 133 | except Exception as e: 134 | logger.critical(f"An unhandled error occurred in main_application_runner: {e}", exc_info=True) 135 | addLog(f"App Critical Error: Unhandled in main_application_runner: {str(e)}", level="CRITICAL") 136 | import traceback 137 | addLog(traceback.format_exc(), level="CRITICAL") 138 | print(f"An unhandled error occurred: {e}") 139 | exit_code_to_return = 1 140 | if rpc_app and hasattr(rpc_app, 'shutdown') and hasattr(rpc_app, 'shutting_down') and not rpc_app.shutting_down: 141 | await rpc_app.shutdown(exit_code=1) 142 | exit_code_to_return = rpc_app._final_exit_code 143 | finally: 144 | if os.path.exists(os.path.join(os.getenv("APPDATA"), APPDATA_DIRNAME, LOCK_FILENAME)): 145 | logger.info("Main_application_runner finally: Releasing lock as a safeguard.") 146 | release_lock() 147 | logger.info("Main_application_runner function exiting.") 148 | return exit_code_to_return 149 | 150 | 151 | if __name__ == "__main__": 152 | freeze_support() 153 | nest_asyncio.apply() 154 | 155 | final_exit_code = 0 156 | try: 157 | final_exit_code = asyncio.run(main_application_runner()) 158 | except SystemExit as e: 159 | final_exit_code = e.code 160 | logger.info(f"asyncio.run completed. SystemExit caught with code: {final_exit_code}") 161 | except KeyboardInterrupt: 162 | logger.info("asyncio.run interrupted by KeyboardInterrupt. Application will terminate.") 163 | final_exit_code = 0 # Or a specific code for Ctrl+C 164 | except Exception as e_run: 165 | logger.critical(f"Critical error during asyncio.run: {e_run}", exc_info=True) 166 | final_exit_code = 1 # General error 167 | finally: 168 | logger.info(f"Application process finalizing with exit code: {final_exit_code}") 169 | 170 | lock_file_full_path = os.path.join(os.getenv("APPDATA"), "DetailedLoLRPC", "detailedlolrpc.lock") # Reconstruct path 171 | if os.path.exists(lock_file_full_path): 172 | try: 173 | with open(lock_file_full_path, "r") as f: 174 | pid_in_lock = f.read().strip() 175 | if pid_in_lock == str(os.getpid()): 176 | release_lock() 177 | else: 178 | logger.warning(f"Lock file found but owned by PID {pid_in_lock}, not current PID {os.getpid()}. Not releasing in main.py finally.") 179 | except Exception as e_lock_final: 180 | logger.error(f"Error during final lock release check: {e_lock_final}") 181 | 182 | os._exit(final_exit_code) 183 | -------------------------------------------------------------------------------- /src/DetailedLoLRPC.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import asyncio 4 | import aiohttp 5 | from time import time, sleep 6 | from multiprocessing import Process 7 | from subprocess import Popen, DEVNULL 8 | from json import loads, JSONDecodeError 9 | 10 | import nest_asyncio 11 | 12 | from pypresence import Presence, exceptions as PyPresenceExceptions 13 | from lcu_driver import Connector 14 | 15 | try: 16 | from .utilities import ( 17 | GITHUBURL, CLIENTID, 18 | fetchConfig, procPath, addLog, logger, 19 | register_config_changed_callback, release_lock 20 | ) 21 | from .cdngen import mapIcon, rankedEmblem, availabilityImg, profileIcon, localeDiscordStrings, localeChatStrings 22 | from .disabler import disableNativePresence 23 | import src.tray_icon as tray_module 24 | from .modes import updateInProgressRPC 25 | from .lcu import LcuManager 26 | from . import gui as gui_module 27 | from . import updater 28 | except ImportError as e: 29 | print(f"CRITICAL: Failed to import necessary modules in DetailedLoLRPC: {e}") 30 | sys.exit(1) 31 | 32 | import tkinter as tk 33 | from tkinter import messagebox 34 | 35 | 36 | RIOT_CLIENT_SERVICES_EXECUTABLE = "RiotClientServices.exe" 37 | RIOT_CLIENT_UX_EXECUTABLE = "Riot Client.exe" 38 | LEAGUE_CLIENT_EXECUTABLE = "LeagueClient.exe" 39 | DEFAULT_LOCALE = "en_us" 40 | INITIAL_SUMMONER_FETCH_TIMEOUT = 30 41 | INITIAL_SUMMONER_FETCH_RETRY_DELAY = 2 42 | IDLE_STATE_CONFIRMATION_DELAY = 1.5 43 | RCS_UX_WAIT_TIMEOUT = 30 44 | LCU_DISCONNECT_SHUTDOWN_DELAY = 10 45 | 46 | MAP_ICON_STYLE_TO_ASSET_KEY = { 47 | "Active": "game-select-icon-active", 48 | "Empty": "icon-empty", 49 | "Hover": "game-select-icon-hover", 50 | "Defeat": "icon-defeat", 51 | "Background": "gameflow-background" 52 | } 53 | DEFAULT_MAP_ICON_KEY = "game-select-icon-active" 54 | 55 | 56 | class DetailedLoLRPC: 57 | def __init__(self): 58 | try: 59 | self._main_loop_ref = asyncio.get_event_loop() 60 | except RuntimeError: 61 | logger.warning("No running asyncio loop found during DetailedLoLRPC init.") 62 | self._main_loop_ref = None 63 | 64 | self.rpc = Presence(client_id=CLIENTID, loop=self._main_loop_ref) 65 | self.connector = Connector(loop=self._main_loop_ref) 66 | self.lcu_manager = LcuManager(self.connector) 67 | 68 | self.lcu_connected = False 69 | self.rpc_connected = False 70 | self.shutting_down = False 71 | self.rpc_lock = asyncio.Lock() 72 | 73 | self.summoner_data = {} 74 | self.locale_strings = {} 75 | self.current_champ_selection = (0, 0) 76 | self.ingame_rpc_task = None 77 | self._delayed_idle_handler_task = None 78 | self._lcu_disconnect_shutdown_task = None 79 | self._final_exit_code = 0 80 | 81 | self.config_changed_event = asyncio.Event() 82 | self._config_watcher_task = None 83 | self.last_gameflow_event_data = None 84 | self.last_chat_event_data = None 85 | self.last_connection_obj_for_refresh = None 86 | self.current_map_icon_asset_key_name = MAP_ICON_STYLE_TO_ASSET_KEY.get(fetchConfig("mapIconStyle"), DEFAULT_MAP_ICON_KEY) 87 | 88 | self._register_lcu_handlers() 89 | register_config_changed_callback(self.schedule_presence_refresh) 90 | logger.info("DetailedLoLRPC initialized.") 91 | 92 | def get_current_app_status_for_gui(self): 93 | if hasattr(tray_module, '_current_status_text'): 94 | return tray_module._current_status_text 95 | return "Status: Unknown" 96 | 97 | async def open_settings_gui(self): 98 | logger.info("DetailedLoLRPC: open_settings_gui coroutine CALLED.") 99 | if self.shutting_down: 100 | logger.warning("DetailedLoLRPC: Attempted to open settings GUI during shutdown. Aborting.") 101 | return 102 | try: 103 | logger.info("DetailedLoLRPC: Attempting to launch settings GUI...") 104 | gui_module.launch_settings_gui( 105 | current_status_getter=self.get_current_app_status_for_gui, 106 | rpc_app_ref=self 107 | ) 108 | logger.info("DetailedLoLRPC: launch_settings_gui function call COMPLETED (GUI scheduled).") 109 | except Exception as e: 110 | logger.error(f"DetailedLoLRPC: Error launching settings GUI: {e}", exc_info=True) 111 | addLog(f"GUI Error: Failed to launch settings: {str(e)}", level="ERROR") 112 | 113 | def schedule_presence_refresh(self): 114 | if not self.shutting_down and self._main_loop_ref and self._main_loop_ref.is_running(): 115 | self.config_changed_event.set() 116 | elif not self.shutting_down : 117 | logger.warning("Cannot schedule presence refresh: Main loop not available or not running.") 118 | 119 | async def _config_change_listener(self): 120 | logger.info("Config change listener started.") 121 | while not self.shutting_down: 122 | try: 123 | await self.config_changed_event.wait() 124 | if self.shutting_down: break 125 | self.current_map_icon_asset_key_name = MAP_ICON_STYLE_TO_ASSET_KEY.get( 126 | fetchConfig("mapIconStyle"), DEFAULT_MAP_ICON_KEY 127 | ) 128 | 129 | # If RPC mute state changed, we might need to clear current presence 130 | if 'isRpcMuted' in self.config_changed_event._flag_name_for_debug if hasattr(self.config_changed_event, '_flag_name_for_debug') else True: # Heuristic 131 | if fetchConfig("isRpcMuted") and self.rpc_connected: 132 | logger.info("RPC Mute enabled via config change, clearing presence.") 133 | await self._update_rpc_presence(clear=True) # Ensure presence is cleared if muted 134 | elif not fetchConfig("isRpcMuted"): 135 | logger.info("RPC Unmuted via config change, refreshing presence.") 136 | # Refresh will be handled by the create_task below 137 | 138 | logger.info(f"Config change detected. Refreshing presence. Muted: {fetchConfig('isRpcMuted')}") 139 | asyncio.create_task(self.refresh_current_presence()) 140 | self.config_changed_event.clear() 141 | except asyncio.CancelledError: 142 | logger.info("Config change listener task cancelled.") 143 | break 144 | except Exception as e: 145 | logger.error(f"Error in config change listener: {e}", exc_info=True) 146 | self.config_changed_event.clear() 147 | await asyncio.sleep(5) 148 | logger.info("Config change listener stopped.") 149 | 150 | async def refresh_current_presence(self): 151 | logger.info("Attempting to refresh current presence...") 152 | if fetchConfig("isRpcMuted"): 153 | logger.info("RPC is muted. Clearing presence if connected, otherwise ensuring it stays clear.") 154 | if self.rpc_connected: 155 | await self._update_rpc_presence(clear=True) 156 | return 157 | 158 | if not self.lcu_connected or not self.lcu_manager.current_connection: 159 | logger.warning("Cannot refresh presence: LCU not connected or no active connection object.") 160 | await self._update_rpc_presence(clear=True) 161 | return 162 | 163 | connection = self.lcu_manager.current_connection 164 | if self.ingame_rpc_task and not self.ingame_rpc_task.done(): 165 | logger.debug("In-game task is active; it will pick up config changes.") 166 | return 167 | 168 | gameflow_phase_resp = await connection.request('get', '/lol-gameflow/v1/gameflow-phase') 169 | live_phase_from_http = None 170 | if gameflow_phase_resp and gameflow_phase_resp.status == 200: 171 | try: 172 | live_phase_from_http_raw = await gameflow_phase_resp.json() 173 | live_phase_from_http = str(live_phase_from_http_raw).strip('"') if isinstance(live_phase_from_http_raw, (str, int, float, bool)) else "Unknown" 174 | except JSONDecodeError: 175 | logger.error("Refresh Presence: Error parsing gameflow phase JSON.") 176 | await self._update_rpc_presence(clear=True); return 177 | else: 178 | logger.warning("Refresh Presence: Could not get current gameflow phase. Clearing RPC.") 179 | await self._update_rpc_presence(clear=True); return 180 | 181 | logger.info(f"Refresh Presence: Live phase from HTTP is '{live_phase_from_http}'.") 182 | 183 | actively_managed_by_gameflow = ("Lobby", "Matchmaking", "ChampSelect", "InProgress") 184 | idle_or_post_game_phases = ("None", "TerminatedInError", "WaitingForStats", "PreEndOfGame", "EndOfGame") 185 | 186 | phase_to_process = live_phase_from_http 187 | data_for_event = None 188 | last_event_phase = self.last_gameflow_event_data.get('phase') if self.last_gameflow_event_data else None 189 | 190 | if live_phase_from_http in actively_managed_by_gameflow and last_event_phase in idle_or_post_game_phases: 191 | logger.warning(f"Refresh Presence: HTTP phase '{live_phase_from_http}' conflicts with last event '{last_event_phase}'. Using last event.") 192 | phase_to_process = last_event_phase 193 | data_for_event = self.last_gameflow_event_data 194 | 195 | if phase_to_process in actively_managed_by_gameflow: 196 | if not data_for_event or data_for_event.get('phase') != phase_to_process: 197 | gameflow_session_resp = await connection.request('get', '/lol-gameflow/v1/session') 198 | if gameflow_session_resp and gameflow_session_resp.status == 200: 199 | try: data_for_event = await gameflow_session_resp.json() 200 | except JSONDecodeError: logger.error(f"Refresh: Error parsing session for '{phase_to_process}'."); await self._update_rpc_presence(clear=True); return 201 | else: logger.warning(f"Refresh: Failed to get session for '{phase_to_process}'."); await self._update_rpc_presence(clear=True); return 202 | 203 | if data_for_event: await self.on_gameflow_update(connection, type('Event', (), {'data': data_for_event})()) 204 | else: logger.error(f"Refresh: Data for event '{phase_to_process}' is None."); await self._update_rpc_presence(clear=True) 205 | 206 | elif phase_to_process in idle_or_post_game_phases: 207 | mock_event_data = data_for_event if data_for_event else self.last_gameflow_event_data 208 | if not mock_event_data or mock_event_data.get('phase') not in idle_or_post_game_phases: 209 | mock_event_data = {'phase': phase_to_process} 210 | await self.on_gameflow_update(connection, type('Event', (), {'data': mock_event_data})()) 211 | else: 212 | logger.info(f"Refresh Presence: Unknown gameflow phase '{phase_to_process}'. No RPC update made.") 213 | 214 | logger.info("Presence refresh attempt complete.") 215 | 216 | def _register_lcu_handlers(self): 217 | self.connector.ready(self.on_lcu_ready) 218 | self.connector.close(self.on_lcu_disconnect) 219 | self.connector.ws.register("/lol-gameflow/v1/session", event_types=("CREATE", "UPDATE", "DELETE"))(self.on_gameflow_update) 220 | self.connector.ws.register("/lol-chat/v1/me", event_types=("CREATE", "UPDATE", "DELETE"))(self.on_chat_update) 221 | self.connector.ws.register("/lol-champ-select/v1/session", event_types=("CREATE", "UPDATE"))(self.on_champ_select_update) 222 | 223 | async def _fetch_json_from_url(self, session, url, description="data"): 224 | try: 225 | tray_module.updateStatus(f"Status: Fetching {description}...") 226 | async with session.get(url) as resp: 227 | resp.raise_for_status() 228 | try: return await resp.json(encoding='utf-8-sig', content_type=None) 229 | except JSONDecodeError as e: 230 | logger.error(f"JSONDecodeError fetching {description} from {url}: {e}. Response: {await resp.text()[:200]}") 231 | tray_module.updateStatus(f"Status: Error parsing {description}.") 232 | return None 233 | except aiohttp.ClientError as e: 234 | logger.error(f"aiohttp.ClientError fetching {description} from {url}: {e}") 235 | tray_module.updateStatus(f"Status: Network error fetching {description}.") 236 | return None 237 | except Exception as e: 238 | logger.error(f"Unexpected error in _fetch_json_from_url for {description}: {e}", exc_info=True) 239 | return None 240 | 241 | async def _initialize_lcu_data(self, connection): 242 | tray_module.updateStatus("Status: Initializing LCU Data...") 243 | summoner_data_fetched = False 244 | fetch_start_time = time() 245 | while not summoner_data_fetched and not self.shutting_down: 246 | if time() - fetch_start_time > INITIAL_SUMMONER_FETCH_TIMEOUT: 247 | logger.error(f"Timeout fetching summoner data after {INITIAL_SUMMONER_FETCH_TIMEOUT}s.") 248 | return False 249 | summoner_response = await connection.request('get', '/lol-summoner/v1/current-summoner') 250 | if summoner_response and summoner_response.status == 200: 251 | try: 252 | self.summoner_data = await summoner_response.json() 253 | if self.summoner_data and self.summoner_data.get('summonerId'): 254 | logger.info(f"Summoner data fetched: {self.summoner_data.get('displayName')}") 255 | summoner_data_fetched = True; break 256 | else: logger.warning("Summoner data incomplete. Retrying...") 257 | except JSONDecodeError: logger.error("Failed to parse summoner data JSON. Retrying...") 258 | elif summoner_response and summoner_response.status == 404: logger.info("Summoner data not yet available (404). Retrying...") 259 | else: logger.warning(f"Failed to get summoner (Status: {summoner_response.status if summoner_response else 'N/A'}). Retrying...") 260 | if not summoner_data_fetched: await asyncio.sleep(INITIAL_SUMMONER_FETCH_RETRY_DELAY) 261 | if not summoner_data_fetched: logger.error("Could not fetch summoner data."); return False 262 | 263 | region_response = await connection.request('get', '/riotclient/region-locale') 264 | if region_response and region_response.status == 200: 265 | try: self.summoner_data['locale'] = (await region_response.json()).get('locale', DEFAULT_LOCALE).lower() 266 | except JSONDecodeError: logger.error("Failed to parse region/locale JSON."); self.summoner_data['locale'] = DEFAULT_LOCALE 267 | else: logger.warning(f"Failed to get region/locale. Using fallback."); self.summoner_data['locale'] = DEFAULT_LOCALE 268 | logger.info(f"Locale set to: {self.summoner_data['locale']}") 269 | 270 | async with aiohttp.ClientSession() as session: 271 | logger.info(f"AIOHTTP session for locale strings created: {id(session)}") 272 | discord_strings = await self._fetch_json_from_url(session, localeDiscordStrings(self.summoner_data['locale']), "Discord strings") 273 | chat_strings = await self._fetch_json_from_url(session, localeChatStrings(self.summoner_data['locale']), "chat strings") 274 | 275 | if not discord_strings or not chat_strings: 276 | logger.warning("Failed to load locale strings. Using fallbacks.") 277 | 278 | def show_locale_fallback_warning_dialog(): 279 | try: 280 | if gui_module._persistent_tk_root and gui_module._persistent_tk_root.winfo_exists(): 281 | messagebox.showwarning( 282 | "Localization Error", 283 | "Failed to fetch language files from the server.\n" 284 | "The application will use default English text.", 285 | parent=gui_module._persistent_tk_root 286 | ) 287 | logger.info("Locale fallback warning dialog shown.") 288 | else: 289 | logger.warning("Locale fallback warning dialog could not be shown: persistent Tk root no longer exists or not ready.") 290 | except tk.TclError as e_tk: 291 | logger.error(f"TclError showing locale fallback warning: {e_tk}. App might be closing.") 292 | except Exception as e_dialog: 293 | logger.error(f"Error showing locale fallback warning dialog: {e_dialog}", exc_info=True) 294 | 295 | if gui_module._persistent_tk_root and \ 296 | hasattr(gui_module._persistent_tk_root, 'winfo_exists') and \ 297 | gui_module._persistent_tk_root.winfo_exists() and \ 298 | gui_module._tk_root_ready_event.is_set(): 299 | try: 300 | gui_module._persistent_tk_root.after(0, show_locale_fallback_warning_dialog) 301 | logger.info("Scheduled locale fallback warning dialog on GUI thread.") 302 | except tk.TclError as e_schedule_tcl: 303 | logger.warning(f"Failed to schedule locale fallback warning (Tk root likely destroyed during .after call): {e_schedule_tcl}") 304 | logger.info("Proceeding with fallback strings without dialog (GUI thread/root issue).") 305 | except Exception as e_schedule: 306 | logger.error(f"Unexpected error scheduling locale fallback warning: {e_schedule}", exc_info=True) 307 | logger.info("Proceeding with fallback strings without dialog (scheduling issue).") 308 | else: 309 | logger.warning("GUI thread/root not ready or available. Locale fallback warning dialog will not be shown. Proceeding with fallback strings.") 310 | 311 | self.locale_strings = {"bot": "Bot Game", "champSelect": "Champion Select", "lobby": "In Lobby", "inGame": "In Game", "inQueue": "In Queue", "custom": "Custom Game", "practicetool": "Practice Tool", "away": "Away", "chat": "Online", "dnd": "Do Not Disturb"} 312 | else: 313 | practicetool_name = "Practice Tool" 314 | try: 315 | map_info_resp = await connection.request('get', '/lol-maps/v2/map/11/PRACTICETOOL') 316 | if map_info_resp and map_info_resp.status == 200: 317 | api_mode_name = (await map_info_resp.json()).get("gameModeName") 318 | if api_mode_name and api_mode_name.strip(): practicetool_name = api_mode_name.strip() 319 | else: logger.warning("API returned empty gameModeName for Practice Tool.") 320 | except Exception as e: logger.warning(f"Could not fetch Practice Tool name: {e}.") 321 | self.locale_strings = {"bot": discord_strings.get("Disc_Pres_QueueType_BOT", "Bot Game"), "champSelect": discord_strings.get("Disc_Pres_State_championSelect", "Champion Select"), "lobby": discord_strings.get("Disc_Pres_State_hosting", "In Lobby"), "inGame": discord_strings.get("Disc_Pres_State_inGame", "In Game"), "inQueue": discord_strings.get("Disc_Pres_State_inQueue", "In Queue"), "custom": discord_strings.get("Disc_Pres_QueueType_CUSTOM", "Custom Game"), "practicetool": practicetool_name, "away": chat_strings.get("availability_away", "Away"), "chat": chat_strings.get("availability_chat", "Online"), "dnd": chat_strings.get("availability_dnd", "Do Not Disturb")} 322 | logger.info(f"Locale strings loaded: {len(self.locale_strings)} entries.") 323 | return True 324 | 325 | async def _update_rpc_presence(self, clear=False, **kwargs): 326 | async with self.rpc_lock: 327 | if not self.rpc_connected: 328 | logger.debug("RPC not connected. Skipping update/clear.") 329 | return 330 | 331 | is_muted = fetchConfig("isRpcMuted") 332 | 333 | if clear: 334 | await asyncio.to_thread(self.rpc.clear) 335 | logger.info("RPC cleared.") 336 | return 337 | 338 | if is_muted: 339 | logger.info("RPC is muted. Clearing presence instead of updating.") 340 | await asyncio.to_thread(self.rpc.clear) 341 | return 342 | 343 | try: 344 | valid_kwargs = {k: v for k, v in kwargs.items() if v is not None} 345 | if valid_kwargs: 346 | await asyncio.to_thread(self.rpc.update, **valid_kwargs) 347 | logger.debug(f"RPC updated: {valid_kwargs.get('details','')}, {valid_kwargs.get('state','')}") 348 | else: 349 | logger.debug("RPC update called with no valid args and not clearing (and not muted).") 350 | except PyPresenceExceptions.InvalidPipe: 351 | logger.warning("Discord pipe closed. RPC disconnected.") 352 | self.rpc_connected = False 353 | asyncio.create_task(self.connect_discord_rpc(is_reconnect=True)) 354 | except RuntimeError as e: 355 | logger.error(f"RuntimeError updating RPC: {e}", exc_info="read() called while another coroutine" not in str(e)) 356 | except Exception as e: 357 | logger.error(f"Error updating RPC: {e}", exc_info=True) 358 | 359 | 360 | async def _cancel_task(self, task_attr_name: str, task_description: str): 361 | task = getattr(self, task_attr_name, None) 362 | if task and not task.done(): 363 | logger.info(f"Cancelling {task_description} task.") 364 | task.cancel() 365 | try: await task 366 | except asyncio.CancelledError: logger.info(f"{task_description} task successfully cancelled.") 367 | except Exception as e: logger.error(f"Error during cancellation of {task_description} task: {e}", exc_info=True) 368 | setattr(self, task_attr_name, None) 369 | 370 | async def _cancel_ingame_task(self): await self._cancel_task('ingame_rpc_task', 'in-game RPC') 371 | async def _cancel_delayed_idle_task(self): await self._cancel_task('_delayed_idle_handler_task', 'delayed idle handler') 372 | async def _cancel_lcu_disconnect_shutdown_task(self): await self._cancel_task('_lcu_disconnect_shutdown_task', 'LCU disconnect shutdown') 373 | 374 | async def _handle_delayed_idle_state(self, original_phase_from_event, connection_at_event_time): 375 | try: 376 | await asyncio.sleep(IDLE_STATE_CONFIRMATION_DELAY) 377 | if self.shutting_down or not self.lcu_connected or connection_at_event_time != self.lcu_manager.current_connection: return 378 | 379 | if fetchConfig("isRpcMuted"): # Check mute status before proceeding 380 | logger.info("Delayed idle: RPC is muted. Not setting idle presence.") 381 | await self._update_rpc_presence(clear=True) 382 | return 383 | 384 | gameflow_phase_resp = await connection_at_event_time.request('get', '/lol-gameflow/v1/gameflow-phase') 385 | live_phase_from_http = None 386 | if gameflow_phase_resp and gameflow_phase_resp.status == 200: 387 | try: live_phase_from_http = str(await gameflow_phase_resp.json()).strip('"') 388 | except JSONDecodeError: logger.error("Delayed idle: Error parsing live gameflow phase."); await self._update_rpc_presence(clear=True); return 389 | 390 | logger.info(f"Delayed idle: Original '{original_phase_from_event}', Live '{live_phase_from_http}'.") 391 | idle_or_post_game_trigger_phases = ("None", "TerminatedInError", "WaitingForStats", "PreEndOfGame", "EndOfGame") 392 | if live_phase_from_http not in idle_or_post_game_trigger_phases: logger.info(f"Delayed idle: Phase changed to '{live_phase_from_http}'. Aborting."); return 393 | if live_phase_from_http in ("PreEndOfGame", "EndOfGame"): logger.info(f"Delayed idle: Phase '{live_phase_from_http}' is post-game. Clearing RPC."); await self._update_rpc_presence(clear=True); return 394 | 395 | idle_option = fetchConfig("idleStatus") 396 | if idle_option == 0: logger.info(f"Delayed idle: Disabled for '{live_phase_from_http}'. Clearing RPC."); await self._update_rpc_presence(clear=True); return 397 | 398 | chat_me_response = await connection_at_event_time.request('get', '/lol-chat/v1/me') 399 | if chat_me_response and chat_me_response.status == 200: 400 | try: 401 | chat_data = await chat_me_response.json(); self.last_chat_event_data = chat_data 402 | availability = chat_data.get("availability", "chat").lower(); status_message = chat_data.get("statusMessage") 403 | rpc_payload_idle = {} 404 | if idle_option == 1: 405 | profile_display_config = fetchConfig("idleProfileInfoDisplay") 406 | large_text_parts = [] 407 | if profile_display_config.get("showRiotId"): 408 | large_text_parts.append(self.summoner_data.get('gameName', 'Player')) 409 | if profile_display_config.get("showTagLine") and self.summoner_data.get('tagLine'): 410 | if not large_text_parts or not profile_display_config.get("showRiotId"): 411 | large_text_parts.append(f"#{self.summoner_data.get('tagLine')}") 412 | else: 413 | large_text_parts[-1] += f"#{self.summoner_data.get('tagLine')}" 414 | 415 | level_str = f"Lvl {self.summoner_data.get('summonerLevel', 'N/A')}" 416 | if profile_display_config.get("showSummonerLevel"): 417 | if large_text_parts and (profile_display_config.get("showRiotId") or profile_display_config.get("showTagLine")): 418 | large_text_parts.append("|") 419 | large_text_parts.append(level_str) 420 | 421 | final_large_text = " ".join(large_text_parts).replace(" | ", "|") 422 | if not final_large_text.strip(): 423 | final_large_text = "League of Legends" 424 | 425 | rpc_payload_idle = { 426 | "state": self.locale_strings.get(availability, chat_data.get("availability", "Online")), 427 | "large_image": profileIcon(chat_data.get("icon")) if self.summoner_data else availabilityImg("leagueIcon"), 428 | "large_text": final_large_text, 429 | "small_image": availabilityImg(availability), 430 | "small_text": status_message if status_message else self.locale_strings.get(availability, chat_data.get("availability", "Online")) 431 | } 432 | elif idle_option == 2: 433 | rpc_payload_idle = { 434 | "large_image": fetchConfig("idleCustomImageLink") or availabilityImg("leagueIcon"), 435 | "large_text": fetchConfig("idleCustomText") or "Idle", 436 | "details": fetchConfig("idleCustomText") or "Chilling...", 437 | "state": None, 438 | "small_image": availabilityImg(availability) if fetchConfig("idleCustomShowStatusCircle") else None, 439 | "small_text": (status_message or self.locale_strings.get(availability, chat_data.get("availability", "Online"))) if fetchConfig("idleCustomShowStatusCircle") else None, 440 | "start": int(time()) if fetchConfig("idleCustomShowTimeElapsed") else None 441 | } 442 | if rpc_payload_idle: await self._update_rpc_presence(**rpc_payload_idle) 443 | else: await self._update_rpc_presence(clear=True) 444 | except JSONDecodeError: await self._update_rpc_presence(clear=True); logger.error("Delayed idle: Error parsing chat data.") 445 | except Exception as e_chat: await self._update_rpc_presence(clear=True); logger.error(f"Delayed idle: Error setting idle: {e_chat}") 446 | else: await self._update_rpc_presence(clear=True) 447 | except asyncio.CancelledError: logger.info("Delayed idle handler task was cancelled.") 448 | except Exception as e: logger.error(f"Error in _handle_delayed_idle_state: {e}", exc_info=True); await self._update_rpc_presence(clear=True) 449 | finally: self._delayed_idle_handler_task = None 450 | 451 | async def _delayed_shutdown_on_lcu_disconnect(self): 452 | try: 453 | logger.info(f"LCU disconnected. Starting {LCU_DISCONNECT_SHUTDOWN_DELAY}s timer for app shutdown...") 454 | await asyncio.sleep(LCU_DISCONNECT_SHUTDOWN_DELAY) 455 | if self.shutting_down: logger.info("App already shutting down. Delayed LCU disconnect shutdown aborted."); return 456 | if not self.lcu_connected: logger.info(f"LCU still disconnected after {LCU_DISCONNECT_SHUTDOWN_DELAY}s. Shutting down app."); await self.shutdown(exit_code=0) 457 | else: logger.info(f"LCU reconnected. Shutdown averted.") 458 | except asyncio.CancelledError: logger.info("Delayed shutdown task on LCU disconnect cancelled.") 459 | except Exception as e: logger.error(f"Error in _delayed_shutdown_on_lcu_disconnect: {e}", exc_info=True) 460 | finally: self._lcu_disconnect_shutdown_task = None 461 | 462 | async def on_lcu_ready(self, connection): 463 | logger.info("LCU Connected and Ready.") 464 | print("LCU Connected.") 465 | await self._cancel_lcu_disconnect_shutdown_task() 466 | tray_module.updateStatus("Status: LCU Connected. Initializing...") 467 | self.lcu_connected = True; self.last_connection_obj_for_refresh = connection 468 | if not await self._initialize_lcu_data(connection): 469 | tray_module.updateStatus("Status: Failed LCU data init."); logger.error("Failed LCU data init."); self.lcu_connected = False; return 470 | tray_module.updateStatus("Status: Ready"); logger.info("LCU Ready and Initialized.") 471 | asyncio.create_task(self.refresh_current_presence()) 472 | 473 | async def on_lcu_disconnect(self, connection): 474 | logger.info(f"LCU Disconnected.") 475 | print("LCU Disconnected.") 476 | self.lcu_connected = False; self.last_connection_obj_for_refresh = None 477 | self.last_gameflow_event_data = None; self.last_chat_event_data = None 478 | await self._cancel_delayed_idle_task(); await self._cancel_ingame_task() 479 | await self._update_rpc_presence(clear=True) 480 | tray_module.updateStatus("Status: LCU Disconnected. App may close soon.") 481 | await self._cancel_lcu_disconnect_shutdown_task() 482 | if not self.shutting_down: self._lcu_disconnect_shutdown_task = asyncio.create_task(self._delayed_shutdown_on_lcu_disconnect()) 483 | 484 | async def on_gameflow_update(self, connection, event): 485 | if not self.lcu_connected or not self.locale_strings: logger.debug("Gameflow update skipped, LCU not ready."); return 486 | 487 | if fetchConfig("isRpcMuted"): 488 | logger.info("Gameflow update: RPC is muted. Clearing presence.") 489 | await self._update_rpc_presence(clear=True) 490 | return 491 | 492 | data = event.data; self.last_gameflow_event_data = data; self.last_connection_obj_for_refresh = connection 493 | phase = data.get('phase'); logger.info(f"Gameflow update: Phase - {phase}") 494 | await self._cancel_delayed_idle_task() 495 | if phase not in ("InProgress", "PreEndOfGame", "EndOfGame", "WaitingForStats"): await self._cancel_ingame_task() 496 | 497 | actively_managed = ("Lobby", "Matchmaking", "ChampSelect", "InProgress") 498 | idle_post_game = ("None", "TerminatedInError", "WaitingForStats", "PreEndOfGame", "EndOfGame") 499 | 500 | if phase in actively_managed: 501 | game_data = data.get('gameData', {}); queue_data = game_data.get('queue', {}); map_data = data.get('map', {}) 502 | map_asset_data = map_data.get("assets") 503 | map_icon_path = map_asset_data.get(self.current_map_icon_asset_key_name) if map_asset_data else None 504 | if not map_icon_path and map_asset_data: map_icon_path = map_asset_data.get(DEFAULT_MAP_ICON_KEY) 505 | if not map_icon_path and phase != "InProgress": logger.debug(f"No map icon for phase {phase}. Map: {map_data.get('name')}") 506 | 507 | lobby_members_count = 0 508 | if phase != "InProgress": 509 | lobby_resp = await connection.request('get', '/lol-lobby/v2/lobby/members') 510 | if lobby_resp and lobby_resp.status == 200: 511 | try: lobby_members_count = len(await lobby_resp.json()) 512 | except JSONDecodeError: logger.error("Error parsing lobby members JSON.") 513 | elif lobby_resp and lobby_resp.status != 404: logger.warning(f"Failed to get lobby members (Status: {lobby_resp.status}).") 514 | 515 | queue_desc = queue_data.get('description', "Unknown Mode") 516 | if self.locale_strings: 517 | if queue_data.get("type") == "BOT": queue_desc = f"{self.locale_strings.get('bot', 'Bot')} {queue_desc}" 518 | if queue_data.get("category") == "Custom": queue_desc = self.locale_strings.get('custom', 'Custom Game') 519 | if queue_data.get('gameMode') == "PRACTICETOOL": queue_desc = self.locale_strings.get('practicetool', 'Practice Tool') 520 | 521 | rank_emblem_url, small_text_str = None, None 522 | if phase != "InProgress": 523 | current_queue_type = queue_data.get("type") 524 | show_ranks_config = fetchConfig("showRanks") 525 | if current_queue_type and isinstance(show_ranks_config, dict) and show_ranks_config.get(current_queue_type, False): 526 | ranked_stats_resp = await connection.request('get', '/lol-ranked/v1/current-ranked-stats') 527 | if ranked_stats_resp and ranked_stats_resp.status == 200: 528 | try: 529 | ranked_stats = await ranked_stats_resp.json() 530 | queue_map = ranked_stats.get("queueMap", {}) 531 | if isinstance(queue_map, dict): 532 | queue_rank_info = queue_map.get(current_queue_type) 533 | if isinstance(queue_rank_info, dict) and queue_rank_info.get("tier", "") not in ("", "NONE", "UNRANKED"): 534 | tier = queue_rank_info['tier'].capitalize(); division = queue_rank_info['division'] 535 | small_text_parts_temp = [f"{tier} {division}"] 536 | rank_emblem_url = rankedEmblem(queue_rank_info['tier']) 537 | ranked_stats_config = fetchConfig("rankedStats") 538 | if isinstance(ranked_stats_config, dict): 539 | if ranked_stats_config.get("lp"): small_text_parts_temp.append(f"{queue_rank_info.get('leaguePoints', 0)} LP") 540 | if ranked_stats_config.get("w"): small_text_parts_temp.append(f"{queue_rank_info.get('wins', 0)}W") 541 | if ranked_stats_config.get("l"): small_text_parts_temp.append(f"{queue_rank_info.get('losses', 0)}L") 542 | small_text_str = " • ".join(small_text_parts_temp) 543 | except JSONDecodeError: logger.error("Error parsing ranked stats JSON for gameflow update.") 544 | 545 | 546 | rpc_base = {"details": f"{map_data.get('name', 'Unknown Map')} ({queue_desc})", "large_text": map_data.get('name'), "small_image": rank_emblem_url, "small_text": small_text_str, "large_image": mapIcon(map_icon_path) if map_icon_path else None} 547 | 548 | if phase == "Lobby": 549 | if queue_data.get("mapId") == 0 and not map_data.get('name'): await self._update_rpc_presence(clear=True); return 550 | tray_module.updateStatus("Status: In Lobby"); rpc_base["state"] = self.locale_strings.get('lobby', 'In Lobby') 551 | if fetchConfig("showPartyInfo") and queue_data.get("maximumParticipantListSize", 0) > 0: rpc_base["party_size"] = [lobby_members_count, queue_data["maximumParticipantListSize"]] 552 | await self._update_rpc_presence(**rpc_base) 553 | elif phase == "Matchmaking": 554 | tray_module.updateStatus("Status: In Queue"); rpc_base["state"] = self.locale_strings.get('inQueue', 'In Queue'); rpc_base["start"] = int(time()) 555 | await self._update_rpc_presence(**rpc_base) 556 | elif phase == "ChampSelect": 557 | tray_module.updateStatus("Status: In Champ Select"); rpc_base["state"] = self.locale_strings.get('champSelect', 'Champion Select') 558 | await self._update_rpc_presence(**rpc_base) 559 | elif phase == "InProgress": 560 | tray_module.updateStatus("Status: In Game"); await self._cancel_ingame_task() 561 | self.ingame_rpc_task = asyncio.create_task(updateInProgressRPC(lambda: not self.lcu_connected or self.shutting_down, int(time()), self.current_champ_selection, map_data, map_icon_path, queue_data, game_data, self.summoner_data.get('internalName'), self.summoner_data.get('displayName'), connection, self.summoner_data.get('summonerId'), self.locale_strings, self.rpc, self.rpc_lock)) 562 | 563 | elif phase in idle_post_game: 564 | logger.info(f"Gameflow phase '{phase}' received. Scheduling delayed idle/clear handler.") 565 | await self._cancel_ingame_task(); await self._cancel_delayed_idle_task() 566 | self._delayed_idle_handler_task = asyncio.create_task(self._handle_delayed_idle_state(phase, connection)) 567 | else: 568 | logger.info(f"Gameflow phase '{phase}' is unknown. No RPC update made.") 569 | 570 | async def on_chat_update(self, connection, event): 571 | if not self.lcu_connected or not self.locale_strings or not self.summoner_data: logger.debug("Chat update skipped, LCU not ready."); return 572 | 573 | if fetchConfig("isRpcMuted"): 574 | logger.info("Chat update: RPC is muted. Clearing presence.") 575 | await self._update_rpc_presence(clear=True) 576 | return 577 | 578 | chat_data = event.data; self.last_chat_event_data = chat_data; self.last_connection_obj_for_refresh = connection 579 | gameflow_phase_resp = await connection.request('get', '/lol-gameflow/v1/gameflow-phase') 580 | current_gameflow_phase = None 581 | if gameflow_phase_resp and gameflow_phase_resp.status == 200: 582 | try: current_gameflow_phase = str(await gameflow_phase_resp.json()).strip('"') 583 | except JSONDecodeError: logger.error("Error parsing gameflow phase for chat update."); return 584 | 585 | active_phases = ("Lobby", "Matchmaking", "ChampSelect", "InProgress", "PreEndOfGame", "EndOfGame") 586 | if current_gameflow_phase in active_phases: logger.debug(f"Chat update in active phase '{current_gameflow_phase}'. Gameflow handles RPC."); return 587 | if self._delayed_idle_handler_task and not self._delayed_idle_handler_task.done(): logger.debug("Chat update for idle, but delayed handler pending."); return 588 | if self.ingame_rpc_task and not self.ingame_rpc_task.done(): logger.debug("Chat update for idle, in-game task running, cancelling."); await self._cancel_ingame_task() 589 | 590 | tray_module.updateStatus("Status: Ready (Idle)") 591 | availability = chat_data.get("availability", "chat").lower(); status_message = chat_data.get("statusMessage") 592 | idle_option = fetchConfig("idleStatus"); rpc_payload = {} 593 | if idle_option == 0: await self._update_rpc_presence(clear=True); logger.info("Idle status: RPC cleared (Disabled)."); return 594 | elif idle_option == 1: rpc_payload = {"state": self.locale_strings.get(availability, chat_data.get("availability", "Online")), "large_image": profileIcon(chat_data.get("icon")) if self.summoner_data else availabilityImg("leagueIcon"), "large_text": f"{self.summoner_data.get('displayName', 'Player')}#{self.summoner_data.get('tagLine','')} | Lvl {self.summoner_data.get('summonerLevel', 'N/A')}" if self.summoner_data else "League of Legends", "small_image": availabilityImg(availability), "small_text": status_message if status_message else self.locale_strings.get(availability, chat_data.get("availability", "Online"))} 595 | elif idle_option == 2: rpc_payload = {"large_image": fetchConfig("idleCustomImageLink") or availabilityImg("leagueIcon"), "large_text": fetchConfig("idleCustomText") or "Idle", "details": fetchConfig("idleCustomText") or "Chilling...", "state": None, "small_image": availabilityImg(availability) if fetchConfig("idleCustomShowStatusCircle") else None, "small_text": (status_message or self.locale_strings.get(availability, chat_data.get("availability", "Online"))) if fetchConfig("idleCustomShowStatusCircle") else None, "start": int(time()) if fetchConfig("idleCustomShowTimeElapsed") else None} 596 | if rpc_payload: await self._update_rpc_presence(**rpc_payload) 597 | logger.info(f"Chat status updated to: {availability}, idle option: {idle_option}") 598 | 599 | async def on_champ_select_update(self, connection, event): 600 | if not self.lcu_connected or not self.summoner_data: return 601 | my_team = event.data.get("myTeam", []) 602 | for player in my_team: 603 | if player.get("summonerId") == self.summoner_data.get("summonerId"): 604 | self.current_champ_selection = (player.get("championId", 0), player.get("selectedSkinId", 0)) 605 | logger.info(f"Champ selection updated: ID {self.current_champ_selection[0]}, Skin {self.current_champ_selection[1]}") 606 | break 607 | 608 | async def connect_discord_rpc(self, is_reconnect=False): 609 | async with self.rpc_lock: 610 | if self.rpc_connected and not is_reconnect: logger.debug("RPC already connected."); return 611 | status_msg = "Reconnecting to Discord..." if is_reconnect else "Connecting to Discord..." 612 | logger.info(status_msg); tray_module.updateStatus(f"Status: {status_msg}") 613 | try: 614 | await asyncio.to_thread(self.rpc.connect); self.rpc_connected = True 615 | logger.info("RPC Connected to Discord."); print("RPC Connected to Discord."); tray_module.updateStatus("Status: Connected to Discord.") 616 | if fetchConfig("isRpcMuted"): # If muted on connect, clear presence 617 | logger.info("RPC connected but is muted. Clearing initial presence.") 618 | await self._update_rpc_presence(clear=True) 619 | else: # Refresh presence if not muted 620 | asyncio.create_task(self.refresh_current_presence()) 621 | 622 | except PyPresenceExceptions.InvalidPipe: logger.warning("Discord pipe closed. Is Discord running?"); tray_module.updateStatus("Status: Discord not found."); self.rpc_connected = False 623 | except RuntimeError as e: logger.error(f"RuntimeError connecting RPC: {e}", exc_info="event loop is already running" not in str(e)); tray_module.updateStatus("Status: Discord connection error."); self.rpc_connected = False 624 | except Exception as e: logger.error(f"Error connecting RPC: {e}", exc_info=True); tray_module.updateStatus("Status: Discord connection error."); self.rpc_connected = False 625 | 626 | async def _check_updates(self): 627 | logger.info("DetailedLoLRPC: Checking for updates via updater module...") 628 | update_initiated_and_requires_exit = False 629 | try: 630 | update_initiated_and_requires_exit = await asyncio.to_thread( 631 | updater.perform_update, 632 | show_messagebox_callback=None, 633 | rpc_app_ref=self 634 | ) 635 | 636 | if update_initiated_and_requires_exit: 637 | logger.info("DetailedLoLRPC: Update process initiated by updater.perform_update and requires app exit.") 638 | else: 639 | logger.info("DetailedLoLRPC: Update check completed. No update applied or no exit required.") 640 | 641 | except Exception as e: 642 | logger.error(f"DetailedLoLRPC: Error during _check_updates using updater module: {e}", exc_info=True) 643 | 644 | return update_initiated_and_requires_exit 645 | 646 | async def _launch_league_if_needed(self): 647 | if procPath(LEAGUE_CLIENT_EXECUTABLE): logger.debug(f"{LEAGUE_CLIENT_EXECUTABLE} already running."); return True 648 | tray_module.updateStatus("Status: Starting League..."); logger.info("League not detected. Launching...") 649 | try: 650 | riot_path = fetchConfig("riotPath") 651 | if not riot_path or not os.path.isdir(riot_path): logger.error(f"Invalid Riot path: '{riot_path}'."); tray_module.updateStatus("Status: Invalid Riot Path."); return False 652 | rcs_exe = os.path.join(riot_path, "Riot Client", RIOT_CLIENT_SERVICES_EXECUTABLE) 653 | if not os.path.exists(rcs_exe): rcs_exe = os.path.join(riot_path, RIOT_CLIENT_SERVICES_EXECUTABLE) 654 | if not os.path.exists(rcs_exe): logger.error(f"{RIOT_CLIENT_SERVICES_EXECUTABLE} not found from '{riot_path}'."); tray_module.updateStatus(f"Status: {RIOT_CLIENT_SERVICES_EXECUTABLE} not found."); return False 655 | 656 | logger.info(f"Launch Step 1: Running {RIOT_CLIENT_SERVICES_EXECUTABLE} (no args).") 657 | Popen([rcs_exe], stdout=DEVNULL, stderr=DEVNULL, stdin=DEVNULL, shell=False) 658 | 659 | logger.info(f"Launch Step 2: Waiting for {RIOT_CLIENT_UX_EXECUTABLE}...") 660 | rcs_ux_running = False; wait_start = time() 661 | while time() - wait_start < RCS_UX_WAIT_TIMEOUT: 662 | if procPath(RIOT_CLIENT_UX_EXECUTABLE): logger.info(f"{RIOT_CLIENT_UX_EXECUTABLE} detected."); rcs_ux_running = True; break 663 | await asyncio.sleep(1) 664 | if not rcs_ux_running: logger.warning(f"{RIOT_CLIENT_UX_EXECUTABLE} not detected after {RCS_UX_WAIT_TIMEOUT}s. Proceeding anyway.") 665 | 666 | logger.info("Launch Step 3: Waiting 1.5s.") 667 | await asyncio.sleep(1.5) 668 | 669 | logger.info(f"Launch Step 4: Running {RIOT_CLIENT_SERVICES_EXECUTABLE} with League args.") 670 | Popen([rcs_exe, '--launch-product=league_of_legends', '--launch-patchline=live'], stdout=DEVNULL, stderr=DEVNULL, stdin=DEVNULL, shell=False) 671 | logger.info("League launch command issued.") 672 | return True 673 | except Exception as e: logger.error(f"Failed to launch League: {e}", exc_info=True); tray_module.updateStatus("Status: Error launching League."); return False 674 | 675 | async def run(self): 676 | logger.info("DetailedLoLRPC application starting...") 677 | if fetchConfig("checkForUpdatesOnStartup"): 678 | if await self._check_updates(): 679 | logger.info("Update found and initiated by startup check. Shutting down for update.") 680 | await self.shutdown(0) 681 | return 682 | 683 | try: tray_module.icon.run_detached(); logger.info("Tray icon started.") 684 | except Exception as e: logger.error(f"Failed to start tray icon: {e}", exc_info=True) 685 | self._config_watcher_task = asyncio.create_task(self._config_change_listener()) 686 | logger.info("Attempting to disable native presence..."); Process(target=disableNativePresence, daemon=True).start() 687 | await self._launch_league_if_needed() 688 | await self.connect_discord_rpc() 689 | logger.info("Starting LCU Manager..."); lcu_manager_task = asyncio.create_task(self.lcu_manager.start()) 690 | try: await lcu_manager_task 691 | except asyncio.CancelledError: logger.info("Main run task (LCU Manager) cancelled.") 692 | except Exception as e: logger.critical(f"Unhandled exception awaiting LCU Manager: {e}", exc_info=True); await self.shutdown(1) 693 | finally: 694 | logger.info("LCU Manager task ended or was cancelled.") 695 | if not self.shutting_down: 696 | if self.lcu_connected: logger.warning("LCU Manager task ended unexpectedly. Shutting down."); await self.shutdown(1) 697 | elif not self._lcu_disconnect_shutdown_task or self._lcu_disconnect_shutdown_task.done(): logger.info("LCU Manager task ended; LCU not connected, no disconnect shutdown active.") 698 | 699 | async def shutdown(self, exit_code=0): 700 | if self.shutting_down: 701 | logger.debug("Shutdown already in progress.") 702 | return 703 | self.shutting_down = True 704 | self._final_exit_code = exit_code 705 | logger.info(f"Shutdown initiated (code {exit_code}).") 706 | print("Shutting down DetailedLoLRPC...") 707 | tray_module.updateStatus("Status: Shutting down...") 708 | 709 | # tasks_to_cancel = [ 710 | # self._config_watcher_task, 711 | # self._lcu_disconnect_shutdown_task, 712 | # self._delayed_idle_handler_task, 713 | # self.ingame_rpc_task 714 | # ] 715 | # active_tasks_to_cancel = [task for task in tasks_to_cancel if task and not task.done()] 716 | 717 | # if active_tasks_to_cancel: 718 | # logger.info(f"Cancelling {len(active_tasks_to_cancel)} application tasks...") 719 | # for task in active_tasks_to_cancel: 720 | # task.cancel() 721 | # try: 722 | # await asyncio.gather(*active_tasks_to_cancel, return_exceptions=True) 723 | # logger.info("Application tasks cancelled/completed.") 724 | # except Exception as e_gather: 725 | # logger.error(f"Error during gather of cancelled tasks: {e_gather}", exc_info=True) 726 | 727 | 728 | # self._config_watcher_task = None 729 | # self._lcu_disconnect_shutdown_task = None 730 | # self._delayed_idle_handler_task = None 731 | # self.ingame_rpc_task = None 732 | 733 | if hasattr(self, 'lcu_manager') and self.lcu_manager: 734 | logger.info("Stopping LCU Manager...") 735 | await self.lcu_manager.stop() 736 | logger.info("LCU Manager stopped.") 737 | 738 | if hasattr(self.lcu_manager, 'connector') and \ 739 | hasattr(self.lcu_manager.connector, '_session') and \ 740 | self.lcu_manager.connector._session and \ 741 | not self.lcu_manager.connector._session.closed: 742 | try: 743 | logger.info("Explicitly awaiting lcu_driver connector session close...") 744 | await self.lcu_manager.connector._session.close() 745 | logger.info("lcu_driver connector session explicitly closed.") 746 | except Exception as e_lcu_close: 747 | logger.error(f"Error explicitly closing lcu_driver session: {e_lcu_close}", exc_info=True) 748 | else: 749 | logger.info("lcu_driver connector session was already closed or not present.") 750 | 751 | if self.rpc_connected: 752 | logger.info("Closing Discord RPC connection...") 753 | try: 754 | async with self.rpc_lock: 755 | await asyncio.to_thread(self.rpc.close) 756 | logger.info("RPC connection closed.") 757 | except Exception as e: logger.error(f"Error closing RPC: {e}", exc_info=True) 758 | self.rpc_connected = False 759 | self.lcu_connected = False 760 | 761 | release_lock() 762 | logger.info("Instance lock released.") 763 | 764 | logger.info("DetailedLoLRPC shut down."); tray_module.updateStatus("Status: Offline.") 765 | if hasattr(tray_module.icon, 'stop'): 766 | try: 767 | logger.debug("Attempting to stop tray icon thread...") 768 | tray_module.icon.stop() 769 | logger.info("Tray icon stop signal sent.") 770 | except Exception as e: logger.warning(f"Exception stopping tray icon: {e}") 771 | 772 | logger.info("Final brief sleep for any pending I/O before stopping event loop.") 773 | await asyncio.sleep(0.75) 774 | 775 | if self._main_loop_ref and self._main_loop_ref.is_running(): 776 | logger.info(f"Stopping main event loop. Program will exit via main.py once loop stops.") 777 | self._main_loop_ref.stop() 778 | else: 779 | logger.warning(f"Main event loop not available or not running during shutdown.") 780 | 781 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developers192/DetailedLoLRPC/7ca3f3a20971338df3fbf723edf762cf40448155/src/__init__.py -------------------------------------------------------------------------------- /src/cdngen.py: -------------------------------------------------------------------------------- 1 | def mapIdimg(mapid: int): 2 | conv = { 3 | 11: "classic_sru", 4 | 12: "aram", 5 | 22: "tft", 6 | 30: "gamemodex", 7 | 21: "shared" 8 | } 9 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/content/src/leagueclient/gamemodeassets/{conv[mapid]}/img/game-select-icon-active.png" 10 | 11 | def mapIcon(data): 12 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/" + "/".join(data.split("/")[2:]).lower() 13 | 14 | def defaultTileLink(champId): 15 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/{champId}.png" 16 | 17 | def assetsLink(link): 18 | links = link.lower().split("/")[4:] 19 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/assets/{'/'.join(links)}" 20 | 21 | def tftImg(compDir): 22 | name = compDir.split("/")[-1].lower() 23 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/assets/loadouts/companions/{name}" 24 | 25 | def localeDiscordStrings(locale): 26 | if locale == "en_us": 27 | locale = "default" 28 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/{locale}/v1/discord_strings.json" 29 | 30 | def localeChatStrings(locale): 31 | if locale == "en_us": 32 | locale = "default" 33 | return f"https://raw.communitydragon.org/latest/plugins/rcp-fe-lol-social/global/{locale}/trans.json" 34 | 35 | def profileIcon(id): 36 | return f"https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/profile-icons/{id}.jpg" 37 | 38 | def availabilityImg(a): 39 | conv = { 40 | "chat": "https://i.imgur.com/I2XxZ5y.png", 41 | "away": "https://i.imgur.com/X5YwSxs.png", 42 | "dnd": "https://i.imgur.com/5I4uDSL.png", 43 | "leagueIcon": "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/assets/splashscreens/lol_icon.png" 44 | } 45 | return conv[a] 46 | 47 | def rankedEmblem(rank): 48 | return f"https://raw.communitydragon.org/latest/plugins/rcp-fe-lol-shared-components/global/default/{rank.lower()}.png" 49 | 50 | from src.utilities import ANIMATEDSPLASHESURL 51 | def animatedSplashUrl(skinId): 52 | return f"{ANIMATEDSPLASHESURL}/{skinId}.gif" 53 | -------------------------------------------------------------------------------- /src/disabler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | 5 | from .utilities import fetchConfig, procPath, logger, addLog 6 | 7 | # Hardcoded constants 8 | LEAGUE_CLIENT_EXECUTABLE = "LeagueClient.exe" 9 | PLUGIN_NAME_TO_DISABLE = "rcp-be-lol-discord-rp" 10 | LOOP_TIMEOUT_SECONDS = 60 11 | 12 | def disableNativePresence(): 13 | """ 14 | Attempts to disable League of Legends' native Discord Rich Presence 15 | by modifying the plugin-manifest.json file. 16 | This function is intended to be run in a separate process. 17 | """ 18 | logger.info(f"Native Discord Presence Disabler process started. Target plugin: {PLUGIN_NAME_TO_DISABLE}") 19 | addLog(f"Disabler: Process started. Target: {PLUGIN_NAME_TO_DISABLE}", level="INFO") 20 | 21 | riot_path = fetchConfig("riotPath") 22 | if not riot_path or not os.path.isdir(riot_path): 23 | logger.error(f"Invalid Riot Games path from config: '{riot_path}'. Disabler cannot proceed.") 24 | addLog(f"Disabler Error: Invalid Riot Path '{riot_path}'. Cannot proceed.", level="ERROR") 25 | return 26 | 27 | plugins_dir = os.path.join(riot_path, "League of Legends", "Plugins") 28 | manifest_path = os.path.join(plugins_dir, "plugin-manifest.json") 29 | 30 | logger.debug(f"Plugin manifest path: {manifest_path}") 31 | 32 | if not os.path.exists(manifest_path): 33 | logger.warning(f"Plugin manifest file not found at {manifest_path}. Cannot disable native presence.") 34 | addLog(f"Disabler Warning: Manifest not found at {manifest_path}.", level="WARNING") 35 | if not os.path.exists(plugins_dir): 36 | try: 37 | os.makedirs(plugins_dir) 38 | logger.info(f"Created missing Plugins directory: {plugins_dir}") 39 | except OSError as e: 40 | logger.error(f"Failed to create Plugins directory {plugins_dir}: {e}") 41 | return 42 | 43 | modified_content = None 44 | try: 45 | with open(manifest_path, "r", encoding='utf-8') as f: 46 | content = json.load(f) 47 | 48 | if not isinstance(content, dict) or "plugins" not in content or not isinstance(content["plugins"], list): 49 | logger.error(f"Invalid plugin manifest format in {manifest_path}. 'plugins' key missing or not a list.") 50 | addLog(f"Disabler Error: Invalid manifest format in {manifest_path}.", level="ERROR") 51 | return 52 | 53 | original_plugins = content["plugins"] 54 | updated_plugins = [p for p in original_plugins if not (isinstance(p, dict) and p.get("name") == PLUGIN_NAME_TO_DISABLE)] 55 | 56 | if len(updated_plugins) < len(original_plugins): 57 | logger.info(f"Plugin '{PLUGIN_NAME_TO_DISABLE}' found and marked for removal from manifest.") 58 | addLog(f"Disabler: Plugin '{PLUGIN_NAME_TO_DISABLE}' found for removal.", level="INFO") 59 | content["plugins"] = updated_plugins 60 | modified_content = content 61 | else: 62 | logger.info(f"Plugin '{PLUGIN_NAME_TO_DISABLE}' not found in manifest or already removed.") 63 | addLog(f"Disabler: Plugin '{PLUGIN_NAME_TO_DISABLE}' not found or already removed.", level="INFO") 64 | modified_content = content 65 | 66 | except FileNotFoundError: 67 | logger.error(f"Plugin manifest file not found at {manifest_path} during read attempt.") 68 | addLog(f"Disabler Error: Manifest not found at {manifest_path} (read).", level="ERROR") 69 | return 70 | except json.JSONDecodeError as e: 71 | logger.error(f"Error decoding JSON from plugin manifest {manifest_path}: {e}") 72 | addLog(f"Disabler Error: JSON decode error in {manifest_path}: {str(e)}", level="ERROR") 73 | return 74 | except Exception as e: 75 | logger.error(f"Unexpected error processing plugin manifest {manifest_path}: {e}", exc_info=True) 76 | addLog(f"Disabler Error: Unexpected error processing manifest: {str(e)}", level="ERROR") 77 | return 78 | 79 | if modified_content is None: 80 | logger.warning("No modified content to write. Exiting disabler process.") 81 | addLog("Disabler Warning: No modified content generated.", level="WARNING") 82 | return 83 | 84 | start_time = time.time() 85 | logger.info("Starting loop to continuously disable native presence (no sleep interval).") 86 | addLog("Disabler: Starting write loop (no sleep).", level="DEBUG") 87 | 88 | while True: 89 | try: 90 | if not os.path.exists(os.path.dirname(manifest_path)): 91 | os.makedirs(os.path.dirname(manifest_path)) 92 | logger.info(f"Re-created directory for manifest: {os.path.dirname(manifest_path)}") 93 | 94 | with open(manifest_path, "w", encoding='utf-8') as f: 95 | json.dump(modified_content, f, indent=2) 96 | logger.debug(f"Successfully wrote modified manifest to {manifest_path}") 97 | except PermissionError: 98 | logger.warning(f"Permission denied while trying to write to {manifest_path}. Retrying.") 99 | addLog(f"Disabler Warning: Permission denied writing to {manifest_path}.", level="WARNING") 100 | except FileNotFoundError: 101 | logger.warning(f"Manifest file {manifest_path} disappeared before write. Retrying.") 102 | addLog(f"Disabler Warning: Manifest file disappeared before write at {manifest_path}.", level="WARNING") 103 | except Exception as e: 104 | logger.error(f"Error writing modified manifest to {manifest_path}: {e}", exc_info=True) 105 | addLog(f"Disabler Error: Writing manifest: {str(e)}", level="ERROR") 106 | 107 | if procPath(LEAGUE_CLIENT_EXECUTABLE): 108 | logger.info(f"{LEAGUE_CLIENT_EXECUTABLE} detected. Stopping disabler loop.") 109 | addLog("Disabler: LeagueClient.exe detected. Stopping.", level="INFO") 110 | break 111 | 112 | if time.time() - start_time > LOOP_TIMEOUT_SECONDS: 113 | logger.info(f"Disabler loop timed out after {LOOP_TIMEOUT_SECONDS} seconds.") 114 | addLog(f"Disabler: Loop timed out after {LOOP_TIMEOUT_SECONDS}s.", level="INFO") 115 | break 116 | 117 | logger.info("Native Discord Presence Disabler process finished.") 118 | addLog("Disabler: Process finished.", level="INFO") 119 | 120 | if __name__ == '__main__': 121 | print("Running disabler.py directly for testing purposes.") 122 | disableNativePresence() 123 | print("Disabler test finished.") 124 | -------------------------------------------------------------------------------- /src/gamestats.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import urllib3 4 | from typing import Dict, Optional, Any 5 | 6 | from .utilities import addLog, logger # Import logger and addLog 7 | 8 | # --- Constants --- 9 | LIVE_CLIENT_API_BASE_URL = "https://127.0.0.1:2999/liveclientdata" 10 | REQUEST_TIMEOUT = 1.0 11 | 12 | # Suppress only the InsecureRequestWarning from urllib3 needed for verify=False 13 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 14 | 15 | # Special marker to indicate API is not ready (e.g., loading screen 404 or connection error) 16 | API_NOT_READY_MARKER = {"status": "api_not_ready"} 17 | 18 | def _make_live_client_request(endpoint: str, description: str) -> Optional[Any]: 19 | """ 20 | Helper function to make requests to the Live Client API. 21 | Handles common errors and JSON parsing. Returns API_NOT_READY_MARKER on 404 or connection issues. 22 | """ 23 | url = f"{LIVE_CLIENT_API_BASE_URL}/{endpoint}" 24 | try: 25 | response = requests.get(url, verify=False, timeout=REQUEST_TIMEOUT) 26 | response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) 27 | 28 | if endpoint == "activeplayername": # This endpoint returns plain text 29 | try: 30 | # Try to parse as JSON first, as it might be a quoted string 31 | name_data = response.json() 32 | if isinstance(name_data, str): 33 | return name_data.strip('"') # Remove quotes if present 34 | else: 35 | # If it's not a simple string after JSON parsing, log and fallback to raw text 36 | logger.warning(f"Unexpected JSON structure for {description} from {url}: {name_data}. Expected string.") 37 | return response.text.strip() 38 | except json.JSONDecodeError: 39 | # If not JSON, it's likely plain text 40 | logger.debug(f"{description} from {url} is not JSON, treating as plain text.") 41 | return response.text.strip() 42 | 43 | return response.json() # For other endpoints that return JSON objects/lists 44 | except requests.exceptions.HTTPError as e: 45 | if e.response.status_code == 404: 46 | logger.debug(f"Live Client API endpoint {url} returned 404 (Not Found) for {description}. Likely loading screen.") 47 | return API_NOT_READY_MARKER 48 | logger.error(f"HTTPError fetching {description} from {url}: {e.response.status_code} - {e.response.text[:100]}", exc_info=False) 49 | addLog(f"Live Client API HTTPError: {description} {e.response.status_code}", level="ERROR") 50 | return None # For other HTTP errors 51 | except requests.exceptions.RequestException as e: # Catches ConnectionError, Timeout, etc. 52 | logger.warning(f"RequestException fetching {description} from {url}: {type(e).__name__}. Likely loading screen or client not in game.", exc_info=False) 53 | # Do not addLog here to avoid spamming if client is just not in game. 54 | return API_NOT_READY_MARKER # Treat connection errors also as API not ready 55 | except Exception as e: # Catch-all for other unexpected errors 56 | logger.error(f"Unexpected error in _make_live_client_request for {description} at {url}: {e}", exc_info=True) 57 | addLog(f"Live Client API Unexpected Error: {description} {str(e)}", level="ERROR") 58 | return None 59 | 60 | 61 | def get_active_player_summoner_name() -> Optional[Any]: # Can return string or API_NOT_READY_MARKER 62 | """ 63 | Fetches the summoner name of the active player from the Live Client API. 64 | Returns API_NOT_READY_MARKER if the API is not ready (404/connection error). 65 | """ 66 | logger.debug("Fetching active player summoner name...") 67 | active_player_name_data = _make_live_client_request("activeplayername", "active player name") 68 | 69 | if active_player_name_data == API_NOT_READY_MARKER: 70 | return API_NOT_READY_MARKER # Propagate marker 71 | 72 | if active_player_name_data and isinstance(active_player_name_data, str): 73 | name = active_player_name_data # Already stripped in _make_live_client_request 74 | logger.debug(f"Active player summoner name: {name}") 75 | return name 76 | elif active_player_name_data: 77 | logger.warning(f"Received non-string/non-marker data for active player name: {active_player_name_data}") 78 | return None 79 | 80 | def get_current_game_time() -> Optional[Any]: # Can return float, API_NOT_READY_MARKER, or None 81 | """ 82 | Fetches the current game time from /liveclientdata/gamestats. 83 | Returns the gameTime as float, API_NOT_READY_MARKER, or None if an error occurs. 84 | """ 85 | logger.debug("Fetching current game time from API...") 86 | gamestats_data = _make_live_client_request("gamestats", "game time stats") 87 | 88 | if gamestats_data == API_NOT_READY_MARKER: 89 | return API_NOT_READY_MARKER 90 | 91 | if isinstance(gamestats_data, dict) and "gameTime" in gamestats_data: 92 | game_time = gamestats_data["gameTime"] 93 | if isinstance(game_time, (float, int)): 94 | logger.debug(f"Current game time from API: {game_time}") 95 | return float(game_time) 96 | else: 97 | logger.warning(f"gamestats API returned gameTime but it's not a number: {game_time}") 98 | elif gamestats_data is not None: # Data received but not in expected format 99 | logger.warning(f"gamestats API response did not contain 'gameTime' or was not a dict. Data: {str(gamestats_data)[:100]}") 100 | 101 | return None # If error, or gameTime not found/invalid type 102 | 103 | def getStats() -> Dict[str, Any]: # Return type can include the marker 104 | """ 105 | Fetches KDA, CS, and level for the active player from the Live Client API. 106 | Returns a dictionary with "kda", "cs", "level" as keys, or API_NOT_READY_MARKER. 107 | """ 108 | logger.debug("Attempting to fetch live game stats...") 109 | default_stats = {"kda": None, "cs": None, "level": None} 110 | 111 | active_summoner_name_result = get_active_player_summoner_name() 112 | if active_summoner_name_result == API_NOT_READY_MARKER: 113 | logger.debug("Active player name endpoint not ready (getStats). Indicating API loading state.") 114 | return API_NOT_READY_MARKER 115 | if not active_summoner_name_result: 116 | logger.warning("Could not determine active player's summoner name (not a 404/conn error). Cannot fetch stats.") 117 | return default_stats 118 | active_summoner_name = active_summoner_name_result 119 | 120 | player_list_data = _make_live_client_request("playerlist", "player list") 121 | if player_list_data == API_NOT_READY_MARKER: 122 | logger.debug("Player list endpoint not ready (getStats). Indicating API loading state.") 123 | return API_NOT_READY_MARKER 124 | 125 | if not player_list_data or not isinstance(player_list_data, list): 126 | logger.warning(f"Could not retrieve or parse player list. Data: {str(player_list_data)[:100]}") 127 | return default_stats 128 | 129 | for player_data in player_list_data: 130 | if isinstance(player_data, dict) and player_data.get("summonerName") == active_summoner_name: 131 | try: 132 | scores = player_data.get("scores", {}) 133 | kda_str = f"{scores.get('kills', 0)}/{scores.get('deaths', 0)}/{scores.get('assists', 0)}" 134 | cs_str = str(scores.get('creepScore', 0)) 135 | level_str = str(player_data.get('level', 0)) 136 | 137 | logger.debug(f"Stats found for {active_summoner_name}: KDA {kda_str}, CS {cs_str}, Lvl {level_str}") 138 | return {"kda": kda_str, "cs": cs_str, "level": level_str} 139 | except KeyError as e: 140 | logger.error(f"Missing expected key in player data for {active_summoner_name}: {e}", exc_info=True) 141 | return default_stats 142 | except Exception as e: 143 | logger.error(f"Error processing player data for {active_summoner_name}: {e}", exc_info=True) 144 | return default_stats 145 | 146 | logger.warning(f"Active player '{active_summoner_name}' not found in player list or stats missing.") 147 | return default_stats 148 | 149 | if __name__ == '__main__': 150 | logger.info("Running gamestats.py directly for testing...") 151 | 152 | current_time = get_current_game_time() 153 | if current_time == API_NOT_READY_MARKER: 154 | logger.info("Test Game Time: API not ready.") 155 | elif isinstance(current_time, float): 156 | logger.info(f"Test Game Time: {current_time:.2f} seconds") 157 | else: 158 | logger.warning("Test Game Time: Could not retrieve game time.") 159 | 160 | stats = getStats() 161 | if stats == API_NOT_READY_MARKER: 162 | logger.info("Test Stats: API not ready.") 163 | elif stats.get("kda"): 164 | logger.info(f"Test Player Stats: KDA: {stats['kda']}, CS: {stats['cs']}, Level: {stats['level']}") 165 | else: 166 | logger.warning("Test Stats: Could not retrieve player stats.") 167 | 168 | -------------------------------------------------------------------------------- /src/lcu.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from lcu_driver.connection import Connection 4 | from lcu_driver.utils import _return_ux_process # For finding LCU process 5 | 6 | from .utilities import logger, addLog, procPath, LEAGUE_CLIENT_EXECUTABLE 7 | 8 | class LcuManager: 9 | """ 10 | Manages the connection to the League of Legends LCU. 11 | Handles finding the client, establishing connection, and reconnections. 12 | """ 13 | def __init__(self, connector_instance): 14 | self.connector = connector_instance 15 | self.current_connection = None 16 | self._shutting_down = False 17 | self._main_loop_task = None 18 | logger.info("LcuManager initialized.") 19 | 20 | async def _find_lcu_process(self): 21 | """Continuously searches for the LCU process until found or shutdown.""" 22 | lcu_process_obj = None 23 | logger.info("LcuManager: Searching for League Client UX process...") 24 | while not lcu_process_obj and not self._shutting_down: 25 | process_generator = _return_ux_process() 26 | try: 27 | lcu_process_obj = next(process_generator) 28 | except StopIteration: 29 | lcu_process_obj = None 30 | 31 | if not lcu_process_obj: 32 | if self._shutting_down: 33 | logger.info("LcuManager: Shutdown signaled while searching for LCU process.") 34 | break 35 | logger.debug("LcuManager: League Client UX process not found. Retrying in 3 seconds...") 36 | await asyncio.sleep(3) 37 | else: 38 | pid = getattr(lcu_process_obj, 'pid', 'N/A') 39 | logger.info(f"LcuManager: League Client UX process found (PID: {pid}).") 40 | addLog(f"LCU Manager: Client process found (PID: {pid}).", level="INFO") 41 | return lcu_process_obj 42 | 43 | async def manage_connection(self): 44 | """ 45 | Main loop for managing the LCU connection. 46 | Attempts to connect and reconnect if the client closes. 47 | """ 48 | logger.info("LcuManager: Starting connection management loop.") 49 | while not self._shutting_down: 50 | lcu_process = await self._find_lcu_process() 51 | 52 | if self._shutting_down: 53 | logger.info("LcuManager: Shutdown signaled, exiting connection management loop.") 54 | break 55 | if not lcu_process: 56 | logger.warning("LcuManager: Could not find LCU process after search loop (should not happen if not shutting down). Will retry.") 57 | await asyncio.sleep(5) # Wait before retrying the whole find process 58 | continue 59 | 60 | self.current_connection = None 61 | connection_object = None 62 | 63 | try: 64 | # Corrected instantiation: Connection(connector_instance, process_object) 65 | connection_object = Connection(self.connector, lcu_process) 66 | 67 | self.connector.register_connection(connection_object) 68 | self.current_connection = connection_object 69 | 70 | logger.info(f"LcuManager: Initializing connection to LCU (PID: {getattr(lcu_process, 'pid', 'N/A')})...") 71 | await connection_object.init() 72 | 73 | logger.info(f"LcuManager: Connection (PID: {getattr(lcu_process, 'pid', 'N/A')}) init() completed. Client likely closed or connection lost.") 74 | addLog("LCU Manager: Connection init() completed (client closed or error).", level="INFO") 75 | 76 | except asyncio.CancelledError: 77 | logger.info("LcuManager: Connection management task was cancelled.") 78 | addLog("LCU Manager: Connection management task cancelled.", level="INFO") 79 | break 80 | except Exception as e: 81 | pid_str = getattr(lcu_process, 'pid', 'N/A') 82 | logger.error(f"LcuManager: Error during LCU connection (PID: {pid_str}): {e}", exc_info=True) 83 | addLog(f"LCU Manager Error: Connection error (PID: {pid_str}): {str(e)}", level="ERROR") 84 | finally: 85 | if connection_object: 86 | self.connector.unregister_connection(getattr(lcu_process, 'pid', None)) 87 | self.current_connection = None 88 | 89 | if not self._shutting_down: 90 | logger.info("LcuManager: Connection lost/closed. Will attempt to find LCU process again after a delay.") 91 | await asyncio.sleep(5) 92 | 93 | logger.info("LcuManager: Connection management loop has exited.") 94 | 95 | 96 | async def start(self): 97 | """Starts the LCU connection management in a background task.""" 98 | if self._main_loop_task and not self._main_loop_task.done(): 99 | logger.warning("LcuManager: Start called but already running.") 100 | return 101 | self._shutting_down = False 102 | self._main_loop_task = asyncio.create_task(self.manage_connection()) 103 | logger.info("LcuManager: Started connection management task.") 104 | try: 105 | await self._main_loop_task 106 | except asyncio.CancelledError: 107 | logger.info("LcuManager: Main loop task was cancelled during start().") 108 | except Exception as e: 109 | logger.error(f"LcuManager: Unhandled exception in main loop task: {e}", exc_info=True) 110 | 111 | 112 | async def stop(self): 113 | """Stops the LCU connection management.""" 114 | logger.info("LcuManager: Stop requested.") 115 | self._shutting_down = True 116 | 117 | if self.current_connection and hasattr(self.current_connection, '_close'): 118 | logger.info("LcuManager: Attempting to close active LCU connection...") 119 | try: 120 | await self.current_connection._close() 121 | logger.info("LcuManager: Active LCU connection closed.") 122 | except Exception as e: 123 | logger.error(f"LcuManager: Error closing active LCU connection: {e}", exc_info=True) 124 | 125 | if self._main_loop_task and not self._main_loop_task.done(): 126 | logger.info("LcuManager: Cancelling main connection management task...") 127 | self._main_loop_task.cancel() 128 | try: 129 | await self._main_loop_task 130 | except asyncio.CancelledError: 131 | logger.info("LcuManager: Main connection management task successfully cancelled.") 132 | except Exception as e: 133 | logger.error(f"LcuManager: Exception while awaiting cancelled main loop task: {e}", exc_info=True) 134 | 135 | logger.info("LcuManager: Stopped.") 136 | 137 | -------------------------------------------------------------------------------- /src/modes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json # For JSONDecodeError 3 | from typing import Dict, Any, Tuple, Callable 4 | from time import time # Import time for actual_game_start_time 5 | 6 | from pypresence import exceptions as PyPresenceExceptions # Import for specific exception handling 7 | 8 | try: 9 | from .utilities import ( 10 | fetchConfig, ANIMATEDSPLASHESIDS, 11 | addLog, logger 12 | ) 13 | from .cdngen import ( 14 | rankedEmblem, assetsLink, defaultTileLink, 15 | tftImg, mapIcon, animatedSplashUrl 16 | ) 17 | from .gamestats import getStats, API_NOT_READY_MARKER, get_current_game_time 18 | except ImportError as e: 19 | print(f"Critical Error: Failed to import modules in modes.py: {e}") 20 | raise 21 | 22 | async def _fetch_lcu_data(connection: Any, endpoint: str, description: str) -> Dict[str, Any] | None: 23 | try: 24 | response = await connection.request('get', endpoint) 25 | if response and response.status == 200: 26 | try: 27 | return await response.json() 28 | except json.JSONDecodeError as e: 29 | logger.error(f"JSONDecodeError fetching {description} from {endpoint}: {e}. Response text: {await response.text()[:200]}") 30 | addLog(f"LCU API Error: Failed to parse JSON for {description} from {endpoint}.", level="ERROR") 31 | return None 32 | else: 33 | status = response.status if response else "No Response" 34 | logger.warning(f"Failed to fetch {description} from {endpoint}. Status: {status}") 35 | addLog(f"LCU API Warning: Failed to fetch {description}. Status: {status}", level="WARNING") 36 | return None 37 | except Exception as e: 38 | logger.error(f"Exception fetching {description} from {endpoint}: {e}", exc_info=True) 39 | addLog(f"LCU API Error: Exception fetching {description}: {str(e)}", level="ERROR") 40 | return None 41 | 42 | 43 | async def updateInProgressRPC( 44 | stop_condition_callable: Callable[[], bool], 45 | start_time: float, 46 | current_champ_selection: Tuple[int, int], 47 | map_data: Dict[str, Any], 48 | map_icon_asset_path: str | None, 49 | queue_data: Dict[str, Any], 50 | game_data: Dict[str, Any], 51 | internal_name: str, 52 | display_name: str, 53 | connection: Any, 54 | summoner_id: int, 55 | locale_strings: Dict[str, str], 56 | rpc_presence_object: Any, 57 | rpc_lock: asyncio.Lock 58 | ): 59 | logger.info(f"In-progress RPC update loop started for {display_name} at {start_time}.") 60 | addLog(f"In-progress RPC loop started for {display_name}.", level="DEBUG") 61 | 62 | champ_id, selected_skin_id = current_champ_selection 63 | 64 | if not champ_id and map_data.get("mapStringId") != "TFT": 65 | logger.error("updateInProgressRPC: No champion ID for non-TFT mode. Exiting loop.") 66 | addLog("Error: In-progress RPC: No champion ID for non-TFT mode.", level="ERROR") 67 | return 68 | 69 | while not stop_condition_callable(): 70 | if fetchConfig("isRpcMuted"): 71 | logger.info(f"In-progress RPC: Muted. Clearing presence for {display_name}.") 72 | async with rpc_lock: 73 | try: 74 | await asyncio.to_thread(rpc_presence_object.clear) 75 | except PyPresenceExceptions.InvalidPipe: 76 | logger.warning("In-progress RPC (Muted): InvalidPipe during clear. Main app should handle reconnect.") 77 | # The main DetailedLoLRPC._update_rpc_presence will handle setting rpc_connected to False 78 | except Exception as e_clear_mute: 79 | logger.error(f"In-progress RPC (Muted): Error during clear: {e_clear_mute}") 80 | await asyncio.sleep(1.0) 81 | continue 82 | 83 | rpc_payload = {} 84 | 85 | map_name_str = map_data.get('name', "Unknown Map") 86 | queue_desc_str = queue_data.get('description', "Unknown Mode") 87 | if queue_data.get('gameMode') == "PRACTICETOOL": 88 | queue_desc_str = locale_strings.get('practicetool', 'Practice Tool') 89 | elif queue_data.get("type") == "BOT": 90 | queue_desc_str = f"{locale_strings.get('bot', 'Bot')} {queue_desc_str}" 91 | elif queue_data.get("category") == "Custom" and queue_data.get('gameMode') != "PRACTICETOOL": 92 | queue_desc_str = locale_strings.get('custom', 'Custom Game') 93 | 94 | details_for_rpc = f"{map_name_str} ({queue_desc_str})" 95 | large_image_for_rpc = mapIcon(map_icon_asset_path) if map_icon_asset_path else "lol_icon" 96 | large_text_for_rpc = map_name_str 97 | 98 | live_game_stats_data = await asyncio.to_thread(getStats) 99 | current_game_time_from_api = await asyncio.to_thread(get_current_game_time) 100 | 101 | rpc_start_timestamp_to_use = int(start_time) 102 | 103 | if current_game_time_from_api == API_NOT_READY_MARKER or live_game_stats_data == API_NOT_READY_MARKER: 104 | logger.info(f"Game API not ready for {display_name}. Showing 'Loading Match' presence.") 105 | addLog(f"In-progress RPC: API not ready for {display_name}. Showing loading.", level="INFO") 106 | 107 | rpc_payload = { 108 | "details": details_for_rpc, 109 | "state": "Loading Match...", 110 | "large_image": large_image_for_rpc, 111 | "large_text": large_text_for_rpc, 112 | "start": int(start_time), 113 | } 114 | elif isinstance(current_game_time_from_api, float): 115 | rpc_start_timestamp_to_use = int(time() - current_game_time_from_api) 116 | logger.debug(f"Game time from API: {current_game_time_from_api:.2f}s. Calculated RPC start: {rpc_start_timestamp_to_use}") 117 | 118 | state_str = locale_strings.get("inGame", "In Game") 119 | game_stats_parts = [state_str] 120 | if live_game_stats_data and live_game_stats_data.get("kda") is not None: 121 | stats_display_config = fetchConfig("stats") 122 | if isinstance(stats_display_config, dict): 123 | if stats_display_config.get("kda") and live_game_stats_data.get("kda"): 124 | game_stats_parts.append(live_game_stats_data['kda']) 125 | if stats_display_config.get("cs") and live_game_stats_data.get("cs"): 126 | game_stats_parts.append(f"{live_game_stats_data['cs']} CS") 127 | if stats_display_config.get("level") and live_game_stats_data.get("level"): 128 | game_stats_parts.append(f"Lvl {live_game_stats_data['level']}") 129 | 130 | if map_data.get("mapStringId") == "TFT": 131 | large_image_key_tft = large_image_for_rpc 132 | large_text_tft = large_text_for_rpc 133 | buttons_list_tft = None 134 | if fetchConfig("useSkinSplash"): 135 | cosmetics_data = await _fetch_lcu_data(connection, '/lol-cosmetics/v1/inventories/tft/companions', "TFT companion cosmetics") 136 | if cosmetics_data: 137 | comp_data = cosmetics_data.get("selectedLoadoutItem") 138 | if comp_data and isinstance(comp_data, dict): 139 | large_image_key_tft = tftImg(comp_data.get("loadoutsIcon")) 140 | large_text_tft = comp_data.get('name', map_name_str) 141 | if fetchConfig("showViewArtButton") and comp_data.get("loadoutsIcon"): 142 | buttons_list_tft = [{"label": "View Companion Art", "url": tftImg(comp_data.get("loadoutsIcon"))}] 143 | rpc_payload = {"details": details_for_rpc, "large_image": large_image_key_tft, "large_text": large_text_tft, "state": " • ".join(game_stats_parts), "start": rpc_start_timestamp_to_use, "buttons": buttons_list_tft} 144 | elif map_data.get("id") == 33: 145 | if not champ_id: actual_champ_id_for_name = 0; champ_name_for_display = "Swarm Survivor" 146 | else: 147 | swarm_champ_map = {3147: 92, 3151: 222, 3152: 89, 3153: 147, 3156: 233, 3157: 157, 3159: 893, 3678: 420, 3947: 498} 148 | actual_champ_id_for_name = swarm_champ_map.get(champ_id, champ_id) 149 | champ_name_for_display = "Champion" 150 | if actual_champ_id_for_name: 151 | champ_details = await _fetch_lcu_data(connection, f'/lol-champions/v1/inventories/{summoner_id}/champions/{actual_champ_id_for_name}', "Swarm champion details") 152 | if champ_details: champ_name_for_display = champ_details.get("name", "Champion") 153 | rpc_payload = {"details": f"{map_name_str} (PvE)", "large_image": defaultTileLink(actual_champ_id_for_name or champ_id), "large_text": champ_name_for_display, "state": " • ".join(game_stats_parts), "start": rpc_start_timestamp_to_use} 154 | else: 155 | if not champ_id: rpc_payload = { "details": details_for_rpc, "state": "In Game", "large_image": large_image_for_rpc, "start": rpc_start_timestamp_to_use }; logger.error("Standard game mode but champ_id is 0.") 156 | else: 157 | skin_name_str = "Champion"; tile_image_key = defaultTileLink(champ_id); splash_art_url = None 158 | champ_skins_list = await _fetch_lcu_data(connection, f'/lol-champions/v1/inventories/{summoner_id}/champions/{champ_id}/skins', "champion skins") 159 | target_skin_id_to_use = selected_skin_id if fetchConfig("useSkinSplash") else (champ_id * 1000); found_skin_info = None 160 | if champ_skins_list and isinstance(champ_skins_list, list): 161 | for skin_info_iter in champ_skins_list: 162 | if not isinstance(skin_info_iter, dict): continue 163 | if skin_info_iter.get("id") == target_skin_id_to_use: found_skin_info = skin_info_iter; break 164 | for chroma_info in skin_info_iter.get("chromas", []): 165 | if isinstance(chroma_info, dict) and chroma_info.get("id") == target_skin_id_to_use: found_skin_info = skin_info_iter; break 166 | if found_skin_info: break 167 | for tier_info in skin_info_iter.get("questSkinInfo", {}).get("tiers", []): 168 | if isinstance(tier_info, dict) and tier_info.get("id") == target_skin_id_to_use: found_skin_info = {**skin_info_iter, **tier_info}; break 169 | if found_skin_info: break 170 | if not found_skin_info: 171 | for skin_info_iter in champ_skins_list: 172 | if isinstance(skin_info_iter, dict) and skin_info_iter.get("isBase"): found_skin_info = skin_info_iter; target_skin_id_to_use = skin_info_iter.get("id"); break 173 | if not found_skin_info and champ_skins_list: found_skin_info = champ_skins_list[0] if isinstance(champ_skins_list[0], dict) else None; 174 | if found_skin_info: target_skin_id_to_use = found_skin_info.get("id") 175 | 176 | if found_skin_info and isinstance(found_skin_info, dict): 177 | skin_name_str = found_skin_info.get("name", "Champion") 178 | if found_skin_info.get("isBase"): tile_image_key = defaultTileLink(champ_id) 179 | elif found_skin_info.get("tilePath"): tile_image_key = assetsLink(found_skin_info["tilePath"]) 180 | if found_skin_info.get("uncenteredSplashPath"): splash_art_url = assetsLink(found_skin_info["uncenteredSplashPath"]) 181 | animated_video_path = found_skin_info.get("collectionSplashVideoPath") 182 | current_skin_id_for_anim_check = target_skin_id_to_use if target_skin_id_to_use is not None else champ_id * 1000 183 | if animated_video_path and current_skin_id_for_anim_check in ANIMATEDSPLASHESIDS and fetchConfig("animatedSplash"): 184 | tile_image_key = animatedSplashUrl(current_skin_id_for_anim_check) 185 | if not splash_art_url and animated_video_path: splash_art_url = assetsLink(animated_video_path) 186 | buttons_list = [{"label": "View Splash Art", "url": splash_art_url}] if fetchConfig("showViewArtButton") and splash_art_url else None 187 | rpc_payload = {"details": details_for_rpc, "large_image": tile_image_key, "large_text": skin_name_str, "state": " • ".join(game_stats_parts), "start": rpc_start_timestamp_to_use, "buttons": buttons_list} 188 | else: 189 | logger.warning(f"Game loaded for {display_name}, but current game time from API is unavailable ({current_game_time_from_api}). Using initial start time for timer.") 190 | rpc_start_timestamp_to_use = int(start_time) 191 | state_str = locale_strings.get("inGame", "In Game") 192 | game_stats_parts = [state_str] 193 | if live_game_stats_data and live_game_stats_data != API_NOT_READY_MARKER and live_game_stats_data.get("kda") is not None: 194 | stats_display_config = fetchConfig("stats") 195 | if isinstance(stats_display_config, dict): 196 | if stats_display_config.get("kda") and live_game_stats_data.get("kda"): game_stats_parts.append(live_game_stats_data['kda']) 197 | if stats_display_config.get("cs") and live_game_stats_data.get("cs"): game_stats_parts.append(f"{live_game_stats_data['cs']} CS") 198 | if stats_display_config.get("level") and live_game_stats_data.get("level"): game_stats_parts.append(f"Lvl {live_game_stats_data['level']}") 199 | 200 | rpc_payload = { 201 | "details": details_for_rpc, 202 | "state": " • ".join(game_stats_parts), 203 | "large_image": large_image_for_rpc, 204 | "large_text": large_text_for_rpc, 205 | "start": rpc_start_timestamp_to_use, 206 | } 207 | 208 | final_rpc_payload = {k: v for k, v in rpc_payload.items() if v is not None} 209 | 210 | if final_rpc_payload: 211 | async with rpc_lock: 212 | try: 213 | # Removed the "if rpc_presence_object.pipe:" check here 214 | result = await asyncio.to_thread(rpc_presence_object.update, **final_rpc_payload) 215 | if result is not None: 216 | logger.debug(f"In-Game RPC updated (payload sent): {final_rpc_payload.get('details')}, {final_rpc_payload.get('state')}") 217 | else: 218 | logger.warning(f"In-Game RPC update: pypresence.update returned None. This might indicate a problem with the send operation even if pipe was thought to be active.") 219 | # DetailedLoLRPC._update_rpc_presence will handle setting rpc_connected to False if pipe is truly dead 220 | except PyPresenceExceptions.InvalidPipe: 221 | logger.error("In-Game RPC update: InvalidPipe exception during rpc.update call. Main app should handle reconnect.") 222 | # DetailedLoLRPC._update_rpc_presence will handle setting rpc_connected to False 223 | except Exception as e_update: 224 | logger.error(f"In-Game RPC update: Exception during rpc.update: {e_update}", exc_info=True) 225 | elif rpc_payload: 226 | logger.debug("In-Game RPC: Payload was empty after filtering Nones. Clearing RPC.") 227 | async with rpc_lock: 228 | try: 229 | await asyncio.to_thread(rpc_presence_object.clear) 230 | except PyPresenceExceptions.InvalidPipe: 231 | logger.warning("In-Game RPC (Clear): InvalidPipe during clear. Main app should handle reconnect.") 232 | except Exception as e_clear: 233 | logger.error(f"In-Game RPC (Clear): Error during clear: {e_clear}") 234 | else: 235 | logger.warning("In-Game RPC: Payload was not constructed. No update/clear attempted.") 236 | 237 | await asyncio.sleep(1.0) 238 | 239 | logger.info(f"In-progress RPC update loop stopped for {display_name}.") 240 | addLog(f"In-progress RPC loop stopped for {display_name}.", level="INFO") 241 | 242 | -------------------------------------------------------------------------------- /src/tray_icon.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import webbrowser 4 | import warnings 5 | import asyncio 6 | 7 | from PIL import Image, UnidentifiedImageError 8 | from pystray import Icon, Menu, MenuItem 9 | 10 | from .utilities import ( 11 | editConfig, fetchConfig, resourcePath, resetConfig, 12 | ISSUESURL, LOG_FILE_PATH, 13 | VERSION, addLog, logger 14 | ) 15 | 16 | _current_status_text = "Status: Initializing..." 17 | _tray_icon_instance = None 18 | _rpc_app_ref = None 19 | _settings_gui_is_open = False 20 | 21 | def setup_rpc_app_reference(app_instance): 22 | global _rpc_app_ref 23 | _rpc_app_ref = app_instance 24 | if logger: logger.info("rpc_app_reference set in tray_icon module.") 25 | 26 | def disable_tray_menu(): 27 | global _settings_gui_is_open, _tray_icon_instance 28 | if logger: logger.info("Tray: Attempting to disable menu (GUI open).") 29 | _settings_gui_is_open = True 30 | if _tray_icon_instance: 31 | try: 32 | _tray_icon_instance.menu = get_menu() 33 | _tray_icon_instance.update_menu() 34 | if logger: logger.info("Tray: Menu reassigned and update called by disable_tray_menu.") 35 | except Exception as e: 36 | if logger: logger.error(f"Tray: Error reassigning/updating menu for disable: {e}", exc_info=True) 37 | elif not _tray_icon_instance: 38 | if logger: logger.warning("Tray: disable_tray_menu called but _tray_icon_instance is None.") 39 | 40 | 41 | def enable_tray_menu(): 42 | global _settings_gui_is_open, _tray_icon_instance 43 | if logger: logger.info("Tray: Attempting to enable menu (GUI closed).") 44 | _settings_gui_is_open = False 45 | if _tray_icon_instance: 46 | try: 47 | _tray_icon_instance.menu = get_menu() 48 | _tray_icon_instance.update_menu() 49 | if logger: logger.info("Tray: Menu reassigned and update called by enable_tray_menu.") 50 | except Exception as e: 51 | if logger: logger.error(f"Tray: Error reassigning/updating menu for enable: {e}", exc_info=True) 52 | elif not _tray_icon_instance: 53 | if logger: logger.warning("Tray: enable_tray_menu called but _tray_icon_instance is None.") 54 | 55 | try: 56 | icon_image_path = resourcePath("icon.ico") 57 | if not os.path.exists(icon_image_path): 58 | if logger: logger.error(f"Tray icon resource 'icon.ico' not found at {icon_image_path}.") 59 | img = Image.new('RGBA', (1, 1), (0,0,0,0)) 60 | else: 61 | with warnings.catch_warnings(): 62 | warnings.filterwarnings( 63 | "ignore", 64 | message="Image was not the expected size", 65 | category=UserWarning, 66 | module='PIL.IcoImagePlugin' 67 | ) 68 | img = Image.open(icon_image_path) 69 | if logger: logger.debug(f"Loaded icon from {icon_image_path}") 70 | 71 | except UnidentifiedImageError: 72 | if logger: logger.error(f"Could not identify or open image file at {icon_image_path}. Using a dummy icon.") 73 | img = Image.new('RGBA', (1, 1), (0,0,0,0)) 74 | except FileNotFoundError: 75 | if logger: logger.error(f"Icon file 'icon.ico' not found via resourcePath. Path: {icon_image_path}. Using a dummy icon.") 76 | img = Image.new('RGBA', (1, 1), (0,0,0,0)) 77 | except Exception as e: 78 | if logger: logger.error(f"An unexpected error occurred while loading tray icon: {e}. Using a dummy icon.") 79 | img = Image.new('RGBA', (1, 1), (0,0,0,0)) 80 | 81 | 82 | def _toggle_config_boolean(config_key): 83 | current_state = fetchConfig(config_key) 84 | new_state = not current_state 85 | editConfig(config_key, new_state) 86 | 87 | def _toggle_nested_config_boolean(main_key, sub_key): 88 | config_dict = fetchConfig(main_key) 89 | if isinstance(config_dict, dict): 90 | current_sub_state = config_dict.get(sub_key, False) 91 | new_config_dict_val = config_dict.copy() 92 | new_config_dict_val[sub_key] = not current_sub_state 93 | editConfig(main_key, new_config_dict_val) 94 | else: 95 | if logger: logger.error(f"Config key '{main_key}' is not a dictionary. Cannot toggle '{sub_key}'. Current value: {config_dict}") 96 | 97 | 98 | def on_exit_clicked(icon_instance_param, item): 99 | if logger: logger.info("Exit requested from tray icon.") 100 | addLog("Tray icon: Exit action initiated.", level="INFO") 101 | 102 | if icon_instance_param: 103 | if logger: logger.debug("Tray: Stopping pystray icon instance.") 104 | icon_instance_param.stop() 105 | if logger: logger.debug("Tray: pystray icon instance stopped.") 106 | 107 | if _rpc_app_ref and hasattr(_rpc_app_ref, 'shutdown') and hasattr(_rpc_app_ref, '_main_loop_ref'): 108 | main_app_loop = getattr(_rpc_app_ref, '_main_loop_ref', None) 109 | if main_app_loop and main_app_loop.is_running(): 110 | if logger: logger.info("Tray: Scheduling graceful shutdown of main application on its event loop.") 111 | asyncio.run_coroutine_threadsafe(_rpc_app_ref.shutdown(exit_code=0), main_app_loop) 112 | if logger: logger.info("Tray: Main application shutdown scheduled.") 113 | else: 114 | logger.warning("Tray: Main app loop not available or not running for graceful shutdown. This might lead to an unclean exit.") 115 | else: 116 | logger.warning("Tray: _rpc_app_ref or its shutdown method/main_loop_ref not available for graceful shutdown.") 117 | 118 | 119 | def on_skin_splash_clicked(icon_instance, item): 120 | _toggle_config_boolean("useSkinSplash") 121 | 122 | def on_view_splash_art_clicked(icon_instance, item): 123 | _toggle_config_boolean("showViewArtButton") 124 | 125 | def on_animated_splash_clicked(icon_instance, item): 126 | _toggle_config_boolean("animatedSplash") 127 | 128 | def on_show_party_info_clicked(icon_instance, item): 129 | _toggle_config_boolean("showPartyInfo") 130 | 131 | def on_idle_status_selected(icon_instance, item_text): 132 | status_map = {"Disabled": 0, "Profile Info": 1, "Custom": 2} 133 | selected_value = status_map.get(str(item_text)) 134 | if selected_value is not None: 135 | editConfig("idleStatus", selected_value) 136 | else: 137 | if logger: logger.warning(f"Unknown idle status selection: {item_text}") 138 | 139 | def on_report_bug_clicked(icon_instance, item): 140 | if logger: logger.info("Report bug action initiated.") 141 | try: 142 | webbrowser.open(ISSUESURL) 143 | log_dir = os.path.dirname(LOG_FILE_PATH) 144 | if os.path.exists(log_dir): 145 | if sys.platform == "win32": os.startfile(log_dir) 146 | elif sys.platform == "darwin": os.system(f'open "{log_dir}"') 147 | else: os.system(f'xdg-open "{log_dir}"') 148 | else: 149 | if logger: logger.error(f"Log directory {log_dir} not found.") 150 | except Exception as e: 151 | if logger: logger.error(f"Error in on_report_bug_clicked: {e}") 152 | 153 | def on_reset_config_clicked(icon_instance, item): 154 | if logger: logger.info("Reset preferences action initiated from tray.") 155 | addLog("Tray icon: Reset preferences action initiated.", level="INFO") 156 | resetConfig() 157 | if logger: logger.info("Preferences have been reset via tray menu (refresh should be triggered by callback in utilities).") 158 | 159 | def on_mute_rpc_clicked(icon_instance, item): 160 | _toggle_config_boolean("isRpcMuted") 161 | 162 | def on_open_settings_clicked(icon_instance=None, item=None): 163 | global _settings_gui_is_open 164 | if logger: logger.info("Tray Icon: Open Settings action triggered.") 165 | 166 | if _settings_gui_is_open: 167 | if logger: logger.info("Tray Icon: Settings GUI is already considered open by the flag. No new action taken to open.") 168 | if _rpc_app_ref and hasattr(_rpc_app_ref, 'focus_settings_gui') and callable(_rpc_app_ref.focus_settings_gui): 169 | asyncio.run_coroutine_threadsafe(_rpc_app_ref.focus_settings_gui(), _rpc_app_ref._main_loop_ref) 170 | return 171 | 172 | if not _rpc_app_ref: 173 | if logger: logger.error("Tray Icon: Cannot open settings - _rpc_app_ref is not set.") 174 | return 175 | 176 | if not hasattr(_rpc_app_ref, 'open_settings_gui') or not callable(_rpc_app_ref.open_settings_gui): 177 | if logger: logger.error("Tray Icon: Cannot open settings - open_settings_gui method is missing or not callable on _rpc_app_ref.") 178 | return 179 | 180 | if not hasattr(_rpc_app_ref, '_main_loop_ref') or _rpc_app_ref._main_loop_ref is None: 181 | if logger: logger.error("Tray Icon: Cannot open settings - _main_loop_ref is missing, not set, or None on _rpc_app_ref.") 182 | return 183 | 184 | main_app_loop = _rpc_app_ref._main_loop_ref 185 | if not main_app_loop.is_running(): 186 | if logger: logger.error("Tray Icon: Cannot open settings - Main application event loop is not running.") 187 | return 188 | 189 | if logger: logger.debug("Tray Icon: All checks passed. Scheduling settings GUI launch on main event loop.") 190 | 191 | try: 192 | future = asyncio.run_coroutine_threadsafe(_rpc_app_ref.open_settings_gui(), main_app_loop) 193 | if logger: logger.info("Tray Icon: Settings GUI launch successfully scheduled.") 194 | except Exception as e: 195 | if logger: logger.error(f"Tray Icon: Error scheduling open_settings_gui: {e}", exc_info=True) 196 | 197 | 198 | def on_kda_clicked(icon_instance, item): _toggle_nested_config_boolean("stats", "kda") 199 | def on_cs_clicked(icon_instance, item): _toggle_nested_config_boolean("stats", "cs") 200 | def on_level_clicked(icon_instance, item): _toggle_nested_config_boolean("stats", "level") 201 | 202 | def on_rank_solo_clicked(icon_instance, item): _toggle_nested_config_boolean("showRanks", "RANKED_SOLO_5x5") 203 | def on_rank_flex_clicked(icon_instance, item): _toggle_nested_config_boolean("showRanks", "RANKED_FLEX_SR") 204 | def on_rank_tft_clicked(icon_instance, item): _toggle_nested_config_boolean("showRanks", "RANKED_TFT") 205 | def on_rank_double_up_clicked(icon_instance, item): _toggle_nested_config_boolean("showRanks", "RANKED_TFT_DOUBLE_UP") 206 | 207 | def on_rank_stats_lp_clicked(icon_instance, item): _toggle_nested_config_boolean("rankedStats", "lp") 208 | def on_rank_stats_w_clicked(icon_instance, item): _toggle_nested_config_boolean("rankedStats", "w") 209 | def on_rank_stats_l_clicked(icon_instance, item): _toggle_nested_config_boolean("rankedStats", "l") 210 | 211 | 212 | def updateStatus(status_message: str): 213 | global _current_status_text 214 | _current_status_text = status_message 215 | if _tray_icon_instance and hasattr(_tray_icon_instance, 'update_menu'): 216 | try: 217 | _tray_icon_instance.update_menu() 218 | except Exception as e: 219 | if logger: logger.warning(f"Could not update tray menu for status: {e}") 220 | if logger: logger.debug(f"Tray status text variable updated: {_current_status_text}") 221 | 222 | 223 | def get_menu(): 224 | global _settings_gui_is_open 225 | if logger: logger.info(f"Tray: get_menu called. _settings_gui_is_open = {_settings_gui_is_open}") 226 | 227 | if _settings_gui_is_open: 228 | return Menu( 229 | MenuItem(f"DetailedLoLRPC {VERSION} - by Ria", None, enabled=False), 230 | MenuItem(lambda item_text: _current_status_text, None, enabled=False), 231 | Menu.SEPARATOR, 232 | MenuItem("Settings Window is Open", None, enabled=False), 233 | Menu.SEPARATOR, 234 | MenuItem("Exit", on_exit_clicked) 235 | ) 236 | else: 237 | interactive_enabled = True 238 | if logger: logger.debug(f"Tray: get_menu - GUI not open, interactive_enabled = {interactive_enabled}") 239 | return Menu( 240 | MenuItem(f"DetailedLoLRPC {VERSION} - by Ria", None, enabled=False), 241 | MenuItem(lambda item_text: _current_status_text, None, enabled=False), 242 | Menu.SEPARATOR, 243 | MenuItem("Use Skin's splash and name", on_skin_splash_clicked, 244 | checked=lambda item: fetchConfig("useSkinSplash"), enabled=interactive_enabled), 245 | MenuItem("Use animated splash if available", on_animated_splash_clicked, 246 | checked=lambda item: fetchConfig("animatedSplash"), enabled=interactive_enabled), 247 | MenuItem('Show "View splash art" button', on_view_splash_art_clicked, 248 | checked=lambda item: fetchConfig("showViewArtButton"), enabled=interactive_enabled), 249 | MenuItem('Show party info', on_show_party_info_clicked, 250 | checked=lambda item: fetchConfig("showPartyInfo"), enabled=interactive_enabled), 251 | Menu.SEPARATOR, 252 | MenuItem("Ingame stats", Menu( 253 | MenuItem("KDA", on_kda_clicked, checked=lambda item: fetchConfig("stats").get("kda", False)), 254 | MenuItem("CS", on_cs_clicked, checked=lambda item: fetchConfig("stats").get("cs", False)), 255 | MenuItem("Level", on_level_clicked, checked=lambda item: fetchConfig("stats").get("level", False)) 256 | ), enabled=interactive_enabled), 257 | MenuItem("Show ranks", Menu( 258 | MenuItem("Solo", on_rank_solo_clicked, checked=lambda item: fetchConfig("showRanks").get("RANKED_SOLO_5x5", False)), 259 | MenuItem("Flex", on_rank_flex_clicked, checked=lambda item: fetchConfig("showRanks").get("RANKED_FLEX_SR", False)), 260 | MenuItem("TFT", on_rank_tft_clicked, checked=lambda item: fetchConfig("showRanks").get("RANKED_TFT", False)), 261 | MenuItem("TFT Double up", on_rank_double_up_clicked, checked=lambda item: fetchConfig("showRanks").get("RANKED_TFT_DOUBLE_UP", False)) 262 | ), enabled=interactive_enabled), 263 | MenuItem("Ranked stats", Menu( 264 | MenuItem("LP", on_rank_stats_lp_clicked, checked=lambda item: fetchConfig("rankedStats").get("lp", False)), 265 | MenuItem("Wins", on_rank_stats_w_clicked, checked=lambda item: fetchConfig("rankedStats").get("w", False)), 266 | MenuItem("Losses", on_rank_stats_l_clicked, checked=lambda item: fetchConfig("rankedStats").get("l", False)) 267 | ), enabled=interactive_enabled), 268 | MenuItem("Idle status", Menu( 269 | MenuItem("Disabled", lambda: on_idle_status_selected(None, "Disabled"), 270 | radio=True, checked=lambda item: fetchConfig("idleStatus") == 0), 271 | MenuItem("Profile Info", lambda: on_idle_status_selected(None, "Profile Info"), 272 | radio=True, checked=lambda item: fetchConfig("idleStatus") == 1), 273 | MenuItem("Custom", lambda: on_idle_status_selected(None, "Custom"), 274 | radio=True, checked=lambda item: fetchConfig("idleStatus") == 2) 275 | ), enabled=interactive_enabled), 276 | Menu.SEPARATOR, 277 | MenuItem("Mute RPC", on_mute_rpc_clicked, 278 | checked=lambda item: fetchConfig("isRpcMuted"), enabled=interactive_enabled), 279 | Menu.SEPARATOR, 280 | MenuItem("Reset preferences", on_reset_config_clicked, enabled=interactive_enabled), 281 | MenuItem("Report bug / Open logs", on_report_bug_clicked, enabled=interactive_enabled), 282 | MenuItem("Open Settings", on_open_settings_clicked, default=True), 283 | MenuItem("Exit", on_exit_clicked) 284 | ) 285 | 286 | try: 287 | icon = Icon("DetailedLoLRPC", img, "DetailedLoLRPC", menu=get_menu(), left_click=on_open_settings_clicked) 288 | _tray_icon_instance = icon 289 | except Exception as e: 290 | if logger: logger.critical(f"Failed to create pystray.Icon instance: {e}. Tray icon will not be available.") 291 | class DummyIcon: 292 | def run_detached(self): 293 | if logger: logger.error("DummyIcon: run_detached called, but tray icon creation failed.") 294 | def stop(self): pass 295 | HAS_MENU = False 296 | def update_menu(self): pass 297 | 298 | icon = DummyIcon() 299 | _tray_icon_instance = icon 300 | -------------------------------------------------------------------------------- /src/updater.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import requests 4 | import shutil 5 | import subprocess 6 | import logging 7 | import tempfile 8 | import json 9 | import asyncio 10 | 11 | from .utilities import logger, VERSION, REPOURL, GITHUBURL, yesNoBox, addLog, resourcePath 12 | 13 | EXPECTED_ASSET_NAME = "DetailedLoLRPC.exe" 14 | CURL_EXE_NAME = "curl.exe" 15 | 16 | def is_running_as_compiled(): 17 | """Check if the application is running as a PyInstaller bundle.""" 18 | return getattr(sys, 'frozen', False) 19 | 20 | def get_latest_release_info(): 21 | """Fetches the latest release information from GitHub.""" 22 | repo_path = REPOURL.split('github.com/')[-1].strip('/') 23 | if not repo_path: 24 | logger.error("Updater: REPOURL is not in the expected format.") 25 | return None 26 | 27 | api_url = f"https://api.github.com/repos/{repo_path}/releases/latest" 28 | logger.info(f"Updater: Fetching latest release info from {api_url}") 29 | try: 30 | response = requests.get(api_url, timeout=15) 31 | response.raise_for_status() 32 | return response.json() 33 | except requests.exceptions.RequestException as e: 34 | logger.error(f"Updater: Error fetching release info: {e}") 35 | return None 36 | except json.JSONDecodeError as e: 37 | logger.error(f"Updater: Error parsing release info JSON: {e}") 38 | return None 39 | 40 | def perform_update(show_messagebox_callback=None, rpc_app_ref=None): 41 | """ 42 | Main function to check for updates, then hands off to a batch script for download and replacement. 43 | Returns True if an update process was started that requires app exit, False otherwise. 44 | """ 45 | if not is_running_as_compiled(): 46 | logger.error("Updater: Update feature is only available for the compiled application.") 47 | if show_messagebox_callback: 48 | show_messagebox_callback("Update Error", "Update feature is only available for the compiled application.") 49 | return False 50 | 51 | logger.info("Updater: Checking for updates...") 52 | 53 | release_info = get_latest_release_info() 54 | if not release_info: 55 | logger.error("Updater: Could not fetch latest release information.") 56 | if show_messagebox_callback: 57 | show_messagebox_callback("Update Error", "Could not fetch latest release information from GitHub.") 58 | return False 59 | 60 | latest_version_tag_name = release_info.get("tag_name") 61 | if not latest_version_tag_name: 62 | logger.error("Updater: 'tag_name' not found in release information.") 63 | if show_messagebox_callback: 64 | show_messagebox_callback("Update Error", "Could not determine latest version from GitHub.") 65 | return False 66 | 67 | latest_version_str = latest_version_tag_name.lstrip('v') 68 | current_version_str = VERSION.lstrip('v') 69 | 70 | logger.info(f"Updater: Current version: {current_version_str}, Latest GitHub version tag: {latest_version_tag_name} (parsed as {latest_version_str})") 71 | 72 | try: 73 | current_v_tuple = tuple(map(int, current_version_str.split('.'))) 74 | latest_v_tuple = tuple(map(int, latest_version_str.split('.'))) 75 | if latest_v_tuple <= current_v_tuple: 76 | logger.info("Updater: Application is up to date.") 77 | if show_messagebox_callback: 78 | show_messagebox_callback("Up to Date", f"You are running the latest version ({VERSION}).") 79 | return False 80 | except ValueError: 81 | logger.warning(f"Updater: Could not parse versions for numeric comparison ('{current_version_str}' vs '{latest_version_str}'). Falling back to string comparison.") 82 | if latest_version_str <= current_version_str: 83 | logger.info("Updater: Application is up to date (string comparison).") 84 | if show_messagebox_callback: 85 | show_messagebox_callback("Up to Date", f"You are running the latest version ({VERSION}).") 86 | return False 87 | except Exception as e_ver: 88 | logger.error(f"Updater: Error comparing versions ('{current_version_str}' vs '{latest_version_str}'): {e_ver}") 89 | if show_messagebox_callback: 90 | show_messagebox_callback("Update Error", "Could not compare versions. Please check manually.") 91 | return False 92 | 93 | if show_messagebox_callback: 94 | prompt_user_callback = show_messagebox_callback 95 | else: 96 | def fallback_prompt(title, msg, msg_type="info"): 97 | if msg_type == "askyesno": 98 | return yesNoBox(msg, title) 99 | else: 100 | log_level = logging.ERROR if msg_type == "error" else logging.INFO 101 | logger.log(log_level, f"Updater Fallback Prompt ({title}): {msg}") 102 | return True 103 | prompt_user_callback = fallback_prompt 104 | 105 | if not prompt_user_callback( 106 | "Update Available", 107 | f"A new version ({latest_version_tag_name}) is available. Download and update now?\n\n" 108 | "The application will close to perform the update.", 109 | msg_type="askyesno" 110 | ): 111 | logger.info("Updater: User declined update.") 112 | return False 113 | 114 | asset_url = f"{REPOURL.rstrip('/')}/releases/download/{latest_version_tag_name}/{EXPECTED_ASSET_NAME}" 115 | logger.info(f"Updater: Constructed asset URL for batch script: {asset_url}") 116 | 117 | temp_download_dir = tempfile.mkdtemp(prefix="dlrpc_update_") 118 | downloaded_asset_temp_path = os.path.join(temp_download_dir, EXPECTED_ASSET_NAME) 119 | 120 | current_exe_path = sys.executable 121 | 122 | bundled_curl_path = "" 123 | try: 124 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 125 | base_dir_for_curl = sys._MEIPASS 126 | else: 127 | base_dir_for_curl = os.path.dirname(os.path.abspath(__file__)) 128 | 129 | potential_curl_path = os.path.join(base_dir_for_curl, "bin", CURL_EXE_NAME) 130 | if os.path.exists(potential_curl_path): 131 | bundled_curl_path = potential_curl_path 132 | logger.info(f"Updater: Found bundled curl at: {bundled_curl_path}") 133 | else: 134 | logger.warning(f"Updater: Bundled curl not found at {potential_curl_path}. Will rely on curl in PATH.") 135 | bundled_curl_path = "curl" 136 | except Exception as e_curl_path: 137 | logger.error(f"Updater: Error determining curl path: {e_curl_path}. Falling back to 'curl' in PATH.") 138 | bundled_curl_path = "curl" 139 | 140 | if sys.platform == "win32": 141 | # PowerShell command to move a file to the Recycle Bin 142 | # Using Microsoft.VisualBasic.FileIO.FileSystem for better reliability 143 | ps_recycle_command = ( 144 | f"Add-Type -AssemblyName Microsoft.VisualBasic; " 145 | f"[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile(" 146 | f"'{current_exe_path.replace("'", "''")}', " 147 | f"[Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, " 148 | f"[Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin, " 149 | f"[Microsoft.VisualBasic.FileIO.UICancelOption]::DoNothing)" 150 | ) 151 | 152 | updater_script_content = f"""@echo off 153 | setlocal enabledelayedexpansion 154 | 155 | echo DetailedLoLRPC Updater 156 | echo ======================= 157 | echo. 158 | echo Downloading update: {latest_version_tag_name} 159 | echo From: {asset_url} 160 | echo To: "{downloaded_asset_temp_path}" 161 | echo. 162 | 163 | set CURL_COMMAND=curl 164 | 165 | %CURL_COMMAND% -L -o "{downloaded_asset_temp_path}" "{asset_url}" --progress-bar -f -S 166 | if errorlevel 1 ( 167 | echo. 168 | echo ERROR: Download failed. 169 | echo Please check your internet connection or try again later. 170 | echo You can also download manually from: {GITHUBURL} 171 | goto :cleanup_and_exit_error 172 | ) 173 | 174 | echo. 175 | echo Download complete. 176 | echo. 177 | echo Waiting for DetailedLoLRPC (PID: {os.getpid()}) to close... 178 | :waitloop 179 | tasklist /FI "PID eq {os.getpid()}" 2>NUL | find /I /N "{os.getpid()}">NUL 180 | if "%ERRORLEVEL%"=="0" ( 181 | timeout /t 1 /nobreak > NUL 182 | goto waitloop 183 | ) 184 | 185 | echo. 186 | echo DetailedLoLRPC closed. Performing update... 187 | echo Moving current application to Recycle Bin: "{current_exe_path}" 188 | powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{ps_recycle_command}" 189 | 190 | rem Check if the file still exists after attempting to recycle 191 | if exist "{current_exe_path}" ( 192 | echo ERROR: Failed to move current application to Recycle Bin. 193 | echo It might still be in use, or a permissions issue occurred. 194 | echo Please check the Recycle Bin. Update aborted. 195 | echo You may need to manually remove or rename "{current_exe_path}" 196 | goto :cleanup_and_exit_error 197 | ) 198 | echo Successfully moved current application to Recycle Bin. 199 | echo. 200 | 201 | echo Moving new version into place: "{downloaded_asset_temp_path}" to "{current_exe_path}" 202 | move /Y "{downloaded_asset_temp_path}" "{current_exe_path}" 203 | if errorlevel 1 ( 204 | echo ERROR: Failed to move new version into place. 205 | echo The original application should be in the Recycle Bin. 206 | echo Please restore it manually from the Recycle Bin and try updating again later. 207 | goto :cleanup_and_exit_error 208 | ) 209 | 210 | echo. 211 | echo Update complete. Starting new version... 212 | start "" "{current_exe_path}" 213 | 214 | goto :cleanup_and_exit_success 215 | 216 | :cleanup_and_exit_error 217 | echo. 218 | echo Update process encountered an error. 219 | if exist "{temp_download_dir}" ( 220 | echo Cleaning up temporary download files... 221 | rd /s /q "{temp_download_dir}" >nul 2>&1 222 | ) 223 | echo. 224 | echo Press any key to close this window... 225 | pause >nul 226 | del "%~f0" >nul 2>&1 227 | exit /b 1 228 | 229 | :cleanup_and_exit_success 230 | echo. 231 | echo Cleaning up temporary download files... 232 | if exist "{temp_download_dir}" ( 233 | rd /s /q "{temp_download_dir}" >nul 2>&1 234 | ) 235 | echo. 236 | echo Update process finished. Press any key to close this window... 237 | pause >nul 238 | del "%~f0" >nul 2>&1 239 | exit /b 0 240 | 241 | """ 242 | script_path = os.path.join(tempfile.gettempdir(), "dlrpc_updater.bat") 243 | try: 244 | with open(script_path, "w", encoding='utf-8') as f: 245 | f.write(updater_script_content) 246 | logger.info(f"Updater: Created updater batch script at {script_path}") 247 | 248 | subprocess.Popen([script_path], creationflags=subprocess.CREATE_NEW_CONSOLE, close_fds=True) 249 | logger.info("Updater: Launched updater script in new console. Main application should now exit.") 250 | 251 | if rpc_app_ref and hasattr(rpc_app_ref, 'shutdown'): 252 | logger.info("Updater: Signaling main application to shut down.") 253 | if asyncio.iscoroutinefunction(rpc_app_ref.shutdown): 254 | if rpc_app_ref._main_loop_ref and rpc_app_ref._main_loop_ref.is_running(): 255 | asyncio.run_coroutine_threadsafe(rpc_app_ref.shutdown(exit_code=0), rpc_app_ref._main_loop_ref) 256 | else: 257 | logger.warning("Updater: Main asyncio loop not running for shutdown. Attempting direct run.") 258 | try: asyncio.run(rpc_app_ref.shutdown(exit_code=0)) 259 | except RuntimeError as e_run: logger.error(f"Updater: Error running shutdown directly: {e_run}") 260 | else: 261 | rpc_app_ref.shutdown(exit_code=0) 262 | return True 263 | 264 | except Exception as e_script: 265 | logger.error(f"Updater: Failed to create or launch updater script: {e_script}", exc_info=True) 266 | if show_messagebox_callback: 267 | show_messagebox_callback("Update Error", f"Failed to initiate update process: {e_script}") 268 | shutil.rmtree(temp_download_dir, ignore_errors=True) 269 | return False 270 | 271 | def download_asset(url, save_path, show_messagebox_callback=None): 272 | """Downloads an asset from a URL to a save path.""" 273 | logger.info(f"Updater: (Python download_asset) Downloading asset from {url} to {save_path}") 274 | try: 275 | with requests.get(url, stream=True, timeout=300) as r: 276 | r.raise_for_status() 277 | with open(save_path, 'wb') as f: 278 | for chunk in r.iter_content(chunk_size=8192): 279 | f.write(chunk) 280 | logger.info(f"Updater: (Python download_asset) Asset downloaded successfully to {save_path}") 281 | return True 282 | except requests.exceptions.RequestException as e: 283 | logger.error(f"Updater: (Python download_asset) Error downloading asset: {e}") 284 | if os.path.exists(save_path): 285 | os.remove(save_path) 286 | return False 287 | except Exception as e: 288 | logger.error(f"Updater: (Python download_asset) Unexpected error during download: {e}", exc_info=True) 289 | if os.path.exists(save_path): 290 | os.remove(save_path) 291 | return False 292 | -------------------------------------------------------------------------------- /src/utilities.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import pickle 5 | import tkinter as tk 6 | from tkinter import messagebox, ttk, simpledialog 7 | import logging 8 | import threading 9 | import psutil 10 | from psutil import process_iter, NoSuchProcess, AccessDenied, ZombieProcess 11 | from requests import get, exceptions as requests_exceptions 12 | from dotenv import load_dotenv 13 | from base64 import b64decode 14 | 15 | logging.basicConfig( 16 | level=logging.INFO, 17 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 18 | handlers=[logging.StreamHandler(sys.stdout)] 19 | ) 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def resourcePath(relative_path): 24 | try: 25 | base_path = sys._MEIPASS 26 | except AttributeError: 27 | base_path = os.path.abspath(".") 28 | return os.path.join(base_path, relative_path) 29 | 30 | env_path = resourcePath(".env") 31 | if os.path.exists(env_path): 32 | load_dotenv(env_path) 33 | logger.info(".env file loaded.") 34 | else: 35 | logger.warning(f".env file not found at {env_path}. Some features might not work.") 36 | 37 | VERSION = "v5.0.0" 38 | REPOURL = "https://github.com/developers192/DetailedLoLRPC/" 39 | GITHUBURL = REPOURL + "releases/latest" 40 | ISSUESURL = REPOURL + "issues/new" 41 | ANIMATEDSPLASHESURL = "https://raw.githubusercontent.com/developers192/DetailedLoLRPC/refs/heads/master/animatedSplashes/" 42 | ANIMATEDSPLASHESIDS = [99007, 360030, 147001, 147002, 147003, 103086, 21016, 77003, 37006, 81005] 43 | 44 | APPDATA_DIRNAME = "DetailedLoLRPC" 45 | APPDATA_PATH = os.path.join(os.getenv("APPDATA"), APPDATA_DIRNAME) 46 | CONFIG_FILENAME = "config.json" 47 | LOG_FILENAME = "session.log" 48 | LOCK_FILENAME = "detailedlolrpc.lock" 49 | 50 | CONFIG_FILE_PATH = os.path.join(APPDATA_PATH, CONFIG_FILENAME) 51 | LOG_FILE_PATH = os.path.join(APPDATA_PATH, LOG_FILENAME) 52 | LOCK_FILE_PATH = os.path.join(APPDATA_PATH, LOCK_FILENAME) 53 | OLD_CONFIG_PICKLE_PATH = os.path.join(APPDATA_PATH, "config.dlrpc") 54 | 55 | 56 | DEFAULT_CONFIG = { 57 | "useSkinSplash": True, 58 | "showViewArtButton": False, 59 | "animatedSplash": True, 60 | "showPartyInfo": True, 61 | "idleStatus": 0, 62 | "mapIconStyle": "Active", 63 | "stats": {"kda": True, "cs": True, "level": True}, 64 | "showRanks": { 65 | "RANKED_SOLO_5x5": True, 66 | "RANKED_FLEX_SR": True, 67 | "RANKED_TFT": True, 68 | "RANKED_TFT_DOUBLE_UP": True 69 | }, 70 | "rankedStats": {"lp": True, "w": True, "l": True}, 71 | "showWindowOnStartup": True, 72 | "checkForUpdatesOnStartup": True, 73 | "riotPath": "", 74 | "theme": "System", 75 | "idleCustomImageLink": "", 76 | "idleCustomShowStatusCircle": True, 77 | "idleCustomText": "Chilling...", 78 | "idleCustomShowTimeElapsed": False, 79 | "isRpcMuted": False, 80 | "idleProfileInfoDisplay": { # New section for profile info idle display 81 | "showRiotId": True, 82 | "showTagLine": True, 83 | "showSummonerLevel": False 84 | }, 85 | } 86 | 87 | try: 88 | CLIENTID_B64 = os.getenv("CLIENTID") 89 | if not CLIENTID_B64: 90 | logger.critical("CLIENTID is not set in .env file. Application cannot start.") 91 | try: 92 | root_err = tk.Tk() 93 | root_err.withdraw() 94 | messagebox.showerror("DetailedLoLRPC - Critical Error", "CLIENTID is missing in the .env file.\nPlease ensure it exists and is correctly base64 encoded.\nThe application will now exit.") 95 | root_err.destroy() 96 | except tk.TclError: 97 | print("CRITICAL ERROR: CLIENTID missing in .env and Tkinter is unavailable to show an error dialog.") 98 | sys.exit(1) 99 | CLIENTID = b64decode(CLIENTID_B64).decode("utf-8") 100 | if not CLIENTID: 101 | raise ValueError("Decoded CLIENTID is empty.") 102 | except Exception as e: 103 | logger.critical(f"Error decoding CLIENTID from .env: {e}") 104 | try: 105 | root_err = tk.Tk() 106 | root_err.withdraw() 107 | messagebox.showerror("DetailedLoLRPC - Critical Error", f"Could not decode CLIENTID: {e}\nPlease check your .env file.\nThe application will now exit.") 108 | root_err.destroy() 109 | except tk.TclError: 110 | print(f"CRITICAL ERROR: Could not decode CLIENTID ({e}) and Tkinter is unavailable.") 111 | sys.exit(1) 112 | 113 | _config_cache = None 114 | _on_config_changed_callbacks = [] 115 | 116 | def _ensure_appdata_dir(): 117 | os.makedirs(APPDATA_PATH, exist_ok=True) 118 | 119 | def is_process_running(pid, name_check=None): 120 | try: 121 | proc = psutil.Process(pid) 122 | if name_check: 123 | exe_name = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else __file__) 124 | if name_check.lower() in proc.name().lower() or \ 125 | (proc.exe() and name_check.lower() in os.path.basename(proc.exe()).lower()): 126 | return True 127 | return False 128 | return True 129 | except (NoSuchProcess, AccessDenied, ZombieProcess): 130 | return False 131 | 132 | def check_and_create_lock(): 133 | _ensure_appdata_dir() 134 | current_pid = os.getpid() 135 | app_name_for_check = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else "DetailedLoLRPC.py") 136 | 137 | if os.path.exists(LOCK_FILE_PATH): 138 | try: 139 | with open(LOCK_FILE_PATH, "r") as f: 140 | locked_pid_str = f.read().strip() 141 | if locked_pid_str: 142 | locked_pid = int(locked_pid_str) 143 | if locked_pid != current_pid and is_process_running(locked_pid, name_check=app_name_for_check): 144 | logger.warning(f"Another instance (PID: {locked_pid}) is already running. Lock file: {LOCK_FILE_PATH}") 145 | return False 146 | else: 147 | logger.info(f"Stale lock file found (PID: {locked_pid} not running or not this app). Overwriting.") 148 | else: 149 | logger.info("Lock file was empty. Overwriting.") 150 | except (IOError, ValueError) as e: 151 | logger.warning(f"Error reading lock file {LOCK_FILE_PATH}: {e}. Assuming stale and attempting to overwrite.") 152 | 153 | try: 154 | os.remove(LOCK_FILE_PATH) 155 | except OSError as e: 156 | logger.error(f"Could not remove stale/corrupt lock file {LOCK_FILE_PATH}: {e}. This might prevent startup.") 157 | return False 158 | 159 | try: 160 | with open(LOCK_FILE_PATH, "w") as f: 161 | f.write(str(current_pid)) 162 | logger.info(f"Lock file created successfully at {LOCK_FILE_PATH} with PID {current_pid}.") 163 | return True 164 | except IOError as e: 165 | logger.error(f"Could not create or write to lock file {LOCK_FILE_PATH}: {e}") 166 | try: 167 | with open(LOCK_FILE_PATH, "r") as f: 168 | locked_pid_str = f.read().strip() 169 | if locked_pid_str and int(locked_pid_str) != current_pid: 170 | logger.warning(f"Lock file appeared after check, likely race condition. Another instance (PID: {locked_pid_str}) may be running.") 171 | return False 172 | except: pass 173 | return False 174 | 175 | 176 | def release_lock(): 177 | try: 178 | if os.path.exists(LOCK_FILE_PATH): 179 | current_pid = os.getpid() 180 | pid_in_file = -1 181 | try: 182 | with open(LOCK_FILE_PATH, "r") as f: 183 | pid_in_file = int(f.read().strip()) 184 | except (IOError, ValueError): 185 | logger.warning(f"Could not read PID from lock file {LOCK_FILE_PATH} during release attempt.") 186 | 187 | if pid_in_file == current_pid: 188 | os.remove(LOCK_FILE_PATH) 189 | logger.info(f"Lock file {LOCK_FILE_PATH} released by PID {current_pid}.") 190 | elif pid_in_file != -1: 191 | logger.warning(f"Lock file {LOCK_FILE_PATH} owned by another PID ({pid_in_file}). Not releasing.") 192 | else: 193 | logger.warning(f"Lock file {LOCK_FILE_PATH} exists but PID unreadable. Not releasing automatically.") 194 | else: 195 | logger.info(f"Lock file {LOCK_FILE_PATH} not found during release attempt (already released or never created).") 196 | 197 | except OSError as e: 198 | logger.error(f"Error releasing lock file {LOCK_FILE_PATH}: {e}") 199 | except Exception as e: 200 | logger.error(f"Unexpected error during lock release: {e}", exc_info=True) 201 | 202 | 203 | def _migrate_pickle_to_json(): 204 | global _config_cache 205 | if os.path.exists(OLD_CONFIG_PICKLE_PATH) and not os.path.exists(CONFIG_FILE_PATH): 206 | logger.warning("Old .dlrpc config found. Attempting migration to .json...") 207 | try: 208 | with open(OLD_CONFIG_PICKLE_PATH, "rb") as pf: 209 | old_data = pickle.load(pf) 210 | 211 | migrated_config = DEFAULT_CONFIG.copy() 212 | migrated_config.update(old_data) 213 | if "closeSettingsOnLeagueOpen" in migrated_config: 214 | del migrated_config["closeSettingsOnLeagueOpen"] 215 | 216 | 217 | with open(CONFIG_FILE_PATH, "w", encoding='utf-8') as jf: 218 | json.dump(migrated_config, jf, indent=4) 219 | 220 | os.rename(OLD_CONFIG_PICKLE_PATH, f"{OLD_CONFIG_PICKLE_PATH}.migrated_to_json") 221 | logger.info("Configuration successfully migrated from pickle to JSON.") 222 | _config_cache = migrated_config 223 | return True 224 | except (pickle.UnpicklingError, IOError, OSError, AttributeError) as e: 225 | logger.error(f"Failed to migrate config from pickle to JSON: {e}. A new default config will be created.") 226 | try: 227 | os.rename(OLD_CONFIG_PICKLE_PATH, f"{OLD_CONFIG_PICKLE_PATH}.corrupted") 228 | except OSError: 229 | pass 230 | return False 231 | 232 | def _load_config(): 233 | global _config_cache 234 | if _config_cache is not None: 235 | return _config_cache 236 | 237 | _ensure_appdata_dir() 238 | 239 | if not _migrate_pickle_to_json() and not os.path.exists(CONFIG_FILE_PATH): 240 | logger.info("Config file not found. Creating default config.") 241 | _config_cache = DEFAULT_CONFIG.copy() 242 | if not _config_cache.get("riotPath"): 243 | _config_cache["riotPath"] = getRiotPath() 244 | _save_config_to_file() 245 | return _config_cache 246 | 247 | try: 248 | with open(CONFIG_FILE_PATH, "r", encoding='utf-8') as f: 249 | loaded_config = json.load(f) 250 | 251 | _config_cache = DEFAULT_CONFIG.copy() 252 | _config_cache.update(loaded_config) 253 | 254 | config_changed_by_cleanup = False 255 | if "closeSettingsOnLeagueOpen" in _config_cache: 256 | del _config_cache["closeSettingsOnLeagueOpen"] 257 | config_changed_by_cleanup = True 258 | 259 | # Ensure idleProfileInfoDisplay exists and has all keys 260 | if not isinstance(_config_cache.get("idleProfileInfoDisplay"), dict): 261 | _config_cache["idleProfileInfoDisplay"] = DEFAULT_CONFIG["idleProfileInfoDisplay"].copy() 262 | config_changed_by_cleanup = True 263 | else: 264 | for key, default_val in DEFAULT_CONFIG["idleProfileInfoDisplay"].items(): 265 | if key not in _config_cache["idleProfileInfoDisplay"]: 266 | _config_cache["idleProfileInfoDisplay"][key] = default_val 267 | config_changed_by_cleanup = True 268 | 269 | 270 | current_riot_path = _config_cache.get("riotPath", "") 271 | if not current_riot_path or not checkRiotClientPath(current_riot_path): 272 | logger.warning("Riot path in config is missing or invalid. Re-fetching.") 273 | _config_cache["riotPath"] = getRiotPath() 274 | config_changed_by_cleanup = True 275 | 276 | if config_changed_by_cleanup: 277 | _save_config_to_file() 278 | 279 | 280 | logger.info("Configuration loaded successfully.") 281 | return _config_cache 282 | except (json.JSONDecodeError, IOError) as e: 283 | logger.error(f"Error loading config file {CONFIG_FILE_PATH}: {e}. Backing up and using defaults.") 284 | try: 285 | corrupted_backup_path = f"{CONFIG_FILE_PATH}.corrupted_{int(os.path.getmtime(CONFIG_FILE_PATH) if os.path.exists(CONFIG_FILE_PATH) else 0)}" 286 | os.rename(CONFIG_FILE_PATH, corrupted_backup_path) 287 | logger.info(f"Corrupted config backed up to {corrupted_backup_path}") 288 | except OSError as bak_e: 289 | logger.error(f"Could not backup corrupted config file: {bak_e}") 290 | 291 | _config_cache = DEFAULT_CONFIG.copy() 292 | if not _config_cache.get("riotPath"): 293 | _config_cache["riotPath"] = getRiotPath() 294 | _save_config_to_file() 295 | return _config_cache 296 | 297 | def _save_config_to_file(): 298 | if _config_cache is None: 299 | logger.warning("Attempted to save config, but cache is None.") 300 | return False 301 | _ensure_appdata_dir() 302 | try: 303 | with open(CONFIG_FILE_PATH, "w", encoding='utf-8') as f: 304 | json.dump(_config_cache, f, indent=4, sort_keys=True) 305 | logger.info(f"Configuration saved to {CONFIG_FILE_PATH}") 306 | return True 307 | except IOError as e: 308 | logger.error(f"Failed to save configuration to {CONFIG_FILE_PATH}: {e}") 309 | return False 310 | 311 | def register_config_changed_callback(callback): 312 | global _on_config_changed_callbacks 313 | if callback not in _on_config_changed_callbacks: 314 | _on_config_changed_callbacks.append(callback) 315 | logger.info(f"Config change callback registered: {callback}") 316 | else: 317 | logger.debug(f"Config change callback already registered: {callback}") 318 | 319 | def unregister_config_changed_callback(callback): 320 | global _on_config_changed_callbacks 321 | try: 322 | _on_config_changed_callbacks.remove(callback) 323 | logger.info(f"Config change callback unregistered: {callback}") 324 | except ValueError: 325 | logger.warning(f"Attempted to unregister a callback that was not registered: {callback}") 326 | 327 | def _execute_config_changed_callbacks(): 328 | global _on_config_changed_callbacks 329 | if not _on_config_changed_callbacks: 330 | logger.debug("No config change callbacks to execute.") 331 | return 332 | 333 | logger.debug(f"Executing {_on_config_changed_callbacks} config change callbacks...") 334 | for callback in list(_on_config_changed_callbacks): 335 | try: 336 | callback() 337 | logger.debug(f"Executed config change callback: {callback}") 338 | except Exception as e: 339 | logger.error(f"Error executing config changed callback {callback}: {e}", exc_info=True) 340 | 341 | 342 | def fetchConfig(entry_key): 343 | config = _load_config() 344 | 345 | if '.' in entry_key: 346 | keys = entry_key.split('.') 347 | main_key = keys[0] 348 | sub_key = keys[1] 349 | 350 | default_main_val = DEFAULT_CONFIG.get(main_key, {}) 351 | config_main_val = config.get(main_key, {}) 352 | 353 | if isinstance(default_main_val, dict) and isinstance(config_main_val, dict): 354 | return config_main_val.get(sub_key, default_main_val.get(sub_key)) 355 | else: 356 | # This case might happen if config_main_val is not a dict but default is. 357 | # Or if default_main_val itself is not a dict (though for idleProfileInfoDisplay it is) 358 | if isinstance(default_main_val, dict): 359 | return default_main_val.get(sub_key) 360 | return None # Should not happen for well-defined defaults 361 | 362 | return config.get(entry_key, DEFAULT_CONFIG.get(entry_key)) 363 | 364 | 365 | def editConfig(entry_key, value): 366 | global _config_cache 367 | _load_config() 368 | 369 | changed = False 370 | if '.' in entry_key: 371 | keys = entry_key.split('.') 372 | main_key = keys[0] 373 | sub_key = keys[1] 374 | if main_key not in _config_cache or not isinstance(_config_cache[main_key], dict): 375 | _config_cache[main_key] = {} 376 | 377 | if _config_cache[main_key].get(sub_key) != value: 378 | _config_cache[main_key][sub_key] = value 379 | changed = True 380 | else: 381 | if _config_cache.get(entry_key) != value: 382 | _config_cache[entry_key] = value 383 | changed = True 384 | 385 | if changed: 386 | if _save_config_to_file(): 387 | _execute_config_changed_callbacks() 388 | else: 389 | logger.debug(f"Config for '{entry_key}' not changed. Skipping save and callback.") 390 | 391 | 392 | def resetConfig(): 393 | global _config_cache 394 | _ensure_appdata_dir() 395 | _config_cache = DEFAULT_CONFIG.copy() 396 | _config_cache["riotPath"] = getRiotPath() 397 | if _save_config_to_file(): 398 | _execute_config_changed_callbacks() 399 | logger.info("Configuration has been reset to defaults.") 400 | 401 | _log_file_handler = None 402 | 403 | def setup_file_logging(): 404 | global _log_file_handler 405 | _ensure_appdata_dir() 406 | if _log_file_handler: 407 | logging.getLogger().removeHandler(_log_file_handler) 408 | 409 | _log_file_handler = logging.FileHandler(LOG_FILE_PATH, mode='a', encoding='utf-8') 410 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 411 | _log_file_handler.setFormatter(formatter) 412 | logging.getLogger().addHandler(_log_file_handler) 413 | logger.info(f"File logging enabled at {LOG_FILE_PATH}") 414 | 415 | def resetLog(): 416 | _ensure_appdata_dir() 417 | try: 418 | global _log_file_handler 419 | if _log_file_handler: 420 | _log_file_handler.close() 421 | logging.getLogger().removeHandler(_log_file_handler) 422 | _log_file_handler = None 423 | 424 | with open(LOG_FILE_PATH, "w", encoding='utf-8') as f: 425 | f.write(f"--- Log Session Started: {logging.Formatter().formatTime(logging.makeLogRecord({}))} ---\n") 426 | logger.info(f"Log file reset: {LOG_FILE_PATH}") 427 | setup_file_logging() 428 | except IOError as e: 429 | print(f"ERROR: Could not reset log file {LOG_FILE_PATH}: {e}") 430 | 431 | def addLog(data, level="INFO"): 432 | log_message = json.dumps(data, indent=2) if isinstance(data, dict) else str(data) 433 | log_level_int = getattr(logging, level.upper(), logging.INFO) 434 | logger.log(log_level_int, log_message) 435 | 436 | def yesNoBox(msg, title="DetailedLoLRPC"): 437 | if threading.current_thread() is not threading.main_thread(): 438 | logger.warning("yesNoBox called from a non-main thread. This can lead to instability with Tkinter.") 439 | 440 | temp_root = None 441 | result = False 442 | try: 443 | temp_root = tk.Tk() 444 | temp_root.withdraw() 445 | temp_root.attributes('-topmost', True) 446 | try: 447 | icon_path = resourcePath("icon.ico") 448 | if os.path.exists(icon_path): 449 | temp_root.iconbitmap(icon_path) 450 | except tk.TclError: 451 | logger.warning("Could not set icon for temporary Tk root in yesNoBox.") 452 | pass 453 | 454 | temp_root.update_idletasks() 455 | temp_root.update() 456 | 457 | result = messagebox.askyesno(title, msg, parent=temp_root) 458 | except RuntimeError as e: 459 | if "main thread is not in main loop" in str(e) or "Calling Tcl from different appartment" in str(e): 460 | logger.error(f"Tkinter RuntimeError in yesNoBox (likely called from wrong thread): {e}") 461 | else: 462 | raise 463 | except Exception as e: 464 | logger.error(f"Unexpected error in yesNoBox: {e}", exc_info=True) 465 | finally: 466 | if temp_root and isinstance(temp_root, tk.Tk): 467 | try: 468 | if temp_root.winfo_exists(): 469 | temp_root.destroy() 470 | except tk.TclError: 471 | logger.warning("TclError during temp_root.destroy() in yesNoBox.") 472 | return result 473 | 474 | def inputBox(prompt_msg, title="DetailedLoLRPC"): 475 | if threading.current_thread() is not threading.main_thread(): 476 | logger.warning("inputBox called from a non-main thread. This can lead to instability with Tkinter.") 477 | 478 | temp_root = None 479 | result = None 480 | try: 481 | temp_root = tk.Tk() 482 | temp_root.withdraw() 483 | temp_root.attributes('-topmost', True) 484 | try: 485 | icon_path = resourcePath("icon.ico") 486 | if os.path.exists(icon_path): 487 | temp_root.iconbitmap(icon_path) 488 | except tk.TclError: 489 | logger.warning("Could not set icon for temporary Tk root in inputBox.") 490 | pass 491 | 492 | temp_root.update_idletasks() 493 | temp_root.update() 494 | 495 | result = simpledialog.askstring(title, prompt_msg, parent=temp_root) 496 | except RuntimeError as e: 497 | if "main thread is not in main loop" in str(e) or "Calling Tcl from different appartment" in str(e): 498 | logger.error(f"Tkinter RuntimeError in inputBox (likely called from wrong thread): {e}") 499 | else: 500 | raise 501 | except Exception as e: 502 | logger.error(f"Unexpected error in inputBox: {e}", exc_info=True) 503 | finally: 504 | if temp_root and isinstance(temp_root, tk.Tk): 505 | try: 506 | if temp_root.winfo_exists(): 507 | temp_root.destroy() 508 | except tk.TclError: 509 | logger.warning("TclError during temp_root.destroy() in inputBox.") 510 | return result 511 | 512 | LEAGUE_CLIENT_EXECUTABLE = "LeagueClient.exe" 513 | 514 | def procPath(process_name): 515 | try: 516 | for proc in process_iter(['name', 'exe']): 517 | if proc.info['name'] and proc.info['name'].lower() == process_name.lower(): 518 | if proc.info['exe']: 519 | return proc.info['exe'] 520 | except (NoSuchProcess, AccessDenied, ZombieProcess, TypeError) as e: 521 | logger.warning(f"Error iterating processes for '{process_name}': {e}") 522 | return None 523 | 524 | def checkRiotClientPath(path_to_check): 525 | if not path_to_check or not os.path.isdir(path_to_check): 526 | return False 527 | league_client_exe_path = os.path.join(path_to_check, "League of Legends", LEAGUE_CLIENT_EXECUTABLE) 528 | if os.path.exists(league_client_exe_path): 529 | return True 530 | if os.path.isdir(os.path.join(path_to_check, "League of Legends")) and \ 531 | os.path.isdir(os.path.join(path_to_check, "Riot Client")): 532 | return True 533 | logger.debug(f"Path {path_to_check} failed Riot Client path check. Missing {league_client_exe_path} or Riot Client folder.") 534 | return False 535 | 536 | def getRiotPath(): 537 | riot_client_services_exe = procPath("RiotClientServices.exe") 538 | if riot_client_services_exe: 539 | potential_riot_games_path = os.path.dirname(os.path.dirname(riot_client_services_exe)) 540 | if checkRiotClientPath(potential_riot_games_path): 541 | logger.info(f"Automatically detected Riot Games path: {potential_riot_games_path}") 542 | return potential_riot_games_path 543 | else: 544 | logger.warning(f"Found RiotClientServices.exe at {riot_client_services_exe}, but derived path {potential_riot_games_path} seems invalid.") 545 | 546 | logger.info("RiotClientServices.exe not found or path derived from it is invalid. Prompting user.") 547 | while True: 548 | user_path = inputBox( 549 | 'Riot Client process was not found or its path is unusual.\n' 550 | 'Please enter the path to your "Riot Games" installation folder.\n' 551 | r'(Example: C:\Riot Games)', 552 | title="DetailedLoLRPC - Riot Games Path" 553 | ) 554 | if user_path is None: 555 | logger.critical("User cancelled Riot Path input during setup. Application cannot continue.") 556 | try: 557 | root_err = tk.Tk() 558 | root_err.withdraw() 559 | messagebox.showerror("DetailedLoLRPC - Error", "Riot Games path is required.\nExiting application.") 560 | root_err.destroy() 561 | except: pass 562 | sys.exit(1) 563 | 564 | user_path = user_path.strip().strip('"') 565 | if checkRiotClientPath(user_path): 566 | logger.info(f"User provided valid Riot Games path: {user_path}") 567 | return user_path 568 | else: 569 | root_warn = tk.Tk(); root_warn.withdraw(); root_warn.attributes('-topmost', True) 570 | messagebox.showwarning( 571 | "DetailedLoLRPC - Invalid Path", 572 | f"The path '{user_path}' does not appear to be a valid Riot Games installation folder.\n\n" 573 | "It should typically contain a 'League of Legends' subfolder with 'LeagueClient.exe'.\n" 574 | "Please try again.", 575 | parent=root_warn 576 | ) 577 | root_warn.destroy() 578 | 579 | 580 | def isOutdated(): 581 | logger.info(f"Checking for updates. Current version: {VERSION}") 582 | try: 583 | api_url = f"https://api.github.com/repos/{REPOURL.split('github.com/')[1].strip('/')}/releases/latest" 584 | response = get(api_url, timeout=10) 585 | response.raise_for_status() 586 | release_data = response.json() 587 | latest_version_tag = release_data.get("tag_name") 588 | 589 | if not latest_version_tag: 590 | logger.warning("Could not determine latest version tag from GitHub API response. Trying redirect method.") 591 | response_redirect = get(GITHUBURL, timeout=10, allow_redirects=True) 592 | response_redirect.raise_for_status() 593 | latest_version_tag = response_redirect.url.split(r"/")[-1] 594 | 595 | if latest_version_tag and latest_version_tag.lstrip('v') != VERSION.lstrip('v'): 596 | logger.info(f"Update available: Latest version is {latest_version_tag}, current is {VERSION}.") 597 | return latest_version_tag 598 | elif latest_version_tag: 599 | logger.info(f"Application is up to date. (Current: {VERSION}, Latest: {latest_version_tag})") 600 | else: 601 | logger.warning("Could not determine latest version from GitHub.") 602 | return False 603 | except requests_exceptions.Timeout: 604 | logger.error("Timeout while checking for updates.") 605 | return False 606 | except requests_exceptions.RequestException as e: 607 | logger.error(f"Could not check for updates due to a network or request error: {e}") 608 | return False 609 | except json.JSONDecodeError as e: 610 | logger.error(f"Error parsing JSON response from GitHub API: {e}") 611 | return False 612 | 613 | _initialized = False 614 | def init(): 615 | global _initialized 616 | if _initialized: 617 | logger.debug("Utilities already initialized.") 618 | return 619 | 620 | _ensure_appdata_dir() 621 | setup_file_logging() 622 | resetLog() 623 | 624 | logger.info(f"--- DetailedLoLRPC Utilities Initializing (Version: {VERSION}) ---") 625 | _load_config() 626 | logger.info(f"Configuration loaded. Riot Path set to: {fetchConfig('riotPath')}") 627 | _initialized = True 628 | logger.info("--- DetailedLoLRPC Utilities Initialized Successfully ---") 629 | 630 | --------------------------------------------------------------------------------