mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-27 19:54:38 +00:00
Merge pull request #72 from 3AvisD/fix/close-league-client-in-game
fix(launcher): close the League client UI while a match runs
This commit is contained in:
commit
0b02889968
5 files changed
+538
No files matched your search
@@ -21,3 +21,4 @@ jobs:
|
||||
- run: python -m unittest discover -s tests -p 'test_system_profiler.py'
|
||||
- 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'
|
||||
@@ -149,6 +149,49 @@ ownership or window information cannot be read. A hidden background client or
|
||||
download is restarted when recovery is enabled. Other desktop environments are
|
||||
not supported by this helper; recovery is off by default.
|
||||
|
||||
### Client window stays black after a match
|
||||
|
||||
The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers)
|
||||
alive during a match. Riot's own "Close client during game" option closes that UI
|
||||
on Windows, but under Darling the pre-match instance keeps running: the client the
|
||||
backend starts when the match ends is a second CEF instance on the same
|
||||
`--user-data-dir`, and its window never paints. Its DOM and audio work and the
|
||||
window stays black. Closing the client by hand during the match leaves the same
|
||||
state for the same reason.
|
||||
|
||||
An opt-in helper closes that UI itself, from the host, and only while a match
|
||||
runs, so the client that appears afterwards is the only instance:
|
||||
|
||||
```sh
|
||||
RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh
|
||||
```
|
||||
|
||||
This is not a memory measure: the UI is closed because it competes with the game
|
||||
for CPU during the match. Measured on a four-core host with a Radeon RX 550, a
|
||||
practice match reported roughly 120 FPS with the client UI running and roughly
|
||||
180 FPS with it closed. On that host a local supervisor using the same detection
|
||||
closed 13 client processes (2027 MiB resident) when a practice match started, and
|
||||
the client came back as one instance with a painted window when it ended; the
|
||||
helper described here has not been through a live match yet. Riot's own option is
|
||||
the equivalent on Windows, so leave either one disabled if the in-client chat is
|
||||
wanted during a match.
|
||||
|
||||
The helper is off by default and fails closed: it attributes both the game and the
|
||||
client to the launcher's prefix through the loader's `__mldr_sockpath` (guest
|
||||
processes do not carry `DPREFIX`), never closes a client younger than 60 seconds
|
||||
(the one the backend starts after a match), leaves the Riot client, the League
|
||||
backend and the game untouched, and takes no action when ownership, the
|
||||
environment or the stat fields cannot be read. The client UI is recognised by the
|
||||
name of its executable, not by the command line: `LeagueCrashHandler` processes
|
||||
carry the client UI name in their arguments only, so they are left alone. It
|
||||
leaves by itself ten minutes after the last client or match is gone (the clock does
|
||||
not run before the first client appears, so a long patch or login is fine). Diagnose it in one
|
||||
observation, without waiting for a match:
|
||||
|
||||
```sh
|
||||
python3 tools/close-league-client-in-game.py --once --dry-run
|
||||
```
|
||||
|
||||
### Downloads repeatedly fail with an IPv6 CONNECT_TO error
|
||||
|
||||
The bundled downloader was observed retrying CDN chunks with:
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Exercise the in-match client close with fake /proc entries and mocked signals only."""
|
||||
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('in_game', ROOT / 'tools/close-league-client-in-game.py')
|
||||
in_game = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(in_game)
|
||||
|
||||
MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx'
|
||||
HELPER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClientUx Helper.app'
|
||||
'/Contents/MacOS/LeagueClientUx Helper')
|
||||
GAME = '/Applications/LeagueofLegends.app/Contents/MacOS/LeagueofLegends'
|
||||
BACKEND = '/Applications/League of Legends.app/Contents/LoL/LeagueClient.app/Contents/MacOS/LeagueClient'
|
||||
RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices'
|
||||
CRASH_HANDLER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClient.app'
|
||||
'/Contents/MacOS/LeagueCrashHandler')
|
||||
# A live crash handler carries the client UI name in its arguments only.
|
||||
CRASH_ARGUMENTS = ('--attachment=/Applications/League of Legends.app/Contents/LoL/Logs/'
|
||||
'LeagueClientUx Logs/2026-10-18T09-49-13_659_LeagueClientUx.log',
|
||||
'--handshake-fd=52')
|
||||
|
||||
|
||||
class InGameCloseTests(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.proc / 'uptime').write_text('1000 0\n')
|
||||
self.home = self.root / 'home'
|
||||
self.home_patch = patch.object(Path, 'home', return_value=self.home)
|
||||
self.home_patch.start()
|
||||
self.addCleanup(self.home_patch.stop)
|
||||
self.prefix = self.home / '.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):
|
||||
if prefix is None:
|
||||
return b''
|
||||
return b'%s=%s\0' % (in_game.SOCK_VARIABLE, os.fsencode(prefix / in_game.SOCK_NAME))
|
||||
|
||||
def process(self, pid, executable, prefix='own', age=120, 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)
|
||||
fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * os.sysconf('SC_CLK_TCK')))]
|
||||
(folder / 'stat').write_text(str(pid) + ' (LeagueClientUx (helper)) ' + ' '.join(fields))
|
||||
return folder
|
||||
|
||||
def client_ui(self, pid, prefix='own', age=120, environ=None):
|
||||
return self.process(pid, MAIN if pid % 2 else HELPER, prefix=prefix, age=age, environ=environ)
|
||||
|
||||
def run_once(self, dry_run=False):
|
||||
self.output = io.StringIO()
|
||||
with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill:
|
||||
result = in_game.supervise(self.proc, once=True, dry_run=dry_run, out=self.output)
|
||||
return result, [call.args for call in kill.call_args_list]
|
||||
|
||||
def test_client_ui_without_a_match_is_preserved(self):
|
||||
self.client_ui(123)
|
||||
result, calls = self.run_once()
|
||||
self.assertEqual((result, calls), (0, []))
|
||||
self.assertIn('match=absent client_ui=1', self.output.getvalue())
|
||||
|
||||
def test_running_match_closes_only_the_client_ui(self):
|
||||
self.client_ui(123)
|
||||
self.process(124, HELPER)
|
||||
self.process(125, GAME)
|
||||
self.process(126, BACKEND)
|
||||
self.process(127, RIOT)
|
||||
self.process(128, MAIN, prefix=self.root / 'other-prefix')
|
||||
result, calls = self.run_once()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM),
|
||||
(124, in_game.signal.SIGTERM),
|
||||
(123, in_game.signal.SIGKILL),
|
||||
(124, in_game.signal.SIGKILL)]))
|
||||
self.assertIn('closed the client UI for the running match (2 processes: 123,124)',
|
||||
self.output.getvalue())
|
||||
|
||||
def test_marker_in_arguments_does_not_make_a_process_client_ui(self):
|
||||
self.client_ui(123)
|
||||
self.process(124, CRASH_HANDLER, args=CRASH_ARGUMENTS)
|
||||
self.process(125, GAME)
|
||||
result, calls = self.run_once()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM),
|
||||
(123, in_game.signal.SIGKILL)]))
|
||||
self.assertIn('closed the client UI for the running match (1 processes: 123)',
|
||||
self.output.getvalue())
|
||||
|
||||
def test_game_argument_naming_the_client_ui_is_still_the_game(self):
|
||||
self.process(125, GAME, args=('--parent=LeagueClientUx',))
|
||||
self.process(126, CRASH_HANDLER, args=CRASH_ARGUMENTS)
|
||||
result, calls = self.run_once()
|
||||
self.assertEqual((result, calls), (0, []))
|
||||
self.assertIn('match=running client_ui=0', self.output.getvalue())
|
||||
|
||||
def test_ui_identity_rejects_a_related_process_with_the_marker(self):
|
||||
folder = self.client_ui(123)
|
||||
self.assertIsNotNone(in_game.ui_identity(self.proc, '123'))
|
||||
(folder / 'cmdline').write_bytes(CRASH_HANDLER.encode() + b'\0'
|
||||
+ b'\0'.join(a.encode() for a in CRASH_ARGUMENTS) + b'\0')
|
||||
self.assertIsNone(in_game.ui_identity(self.proc, '123'))
|
||||
with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill:
|
||||
in_game.close_ui(self.proc, {'123': 1})
|
||||
self.assertEqual(kill.call_args_list, [])
|
||||
|
||||
def test_match_in_another_install_is_preserved(self):
|
||||
self.client_ui(123)
|
||||
self.process(125, GAME, prefix=self.root / 'other-prefix')
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_game_without_an_install_socket_is_preserved(self):
|
||||
self.client_ui(123)
|
||||
self.process(125, GAME, environ=b'')
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_young_client_ui_is_preserved(self):
|
||||
self.client_ui(123, age=in_game.MIN_AGE_SECONDS - 1)
|
||||
self.process(125, GAME)
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_client_ui_of_another_install_is_preserved(self):
|
||||
self.client_ui(123, prefix=self.root / 'other-prefix')
|
||||
self.process(125, GAME)
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_client_ui_without_an_install_socket_is_preserved(self):
|
||||
self.client_ui(123, environ=b'')
|
||||
self.process(125, GAME)
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_missing_prefix_prevents_any_action(self):
|
||||
self.client_ui(123)
|
||||
self.process(125, GAME)
|
||||
with patch.dict(os.environ, {'DPREFIX': 'relative/path'}):
|
||||
self.assertIsNone(in_game.target_prefix())
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_foreign_owned_process_is_ignored(self):
|
||||
self.client_ui(123)
|
||||
self.process(125, GAME)
|
||||
with patch.object(in_game.os, 'getuid', return_value=os.getuid() + 1):
|
||||
result, calls = self.run_once()
|
||||
self.assertEqual((result, calls), (0, []))
|
||||
|
||||
def test_unreadable_environment_prevents_action(self):
|
||||
self.client_ui(123)
|
||||
self.process(125, GAME)
|
||||
original = Path.read_bytes
|
||||
def read(path):
|
||||
if path.name == 'environ':
|
||||
raise PermissionError()
|
||||
return original(path)
|
||||
with patch.object(Path, 'read_bytes', read):
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_malformed_or_missing_age_prevents_action(self):
|
||||
proc = self.client_ui(123)
|
||||
self.process(125, GAME)
|
||||
for value in ('garbage', '123 (worker) S'):
|
||||
(proc / 'stat').write_text(value)
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
(proc / 'stat').unlink()
|
||||
self.assertEqual(self.run_once()[1], [])
|
||||
|
||||
def test_dry_run_reports_without_signalling(self):
|
||||
self.client_ui(123)
|
||||
self.process(125, GAME)
|
||||
self.assertEqual(self.run_once(dry_run=True)[1], [])
|
||||
self.assertIn('would close the client UI (1 processes: 123)', self.output.getvalue())
|
||||
|
||||
def test_exited_or_reused_processes_are_preserved(self):
|
||||
folder = self.client_ui(123)
|
||||
identity = in_game.ui_identity(self.proc, '123')
|
||||
self.assertIsNotNone(identity)
|
||||
(folder / 'cmdline').write_bytes(b'/usr/bin/editor\0')
|
||||
self.assertIsNone(in_game.ui_identity(self.proc, '123'))
|
||||
with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill:
|
||||
in_game.close_ui(self.proc, {'123': identity, '124': 1})
|
||||
self.assertEqual(kill.call_args_list, [])
|
||||
|
||||
def test_idle_clock_starts_only_after_a_client_or_match(self):
|
||||
empty = {'game': False, 'ui': {}}
|
||||
client = {'game': False, 'ui': {'10': 5}}
|
||||
match = {'game': True, 'ui': {}}
|
||||
# Before any client (long patch or login): never idle, however long it takes.
|
||||
self.assertEqual(in_game.update_idle(empty, False, None, 5000.0), (False, None))
|
||||
# A client or a match marks the session as seen and keeps the clock stopped.
|
||||
self.assertEqual(in_game.update_idle(client, False, None, 10.0), (True, None))
|
||||
self.assertEqual(in_game.update_idle(match, True, 50.0, 60.0), (True, None))
|
||||
# Afterwards the clock starts once and keeps its start time.
|
||||
self.assertEqual(in_game.update_idle(empty, True, None, 70.0), (True, 70.0))
|
||||
self.assertEqual(in_game.update_idle(empty, True, 70.0, 900.0), (True, 70.0))
|
||||
|
||||
def test_close_decision_covers_limits_and_cooldown(self):
|
||||
state = {'game': True, 'ui': {'123': 1}}
|
||||
self.assertEqual(in_game.close_decision(state, 0, 0.0, 100.0), 'close')
|
||||
self.assertIsNone(in_game.close_decision(state, 0, 90.0, 100.0))
|
||||
self.assertEqual(in_game.close_decision(state, in_game.MAX_CLOSES, 0.0, 100.0), 'limit')
|
||||
self.assertIsNone(in_game.close_decision({'game': False, 'ui': {'123': 1}}, 0, 0.0, 100.0))
|
||||
self.assertIsNone(in_game.close_decision({'game': True, 'ui': {}}, 0, 0.0, 100.0))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Close the League client UI while a match runs; fail closed.
|
||||
|
||||
Opt-in helper for `run-riot-client.sh` (`RCLOL_CLOSE_CLIENT_IN_GAME=1`). The
|
||||
League client keeps its Chromium/CEF UI (executable `LeagueClientUx` and its
|
||||
helpers) alive during a match. Those processes compete with the game for CPU,
|
||||
and the client the backend starts when the match ends is a second CEF instance
|
||||
on the same `--user-data-dir` as the pre-match one, so its window never paints:
|
||||
the DOM and audio work, the window stays black. Riot's own "Close client during
|
||||
game" option has both outcomes on a Windows client, but under Darling it leaves
|
||||
the pre-match instance running, so the option reproduces the black window.
|
||||
|
||||
This helper performs the close itself, from the host, while a match is running.
|
||||
Only then is the client UI expendable, and the client that appears afterwards is
|
||||
the only instance. It never touches the Riot client, the League backend
|
||||
(`LeagueClient`) or the game, and it exits by itself when no client is left.
|
||||
|
||||
It is fail closed: it acts only after positively identifying a running game and
|
||||
the League client UI of its own prefix, and never closes a client younger than
|
||||
MIN_AGE_SECONDS (the one the backend starts after a match). An unreadable
|
||||
`/proc` entry, an unreadable environment, an unreadable or malformed stat field,
|
||||
a process of another install and a missing game all lead to no action.
|
||||
|
||||
The client UI is recognised by the name of its executable, not by the command
|
||||
line: `LeagueCrashHandler` processes carry the `LeagueClientUx` string in their
|
||||
arguments (attachment paths under `LeagueClientUx Logs`) and live under the same
|
||||
`/League` paths, so a wider match would signal a related process.
|
||||
|
||||
Diagnose with `--once` (one observation) or `--once --dry-run` (report only).
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
UX_NAME = 'LeagueClientUx'
|
||||
GAME_SUFFIX = '/LeagueofLegends'
|
||||
# DPREFIX is not in the environment of guest processes (checked on a live session), but
|
||||
# every process the loader starts carries the socket of the install it belongs to.
|
||||
SOCK_VARIABLE = b'__mldr_sockpath'
|
||||
SOCK_NAME = '.darlingserver.sock'
|
||||
|
||||
MIN_AGE_SECONDS = 60 # a younger client UI is the one starting after a match
|
||||
TERM_GRACE = 4.0 # seconds between SIGTERM and SIGKILL
|
||||
KILL_GRACE = 2.0
|
||||
POLL_SECONDS = 5.0
|
||||
COOLDOWN_SECONDS = 30.0 # minimum between two closes in the same match
|
||||
MAX_CLOSES = 3 # give up on a client that keeps reopening during a match
|
||||
IDLE_SECONDS = 600.0 # leave after this long without a client or a game, once one was seen
|
||||
|
||||
|
||||
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 is_client_ui(executable):
|
||||
"""True for the League client UI and its helpers, by executable name.
|
||||
|
||||
The whole command line is not usable: `LeagueCrashHandler` carries the
|
||||
`LeagueClientUx` string in its arguments (attachment paths under
|
||||
`LeagueClientUx Logs`) and lives under the same `/League` paths, so only the
|
||||
file name tells the UI apart from a related process.
|
||||
"""
|
||||
name = os.path.basename(executable)
|
||||
return name == UX_NAME or name.startswith(UX_NAME + ' Helper')
|
||||
|
||||
|
||||
def match_state(proc_root, prefix):
|
||||
"""Return {'game': bool, 'ui': {pid: start_ticks}}, or None if not reliable.
|
||||
|
||||
None means the caller must not act. The game and the client UI are both
|
||||
attributed to `prefix` through the loader's socket path: a process of
|
||||
another install is not a match and its client is not touched.
|
||||
"""
|
||||
state = {'game': False, 'ui': {}}
|
||||
owner = (prefix / SOCK_NAME).parent.resolve()
|
||||
try:
|
||||
uptime = float((proc_root / 'uptime').read_text().split()[0])
|
||||
ticks = os.sysconf('SC_CLK_TCK')
|
||||
if not math.isfinite(uptime) or uptime < 0 or ticks <= 0:
|
||||
return None
|
||||
entries = list(proc_root.iterdir())
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
for proc in entries:
|
||||
if not proc.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
if proc.stat().st_uid != os.getuid():
|
||||
continue
|
||||
raw = (proc / 'cmdline').read_bytes()
|
||||
executable = raw.split(b'\0')[0].decode(errors='replace')
|
||||
client_ui = is_client_ui(executable)
|
||||
if not client_ui and not executable.endswith(GAME_SUFFIX):
|
||||
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 or Path(os.fsdecode(socket)).parent.resolve() != owner:
|
||||
continue
|
||||
if not client_ui:
|
||||
state['game'] = True
|
||||
continue
|
||||
# Field 22 follows a comm field that may contain spaces/parentheses.
|
||||
start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19])
|
||||
if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS:
|
||||
continue
|
||||
state['ui'][proc.name] = start_ticks
|
||||
except FileNotFoundError:
|
||||
if proc.exists():
|
||||
return None
|
||||
continue
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def ui_identity(proc_root, pid):
|
||||
"""Start ticks of a live client UI process, or None when it is not one."""
|
||||
try:
|
||||
raw = (proc_root / pid / 'cmdline').read_bytes()
|
||||
executable = raw.split(b'\0')[0].decode(errors='replace')
|
||||
if not is_client_ui(executable):
|
||||
return None
|
||||
return int((proc_root / pid / 'stat').read_text().rsplit(')', 1)[1].split()[19])
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def close_ui(proc_root, processes):
|
||||
"""Terminate the given client UI processes; return the pids signalled.
|
||||
|
||||
Each pid is rechecked, including its start time, before every signal: a
|
||||
process that exited, a reused pid and a process that is no longer a client
|
||||
UI are all left alone.
|
||||
"""
|
||||
closed = set()
|
||||
for sig, grace in ((signal.SIGTERM, TERM_GRACE), (signal.SIGKILL, KILL_GRACE)):
|
||||
for pid, start_ticks in processes.items():
|
||||
if ui_identity(proc_root, pid) != start_ticks:
|
||||
continue
|
||||
try:
|
||||
os.kill(int(pid), sig)
|
||||
closed.add(pid)
|
||||
except OSError:
|
||||
continue
|
||||
time.sleep(grace)
|
||||
return closed
|
||||
|
||||
|
||||
def close_decision(state, closes, last_close, now):
|
||||
"""Return 'close', 'limit' or None for one observation."""
|
||||
if not state['game'] or not state['ui']:
|
||||
return None
|
||||
if closes >= MAX_CLOSES:
|
||||
return 'limit'
|
||||
if now - last_close < COOLDOWN_SECONDS:
|
||||
return None
|
||||
return 'close'
|
||||
|
||||
|
||||
def update_idle(state, seen, idle_since, now):
|
||||
"""Return (seen, idle_since) after one observation.
|
||||
|
||||
The idle clock only starts once a client UI or a match has been seen: a long
|
||||
patch or login can keep the League client closed for more than IDLE_SECONDS
|
||||
after launch, and leaving then would skip the match that follows.
|
||||
"""
|
||||
active = state['game'] or bool(state['ui'])
|
||||
seen = seen or active
|
||||
if active or not seen:
|
||||
return seen, None
|
||||
return seen, now if idle_since is None else idle_since
|
||||
|
||||
|
||||
def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, out=sys.stdout):
|
||||
"""Watch for a match and close the client UI while it runs."""
|
||||
prefix = prefix or target_prefix()
|
||||
if prefix is None:
|
||||
print('close-league-client-in-game: unusable DPREFIX: no action', file=out, flush=True)
|
||||
return 0
|
||||
print('close-league-client-in-game: watching %s for a running match; '
|
||||
'opt out with RCLOL_CLOSE_CLIENT_IN_GAME=0' % prefix, file=out, flush=True)
|
||||
in_game = False
|
||||
closes = 0
|
||||
last_close = 0.0
|
||||
limit_reported = False
|
||||
seen = False
|
||||
idle_since = None
|
||||
while True:
|
||||
state = match_state(proc_root, prefix)
|
||||
now = time.monotonic()
|
||||
if state is None:
|
||||
print('close-league-client-in-game: unreadable /proc state: no action',
|
||||
file=out, flush=True)
|
||||
else:
|
||||
if state['game'] and not in_game:
|
||||
in_game = True
|
||||
closes = 0
|
||||
last_close = 0.0
|
||||
limit_reported = False
|
||||
print('close-league-client-in-game: a match started', file=out, flush=True)
|
||||
elif not state['game'] and in_game:
|
||||
in_game = False
|
||||
print('close-league-client-in-game: the match ended; the client can reopen',
|
||||
file=out, flush=True)
|
||||
decision = close_decision(state, closes, last_close, now)
|
||||
if decision == 'limit':
|
||||
if not limit_reported:
|
||||
print('close-league-client-in-game: the client reopened %d times in one '
|
||||
'match: no further action' % closes, file=out, flush=True)
|
||||
limit_reported = True
|
||||
elif decision == 'close':
|
||||
pids = sorted(state['ui'], key=int)
|
||||
if dry_run:
|
||||
print('close-league-client-in-game: would close the client UI (%d '
|
||||
'processes: %s)' % (len(pids), ','.join(pids)), file=out, flush=True)
|
||||
else:
|
||||
closed = close_ui(proc_root, state['ui'])
|
||||
if closed:
|
||||
closes += 1
|
||||
last_close = time.monotonic()
|
||||
print('close-league-client-in-game: closed the client UI for the '
|
||||
'running match (%d processes: %s)'
|
||||
% (len(closed), ','.join(sorted(closed, key=int))),
|
||||
file=out, flush=True)
|
||||
seen, idle_since = update_idle(state, seen, idle_since, now)
|
||||
if once:
|
||||
if state is not None:
|
||||
print('close-league-client-in-game: one observation: match=%s client_ui=%d'
|
||||
% ('running' if state['game'] else 'absent', len(state['ui'])),
|
||||
file=out, flush=True)
|
||||
return 0
|
||||
if idle_since is not None and time.monotonic() - idle_since > IDLE_SECONDS:
|
||||
print('close-league-client-in-game: no client or match for %ds: leaving'
|
||||
% IDLE_SECONDS, file=out, flush=True)
|
||||
return 0
|
||||
time.sleep(POLL_SECONDS)
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--once', action='store_true',
|
||||
help='one observation and exit (diagnostics)')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='report what it would close, without signalling anything')
|
||||
parser.add_argument('--detach', action='store_true',
|
||||
help='leave the launcher session so a terminal hang-up cannot end it')
|
||||
args = parser.parse_args()
|
||||
if args.detach:
|
||||
try:
|
||||
os.setsid()
|
||||
except OSError:
|
||||
pass
|
||||
return supervise(once=args.once, dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -12,6 +12,13 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then
|
||||
python3 "$runner_dir/recover-hidden-riot-client.py"
|
||||
fi
|
||||
# Opt-in: close the League client UI while a match runs. It keeps the CPU for the game
|
||||
# and leaves the client the backend starts when the match ends as the only CEF instance;
|
||||
# a second instance on the same profile never paints its window, which is the black
|
||||
# window seen with Riot's own "Close client during game" option. Off by default.
|
||||
if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then
|
||||
python3 "$runner_dir/close-league-client-in-game.py" --detach &
|
||||
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