mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
Add RCLOL_CLIENT_IN_GAME_TRICKS=1 as the practical launcher switch. It reads lol-user-experience from LCULocalPreferences.yaml: - potatoModeEnabled false or unreadable: no action; - closeLeagueClientDuringGame true: close the client UI; - closeLeagueClientDuringGame false: hide the client windows. The existing close and hide switches remain low-level options when the automatic switch is not set. The hide helper accepts the master switch, and the resolver plus preference cases are covered by tests and documentation. The local .desktop entry was validated with the master switch; the installed preferences select hide on this machine.
389 lines
13 KiB
Python
Executable File
389 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Opt-in: hide the League client UI while a match is running.
|
|
|
|
The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when
|
|
the client's own close-during-game preference is false. The low-level
|
|
`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. 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 xprop_value(window, prop):
|
|
try:
|
|
return subprocess.check_output(
|
|
['xprop', '-id', window, prop], text=True, timeout=5
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return ''
|
|
|
|
|
|
def window_class(window):
|
|
return xprop_value(window, 'WM_CLASS').lower()
|
|
|
|
|
|
def window_pid(window):
|
|
match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID'))
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def _socket_from_process(proc):
|
|
try:
|
|
environment = dict(
|
|
item.split(b'=', 1)
|
|
for item in (proc / 'environ').read_bytes().split(b'\0')
|
|
if b'=' in item
|
|
)
|
|
except (OSError, ValueError):
|
|
return None
|
|
return environment.get(SOCK_VARIABLE)
|
|
|
|
|
|
def host_socket_for_guest(guest_pid, proc_root=Path('/proc')):
|
|
"""Find the host process for a guest X11 PID and return its loader socket.
|
|
|
|
Darling's X11 window stores the guest PID in _NET_WM_PID, while the
|
|
process visible in /proc is the host loader. NSpid carries both IDs. The
|
|
direct /proc/<pid> case is checked too for a native process.
|
|
"""
|
|
direct = proc_root / str(guest_pid)
|
|
if direct.exists():
|
|
socket = _socket_from_process(direct)
|
|
if socket is not None:
|
|
return socket
|
|
sockets = set()
|
|
try:
|
|
entries = list(proc_root.iterdir())
|
|
except OSError:
|
|
return None
|
|
for proc in entries:
|
|
if not proc.name.isdigit():
|
|
continue
|
|
try:
|
|
status = (proc / 'status').read_text()
|
|
except OSError:
|
|
continue
|
|
for line in status.splitlines():
|
|
if not line.startswith('NSpid:'):
|
|
continue
|
|
if str(guest_pid) not in line.split()[1:]:
|
|
continue
|
|
socket = _socket_from_process(proc)
|
|
if socket is not None:
|
|
sockets.add(socket)
|
|
return sockets.pop() if len(sockets) == 1 else None
|
|
|
|
|
|
def window_belongs_to_prefix(window, prefix):
|
|
pid = window_pid(window)
|
|
if pid is None:
|
|
return False
|
|
socket = host_socket_for_guest(pid)
|
|
if socket is None:
|
|
return False
|
|
try:
|
|
return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def target_windows(prefix, class_cache=None):
|
|
"""Managed client windows for this prefix; the game window is never selected.
|
|
|
|
WM_CLASS is cached only when non-empty. The cache is pruned when a window
|
|
leaves _NET_CLIENT_LIST; empty results are retried because a class can be
|
|
set after the window appears.
|
|
"""
|
|
current = []
|
|
for raw in xprop_root():
|
|
try:
|
|
current.append((int(raw, 16), raw))
|
|
except ValueError:
|
|
continue
|
|
current_ids = {window for window, _raw in current}
|
|
if class_cache is not None:
|
|
for stale in set(class_cache) - current_ids:
|
|
del class_cache[stale]
|
|
found = []
|
|
for window, raw in current:
|
|
classes = class_cache.get(window) if class_cache is not None else None
|
|
if not classes:
|
|
classes = window_class(raw)
|
|
if classes and class_cache is not None:
|
|
class_cache[window] = classes
|
|
if not classes or not any(name in classes for name in TARGET_CLASSES):
|
|
continue
|
|
if window_belongs_to_prefix(raw, prefix):
|
|
found.append(window)
|
|
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.class_cache = {}
|
|
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(self.prefix, self.class_cache)
|
|
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)
|
|
|
|
tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1'
|
|
if not args.once and not tricks and 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 not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1':
|
|
print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or '
|
|
'RCLOL_CLIENT_IN_GAME_TRICKS=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
|
|
|
|
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
signal.signal(signum, 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())
|