From b82a5874c1aa6a4bdc99e0bc38eac7687d726812 Mon Sep 17 00:00:00 2001 From: WordlessMeteor <97432897+WordlessMeteor@users.noreply.github.com> Date: Sat, 11 Oct 2025 23:31:58 +0800 Subject: [PATCH 1/4] Fixed event loop issue Adapted BaseConnector's event loop to Python 3.14.0 asyncio library. --- lcu_driver/connector.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lcu_driver/connector.py b/lcu_driver/connector.py index 694b85d..f35ffc9 100644 --- a/lcu_driver/connector.py +++ b/lcu_driver/connector.py @@ -13,7 +13,14 @@ class BaseConnector(ConnectorEventManager, ABC): def __init__(self, loop=None): super().__init__() - self.loop = loop or asyncio.get_event_loop() + if loop is not None: + self.loop = loop + else: + try: + self.loop = asyncio.get_event_loop() + except RuntimeError: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) self.ws = WebsocketEventManager() @abstractmethod From eee11b2cb2d22b25513b7f80fac52d62c26bd8d2 Mon Sep 17 00:00:00 2001 From: WordlessMeteor <97432897+WordlessMeteor@users.noreply.github.com> Date: Sun, 12 Oct 2025 13:37:13 +0800 Subject: [PATCH 2/4] Multi-Client and No-Client Detection and Performance Optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Adapted `utils._return_ux_process` function, so that a list of processes, instead of only one process, are returned. Discussion: By a generator returned, the memory used is supposed to be significantly reduced, but in this case, since a user normally won't run multiple League Clients on one device, the memory cost difference between returning a generator and returning a list of processes shouldn't be too much. 2. Discuss about the number of running League Clients (namely the number of LeagueClientx.exe) through `connector.chooseClient` function. (1) If no client is found, raise a new error `NoLeagueClientDetected`; (2) if one client is found, build connection with that process; (3) otherwise, display the process list and let the user decide which client process to build connection with. While I was rewriting `utils._return_ux_process` function, two optimizations were made. 1. Optimized the running time of `_return_ux_process` funtion. (1) Avoiding a zombie process by always checking a process' `status` method significantly increases the time expense. Therefore, A try-except statement is used to handle exceptions triggered by accessing the attribute of a zombie process. (2) (Windows only) Accessing the cmdline attribute of a process significantly increases the time expense. Since LeagueClientUx.exe always has the correct name on Windows, there's no need to access a process' cmdline attribute on Windows. 2. Adjusted some type annotations. Note that these type annotations rely on Python ≥ 3.9. --- lcu_driver/connector.py | 54 ++++++++++++++++++++++++++++++++-------- lcu_driver/exceptions.py | 4 +++ lcu_driver/utils.py | 44 ++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/lcu_driver/connector.py b/lcu_driver/connector.py index f35ffc9..b26efa5 100644 --- a/lcu_driver/connector.py +++ b/lcu_driver/connector.py @@ -2,13 +2,52 @@ import logging import time from abc import ABC, abstractmethod +from psutil import Process # Type annotation only from .connection import Connection from .events.managers import ConnectorEventManager, WebsocketEventManager from .utils import _return_ux_process +from .exceptions import NoLeagueClientDetected # Handles one case of the number of League Clients logger = logging.getLogger('lcu-driver') +def chooseClient(processList: list[Process]): # Allows users to select one running League Client + if isinstance(processList, list) and all(map(lambda x: isinstance(x, Process), processList)): + if len(processList) == 0: + raise NoLeagueClientDetected() + elif len(processList) == 1: + process = processList[0] + else: + print('Multiple League Clients are detected. Please select one process to continue: (Submit "0" to exit.)') + # Optimize the prompt layout by formatting each column's width + indexWidth: int = max(map(lambda x: len(str(x + 1)), range(len(processList)))) + pidWidth: int = max(map(lambda x: len(str(x.pid)), processList)) + statusWidth: int = max(map(lambda x: len(str(x.status())), processList)) + createTimeWidth: int = max(map(lambda x: len(str(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(x.create_time())))), processList)) + filePathWidth: int = max(map(lambda x: len(str(x.exe())), processList)) + print("{0:^{5}}{1:^{6}}{2:^{7}}{3:^{8}}{4:^{9}}".format("No.", "pid", "status", "createTime", "filePath", indexWidth, pidWidth, statusWidth, createTimeWidth, filePathWidth)) + for i in range(len(processList)): + process = processList[i] + procId: int = process.pid + procStatus: str = process.status() # Based on the status, this table may only display the running processes in a future commit + procCreateTime: str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(process.create_time())) + procFilePath: str = process.exe() + print("{0:^{5}}{1:^{6}}{2:^{7}}{3:^{8}}{4:^{9}}".format(i + 1, procId, procStatus, procCreateTime, procFilePath, indexWidth, pidWidth, statusWidth, createTimeWidth, filePathWidth)) + while True: + processIndex = input() + if processIndex == "": + continue + elif processIndex == "0": + exit(0) + elif processIndex in set(map(str, range(1, len(processList) + 1))): + processIndex = int(processIndex) - 1 + break + else: + print("Please input an integer between 1 and %d." %(len(processList))) + process = processList[processIndex] + return process + else: + raise TypeError('invalid type of parameter "processList". Pass a list of Process objects instead') class BaseConnector(ConnectorEventManager, ABC): def __init__(self, loop=None): @@ -61,11 +100,8 @@ def start(self) -> None: """ try: def wrapper(): - process = next(_return_ux_process(), None) - while not process: - process = next(_return_ux_process(), None) - time.sleep(0.5) - + processList: list[Process] = _return_ux_process() + process = chooseClient(processList) connection = Connection(self, process) self.register_connection(connection) self.loop.run_until_complete(connection.init()) @@ -116,15 +152,11 @@ async def _astart(self): tasks = [] try: while True: - process_iter = _return_ux_process() - - process = next(process_iter, None) - while process: + processList: list[Process] = _return_ux_process() + for process in processList: connection = Connection(self, process) if not self._process_was_initialized(connection): tasks.append(asyncio.create_task(connection.init())) - - process = next(process_iter, None) await asyncio.sleep(0.5) except KeyboardInterrupt: diff --git a/lcu_driver/exceptions.py b/lcu_driver/exceptions.py index bb37fdb..50e62c0 100644 --- a/lcu_driver/exceptions.py +++ b/lcu_driver/exceptions.py @@ -19,3 +19,7 @@ class InvalidURI(BaseException): def __init__(self, error_type, used_uri=None): if error_type == 'backslash': super().__init__(f'every endpoint must start with a backslash, replace {used_uri} by /{used_uri}') + +class NoLeagueClientDetected(BaseException): + def __init__(self): + super().__init__("The program didn't detect a running League Client.") diff --git a/lcu_driver/utils.py b/lcu_driver/utils.py index c1cf626..4ac6934 100644 --- a/lcu_driver/utils.py +++ b/lcu_driver/utils.py @@ -1,10 +1,9 @@ -from typing import Dict, Generator, List +import platform +from psutil import ZombieProcess, AccessDenied, Process, process_iter -from psutil import STATUS_ZOMBIE, Process, process_iter - -def parse_cmdline_args(cmdline_args) -> Dict[str, str]: - cmdline_args_parsed = {} +def parse_cmdline_args(cmdline_args: list[str]) -> dict[str, str]: + cmdline_args_parsed: dict[str, str] = {} for cmdline_arg in cmdline_args: if len(cmdline_arg) > 0 and "=" in cmdline_arg: key, value = cmdline_arg[2:].split("=", 1) @@ -12,17 +11,26 @@ def parse_cmdline_args(cmdline_args) -> Dict[str, str]: return cmdline_args_parsed -def _return_ux_process() -> Generator[Process, None, None]: - for process in process_iter(attrs=["cmdline"]): - if process.status() == STATUS_ZOMBIE: +def _return_ux_process() -> list[Process]: + processList: list[Process] = [] + osPlatform: str = platform.system() # Distinguish the operating system preemptively + seen_pid: set[int] = set() # Ensure processList's uniqueness + for process in process_iter(): #attrs=["cmdline"] greatly increases time cost on Windows + try: + name: str = process.name() + if osPlatform in {"Linux", "Darwin"}: + cmdline: list[str] = process.cmdline() + except (ZombieProcess, AccessDenied): # Accessing the status method significantly increases time expense. This try-except statement should optimize this issue continue - - cmdline: List[str] = process.info.get("cmdline", []) - - if process.name() in ["LeagueClientUx.exe", "LeagueClientUx"]: - yield process - - # Check cmdline for the executable, especially useful in Linux environments - # where process names might differ due to compatibility layers like wine. - if cmdline and cmdline[0].endswith("LeagueClientUx.exe"): - yield process + else: + if name in {"LeagueClientUx.exe", "LeagueClientUx"}: + processList.append(process) + seen_pid.add(process.pid) + + if osPlatform in {"Linux", "Darwin"}: # In case the same process would be added multiple times on Windows + # Check cmdline for the executable, especially useful in Linux environments + # where process names might differ due to compatibility layers like wine. + if cmdline and cmdline[0].endswith("LeagueClientUx.exe") and not process.pid in seen_pid: + processList.append(process) + seen_pid.add(process.pid) + return processList From 79815dd3b92601ab2df3aac79b972b2c1c373920 Mon Sep 17 00:00:00 2001 From: WordlessMeteor <97432897+WordlessMeteor@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:42:46 +0800 Subject: [PATCH 3/4] Multi-Client Prompt Layout Optimization Optimized the prompt layout when multiple clients are detected. By "+2", each pair of neighboring cells should be delimited by at least 2 spaces. --- lcu_driver/connector.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lcu_driver/connector.py b/lcu_driver/connector.py index b26efa5..a7b77b0 100644 --- a/lcu_driver/connector.py +++ b/lcu_driver/connector.py @@ -20,11 +20,11 @@ def chooseClient(processList: list[Process]): # Allows users to select one runni else: print('Multiple League Clients are detected. Please select one process to continue: (Submit "0" to exit.)') # Optimize the prompt layout by formatting each column's width - indexWidth: int = max(map(lambda x: len(str(x + 1)), range(len(processList)))) - pidWidth: int = max(map(lambda x: len(str(x.pid)), processList)) - statusWidth: int = max(map(lambda x: len(str(x.status())), processList)) - createTimeWidth: int = max(map(lambda x: len(str(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(x.create_time())))), processList)) - filePathWidth: int = max(map(lambda x: len(str(x.exe())), processList)) + indexWidth: int = max(map(lambda x: len(str(x + 1)), range(len(processList)))) + 2 + pidWidth: int = max(map(lambda x: len(str(x.pid)), processList)) + 2 + statusWidth: int = max(map(lambda x: len(str(x.status())), processList)) + 2 + createTimeWidth: int = max(map(lambda x: len(str(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(x.create_time())))), processList)) + 2 + filePathWidth: int = max(map(lambda x: len(str(x.exe())), processList)) + 2 print("{0:^{5}}{1:^{6}}{2:^{7}}{3:^{8}}{4:^{9}}".format("No.", "pid", "status", "createTime", "filePath", indexWidth, pidWidth, statusWidth, createTimeWidth, filePathWidth)) for i in range(len(processList)): process = processList[i] From b5abe1d126cb2ae54ee91fcdb558aa1cd9300194 Mon Sep 17 00:00:00 2001 From: WordlessMeteor <1925009271@qq.com> Date: Thu, 26 Mar 2026 12:12:20 +0800 Subject: [PATCH 4/4] Enable Default Client Selection Now, when there's more than one League Client running and the library outputs multiple options to connect to, users may enter nothing, namely directly press Enter, to select the process with the latest creation time. To select another process, users still need to enter the process index shown in "No." column. Besides, adjusted some code based on the strict type checking mode: One variable should have only one type. --- lcu_driver/connector.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lcu_driver/connector.py b/lcu_driver/connector.py index a7b77b0..b313552 100644 --- a/lcu_driver/connector.py +++ b/lcu_driver/connector.py @@ -26,6 +26,8 @@ def chooseClient(processList: list[Process]): # Allows users to select one runni createTimeWidth: int = max(map(lambda x: len(str(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(x.create_time())))), processList)) + 2 filePathWidth: int = max(map(lambda x: len(str(x.exe())), processList)) + 2 print("{0:^{5}}{1:^{6}}{2:^{7}}{3:^{8}}{4:^{9}}".format("No.", "pid", "status", "createTime", "filePath", indexWidth, pidWidth, statusWidth, createTimeWidth, filePathWidth)) + latest_index: int = 0 # The index of the process with the latest creation time + max_procCreateTime: str = "" # An intermediate variable to store the latest creation time. It obeys one-to-one correspondence with the integer timestamp, so comparisons can be made on it for i in range(len(processList)): process = processList[i] procId: int = process.pid @@ -33,14 +35,18 @@ def chooseClient(processList: list[Process]): # Allows users to select one runni procCreateTime: str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(process.create_time())) procFilePath: str = process.exe() print("{0:^{5}}{1:^{6}}{2:^{7}}{3:^{8}}{4:^{9}}".format(i + 1, procId, procStatus, procCreateTime, procFilePath, indexWidth, pidWidth, statusWidth, createTimeWidth, filePathWidth)) + if max_procCreateTime < procCreateTime: + max_procCreateTime = procCreateTime + latest_index = i while True: - processIndex = input() - if processIndex == "": - continue - elif processIndex == "0": + processIndex_str: str = input() + if processIndex_str == "": # Enter nothing to select the default option + processIndex: int = latest_index + break + if processIndex_str == "0": exit(0) - elif processIndex in set(map(str, range(1, len(processList) + 1))): - processIndex = int(processIndex) - 1 + elif processIndex_str in set(map(str, range(1, len(processList) + 1))): + processIndex = int(processIndex_str) - 1 break else: print("Please input an integer between 1 and %d." %(len(processList)))