├── .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 |

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 |
17 |
18 |
19 |
20 |
21 |
22 |
25 |
26 |
27 | About
28 | ·
29 | Getting Started
30 | ·
31 | Report Bug
32 |
33 |
34 |
35 | ## 📖 About The Project
36 |
37 |
38 |
39 |  
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 |
--------------------------------------------------------------------------------