mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
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.
This commit is contained in:
1 parent
a4da79011f
commit
415aa7a949
7 files changed
+537
No files matched your search
Executable
+295
@@ -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())
|
||||
@@ -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}"
|
||||
|
||||
Reference in new issue
Block a user