From 899576cbbcca0d7bfeebfb5a9401ef06f8fc188d Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:15:35 -0600 Subject: [PATCH] feat(launcher): hide the League client UI while a match runs Add an opt-in alternative to closing LeagueClientUx: hide the managed LeagueClientUx and Riot Client X11 windows while the game process runs and show them again when it exits. The client stays open; the game window is never touched. Detection is attributed to the launcher prefix through __mldr_sockpath and fails closed when proc, ownership or window state is unreadable. The helper requires X11/Xwayland, restores the window on end, signal or error, and is mutually exclusive with RCLOL_CLOSE_CLIENT_IN_GAME=1. Document the flag, compliance scope and limits. A local 30-second test on an i5-3470/RX 550 reduced summed client/CEF CPU from 120.7% to 93.4%; this is a CPU-bound-host measurement, not a universal FPS claim. No client binaries, CEF, assets or anti-cheat are modified. --- .github/workflows/source-check.yml | 1 + README.md | 8 + docs/SETUP.md | 8 + docs/TROUBLESHOOTING.md | 49 ++++ tests/test_hide_league_client_in_game.py | 165 +++++++++++++ tools/hide-league-client-in-game.py | 295 +++++++++++++++++++++++ tools/run-riot-client.sh | 11 + 7 files changed, 537 insertions(+) create mode 100644 tests/test_hide_league_client_in_game.py create mode 100755 tools/hide-league-client-in-game.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..afc8de7 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -31,3 +31,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' diff --git a/README.md b/README.md index b6144f7..97774de 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,14 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. +If you want to keep the client open during matches, opt in to hiding its window instead of closing it: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..a83e8b7 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,6 +78,14 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` +and `Riot Client` X11 windows while a match runs, then showing them when the +game process ends. It keeps the client open and is mutually exclusive with +`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, +requires X11/Xwayland, and does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +for the measured CPU effect and limitations. + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..2125d45 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,55 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` +### Keep the client open but hide it during a match + +`LeagueClientUx` can consume a noticeable amount of CPU while the client stays +open during a match. An opt-in alternative to closing it hides the managed +client window instead: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The launcher detects when a match starts, unmaps the `LeagueClientUx` and +`Riot Client` X11 windows, and maps them again when the game process ends. The +client is not closed, the League backend and Riot client keep running, and the +game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence +when both flags are set. + +The helper is off by default and window-management only: it does not patch +client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix +through the loader's `__mldr_sockpath`, and fails closed when ownership, proc +state, environment or window information cannot be read. It requires an X11 or +Xwayland desktop; on a native Wayland window manager without Xwayland it cannot +find the windows. + +On a CPU-bound host the effect appears after CEF has been hidden for a few +seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding +`LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from +120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the +window returned it to 119.9%. This is not a universal FPS or percentage claim: +a game that is GPU-bound, a client with no active UI work, or a different +desktop may show less benefit. The hidden window can also need a moment to +repaint after it is shown. + +`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client +chat during the match is not needed. Configure one of the two, not both: + +```sh +# Keep the client open, hide its windows while playing +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh + +# Or close the client UI while playing +RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +Diagnose the match detector without changing any window: + +```sh +python3 tools/hide-league-client-in-game.py --once +``` + ### Riot reports NO INTERNET FOUND while the host is online Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py new file mode 100644 index 0000000..22740fe --- /dev/null +++ b/tests/test_hide_league_client_in_game.py @@ -0,0 +1,165 @@ +"""Exercise the in-match client window hide with fake /proc entries and X11.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py') +hide_in_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hide_in_game) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx' +RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class FakeX11: + def __init__(self): + self.unmapped = [] + self.shown = [] + + def unmap(self, window): + self.unmapped.append(window) + + def show(self, window): + self.shown.append(window) + + +class HideClientInGameTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def environment(self, prefix): + return b'%s=%s\0' % ( + hide_in_game.SOCK_VARIABLE, + os.fsencode(prefix / hide_in_game.SOCK_NAME), + ) + + def process(self, pid, executable=GAME, prefix='own', environ=None, args=('--flag',)): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes( + executable.encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in args)) + if environ is None: + environ = self.environment(self.prefix if prefix == 'own' else prefix) + (folder / 'environ').write_bytes(environ) + (folder / 'stat').write_text(f'{pid} (game) S') + return folder + + def test_game_running_for_own_prefix(self): + self.process(123) + self.assertTrue(hide_in_game.game_running(self.prefix, self.proc)) + + def test_no_game_is_false(self): + self.process(123, executable=MAIN) + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_of_another_prefix_is_false(self): + self.process(123, prefix=self.root / 'other') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_without_socket_is_false(self): + self.process(123, environ=b'') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_unreadable_environment_is_unknown(self): + folder = self.process(123) + original = Path.read_bytes + + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + + with patch.object(Path, 'read_bytes', read): + self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + + def test_target_windows_ignores_game_class(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20', '0x30']), \ + patch.object(hide_in_game, 'window_class', + side_effect=[ + 'wm_class(string) = "leagueclientux"', + 'wm_class(string) = "leagueoflegends"', + 'wm_class(string) = "riot client"', + ]): + self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + + def test_hides_then_shows_on_match_end(self): + states = iter([True, True, False]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=3) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('hidden 0xabc', output.getvalue()) + self.assertIn('the match ended', output.getvalue()) + self.assertIn('shown 0xabc', output.getvalue()) + + def test_unreadable_state_restores_the_window(self): + states = iter([True, None]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xdef], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=2) + self.assertEqual(x11.unmapped, [0xdef]) + self.assertEqual(x11.shown, [0xdef]) + self.assertIn('unreadable /proc state', output.getvalue()) + + def test_no_game_does_not_touch_windows(self): + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: False, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher.run(max_steps=1) + self.assertEqual(x11.unmapped, []) + self.assertEqual(x11.shown, []) + + def test_main_is_disabled_by_default(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }): + self.assertEqual(hide_in_game.main([]), 0) + + def test_close_mode_takes_precedence(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '1', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '1', + }): + self.assertEqual(hide_in_game.main([]), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py new file mode 100755 index 0000000..2762c46 --- /dev/null +++ b/tools/hide-league-client-in-game.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Opt-in: hide the League client UI while a match is running. + +`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with +`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for +`LeagueClientUx` and `Riot Client`, and shows them again when the game process +exits. The Riot client, League backend, CEF helpers and game keep running. + +This is launcher/window management only. It does not patch client binaries, +CEF, anti-cheat or DRM, and it never hides the game window. It is off by +default, fails closed, and is intended for X11/Xwayland desktops. + +The game is attributed to this prefix through the loader's +`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc, +ownership or window information cannot be read, no window is touched. + +Diagnose one observation with: + + python3 tools/hide-league-client-in-game.py --once + +Enable it for a launcher run with: + + RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +""" + +import argparse +import ctypes +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +GAME_NAME = 'LeagueofLegends' +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' +POLL_SECONDS = 2.0 +TARGET_CLASSES = ('leagueclientux', 'riot client') + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def process_state(proc): + try: + return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return None + + +def command_names(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return [] + return [ + os.path.basename(part.decode(errors='replace')) + for part in raw.split(b'\0') + if part + ] + + +def game_running(prefix, proc_root=Path('/proc')): + """True/False for a game of this prefix; None when /proc is unreliable. + + The game executable is matched by its exact file name. The process must + carry the loader socket of this prefix, so a game from another install is + never considered. Any unreadable candidate makes the result None (fail + closed). + """ + owner = (prefix / SOCK_NAME).parent.resolve() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + for proc in entries: + if not proc.name.isdigit() or process_state(proc) in ('Z', None): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + if GAME_NAME not in command_names(proc): + continue + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + socket = environment.get(SOCK_VARIABLE) + if socket is None: + continue + if Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + return True + except FileNotFoundError: + if proc.exists(): + return None + continue + except (OSError, ValueError): + return None + return False + + +def xprop_root(): + try: + listing = subprocess.check_output( + ['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return [] + return re.findall(r'0x[0-9a-fA-F]+', listing) + + +def window_class(window): + try: + prop = subprocess.check_output( + ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return '' + return prop.lower() + + +def target_windows(): + """Managed client windows only; the game window is never selected.""" + found = [] + for window in xprop_root(): + classes = window_class(window) + if any(name in classes for name in TARGET_CLASSES): + found.append(int(window, 16)) + return sorted(set(found)) + + +class X11: + def __init__(self): + self.lib = ctypes.CDLL('libX11.so.6') + self.lib.XOpenDisplay.restype = ctypes.c_void_p + self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p] + self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p] + error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) + self._error_handler = error_type(lambda _display, _event: 0) + self.lib.XSetErrorHandler.argtypes = [error_type] + self.lib.XSetErrorHandler.restype = ctypes.c_void_p + self.lib.XSetErrorHandler(self._error_handler) + self.display = self.lib.XOpenDisplay(None) + if not self.display: + raise RuntimeError('cannot open DISPLAY') + + def unmap(self, window): + self.lib.XUnmapWindow(self.display, window) + self.lib.XSync(self.display, 0) + + def show(self, window): + self.lib.XMapRaised(self.display, window) + self.lib.XSync(self.display, 0) + + def close(self): + self.lib.XCloseDisplay(self.display) + + +class Watcher: + """Hide while a match is running, restore on end, uncertainty or exit.""" + + def __init__(self, prefix, x11, window_source=target_windows, + state_source=game_running, sleep=time.sleep, out=sys.stdout): + self.prefix = prefix + self.x11 = x11 + self.window_source = window_source + self.state_source = state_source + self.sleep = sleep + self.out = out + self.hidden = set() + self.reported_no_window = False + + def _show_all(self, reason): + if not self.hidden: + return + print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + for window in sorted(self.hidden): + try: + self.x11.show(window) + print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + file=sys.stderr, flush=True) + self.hidden.clear() + + def _hide_new(self): + windows = self.window_source() + if not windows and not self.hidden and not self.reported_no_window: + print('hide-league-client-in-game: no managed client window; not hiding', + file=self.out, flush=True) + self.reported_no_window = True + for window in windows: + if window in self.hidden: + continue + try: + self.x11.unmap(window) + self.hidden.add(window) + print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + file=sys.stderr, flush=True) + + def run(self, max_steps=None): + steps = 0 + while True: + state = self.state_source(self.prefix) + if state is None: + self._show_all('unreadable /proc state') + elif state: + if not self.hidden and self.reported_no_window: + self.reported_no_window = False + self._hide_new() + else: + self._show_all('the match ended') + self.reported_no_window = False + steps += 1 + if max_steps is not None and steps >= max_steps: + return 0 + self.sleep(POLL_SECONDS) + + def restore(self): + self._show_all('exiting') + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--detach', action='store_true', + help='leave the launcher session so a terminal hang-up cannot end it') + parser.add_argument('--once', action='store_true', + help='one game observation and exit (no window changes)') + args = parser.parse_args(argv) + + if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', + file=sys.stderr, flush=True) + return 0 + if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', + flush=True) + return 0 + prefix = target_prefix() + if prefix is None: + print('hide-league-client-in-game: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + if args.once: + state = game_running(prefix) + print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}', + flush=True) + return 0 + if args.detach: + try: + os.setsid() + except OSError: + pass + try: + x11 = X11() + except Exception as error: + print(f'hide-league-client-in-game: cannot open X11: {error}', + file=sys.stderr, flush=True) + return 1 + + watcher = Watcher(prefix, x11) + + def stop(_signum, _frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + print('hide-league-client-in-game: watching %s for a running match; ' + 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + try: + watcher.run() + except KeyboardInterrupt: + pass + finally: + watcher.restore() + x11.close() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..88dc083 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -19,6 +19,17 @@ fi if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & fi +# Opt-in alternative for users who keep the client open during a match: hide +# the managed client windows instead of closing the UI. It never touches the +# League backend, the Riot client or the game window; it restores the window +# when the game process ends. Close and hide are mutually exclusive. +if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 + else + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}"