feat(launcher): select client-in-game mode from preferences

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.
This commit is contained in:
3AvisD committed 2026-10-22 14:56:03 -06:00
1 parent 521527eb21
commit 8de6bceeae
9 files changed
+231 -61

No files matched your search

+1
View File
@@ -32,3 +32,4 @@ jobs:
- 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'
- run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py'
+11 -3
View File
@@ -109,13 +109,21 @@ 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:
For automatic client handling during a match, opt in with one switch:
```sh
RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh
RCLOL_CLIENT_IN_GAME_TRICKS=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.
It reads `lol-user-experience` from the installed League client's
`LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the
client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the
`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game
ends. With client optimizations disabled or unreadable preferences it does
nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME`
low-level switches remain available when the automatic switch is not set. See
[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured effect and caveats.
## Testing
+11 -6
View File
@@ -78,12 +78,17 @@ 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)
`RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a
match. It reads `lol-user-experience` from the installed client's
`LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client
UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed
`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game
ends. With client optimizations disabled or preferences unreadable it does
nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and
`RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic
switch is not set. The feature is off by default, requires X11/Xwayland, and
does not patch client binaries, CEF or the game. See
[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured CPU effect and limitations.
`RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before
+29 -27
View File
@@ -192,30 +192,39 @@ 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
### Automatic client handling 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:
A single opt-in switch reads the client's own preferences and chooses the mode:
```sh
RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh
RCLOL_CLIENT_IN_GAME_TRICKS=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.
It reads `lol-user-experience` from the installed client's
`LCULocalPreferences.yaml`:
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.
| Client preference | Launcher action |
|---|---|
| `potatoModeEnabled: false` or unreadable | no client-in-game helper |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows |
On a CPU-bound host the effect appears after CEF has been hidden for a few
The hide path 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. The
close path uses the UI-close helper described above.
The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and
`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic
switch is not set. All modes are off by default. The helpers are
window-management only: they do not patch client binaries, CEF, anti-cheat or
DRM. They attribute the game to this prefix through the loader's
`__mldr_sockpath`, and fail closed when ownership, proc state, environment or
window information cannot be read. The hide helper 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 hide 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
@@ -224,16 +233,9 @@ 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
```
With client optimizations enabled and close-during-game enabled, the close
helper remains the more aggressive option when in-client chat during the match
is not needed.
Diagnose the match detector without changing any window:
+69
View File
@@ -0,0 +1,69 @@
"""Select the automatic client-in-game mode from LCULocalPreferences.yaml."""
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(
'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py')
tricks = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tricks)
class ClientInGameTricksTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml'
def write(self, optimization=None, close=None):
lines = ['lol-user-experience:', ' data:']
if optimization is not None:
lines.append(f' potatoModeEnabled: {optimization}')
if close is not None:
lines.append(f' closeLeagueClientDuringGame: {close}')
self.path.write_text('\n'.join(lines) + '\n')
def test_close_when_both_preferences_are_true(self):
self.write(True, True)
self.assertEqual(tricks.mode(self.path), 'close')
def test_hide_when_optimizations_are_on_and_close_is_false(self):
self.write(True, False)
self.assertEqual(tricks.mode(self.path), 'hide')
def test_hide_when_close_preference_is_missing(self):
self.write(True)
self.assertEqual(tricks.mode(self.path), 'hide')
def test_none_when_optimizations_are_off(self):
self.write(False, True)
self.assertEqual(tricks.mode(self.path), 'none')
def test_none_when_optimizations_are_missing_or_file_is_unreadable(self):
self.write(None, True)
self.assertEqual(tricks.mode(self.path), 'none')
self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none')
def test_last_duplicate_value_wins_and_quotes_are_accepted(self):
self.path.write_text(
'potatoModeEnabled: false\n'
'closeLeagueClientDuringGame: false\n'
'potatoModeEnabled: "true"\n'
'closeLeagueClientDuringGame: "true"\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_main_prints_mode(self):
self.write(True, False)
output = io.StringIO()
with patch('sys.stdout', output):
self.assertEqual(tricks.main([str(self.path)]), 0)
self.assertEqual(output.getvalue().strip(), 'hide')
if __name__ == '__main__':
unittest.main()
+19 -1
View File
@@ -5,7 +5,7 @@ import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
@@ -217,6 +217,24 @@ class HideClientInGameTests(unittest.TestCase):
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_master_tricks_switch_enables_hide(self):
class FakeX11:
def close(self):
pass
watcher = Mock()
with patch.dict(os.environ, {
'RCLOL_CLIENT_IN_GAME_TRICKS': '1',
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}), patch.object(hide_in_game, 'target_prefix',
return_value=self.prefix), \
patch.object(hide_in_game, 'X11', return_value=FakeX11()), \
patch.object(hide_in_game, 'Watcher', return_value=watcher):
self.assertEqual(hide_in_game.main([]), 0)
watcher.run.assert_called_once_with()
watcher.restore.assert_called_once_with()
if __name__ == '__main__':
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Resolve the client-in-game mode from League's local preferences.
The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads
`lol-user-experience` from LCULocalPreferences.yaml:
- potatoModeEnabled must be true (client optimizations enabled);
- closeLeagueClientDuringGame true -> close the client UI while a match runs;
- closeLeagueClientDuringGame false -> hide the client windows instead;
- missing/unreadable preferences -> no action (none).
This helper only selects a mode. It does not change preferences or files.
"""
import re
import sys
from pathlib import Path
CLOSE_KEY = 'closeLeagueClientDuringGame'
OPTIMIZATION_KEY = 'potatoModeEnabled'
TRUE_VALUES = {'true', 'yes', '1'}
FALSE_VALUES = {'false', 'no', '0'}
def read_bool(path, key):
"""Return True/False for the last value, or None when absent/unreadable."""
try:
lines = Path(path).read_text().splitlines()
except (OSError, UnicodeError):
return None
value = None
for line in lines:
match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line)
if match:
value = match.group(1).strip('"\'').lower()
if value in TRUE_VALUES:
return True
if value in FALSE_VALUES:
return False
return None
def mode(path):
if read_bool(path, OPTIMIZATION_KEY) is not True:
return 'none'
return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide'
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
path = argv[0] if argv else ''
print(mode(path))
return 0
if __name__ == '__main__':
sys.exit(main())
+11 -8
View File
@@ -1,10 +1,12 @@
#!/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.
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
@@ -332,13 +334,14 @@ def main(argv=None):
help='one game observation and exit (no window changes)')
args = parser.parse_args(argv)
if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1':
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 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)
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:
+23 -16
View File
@@ -12,23 +12,30 @@ 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
# Opt-in automatic client-in-game handling. This single switch reads the
# client's own lol-user-experience preferences: close the UI when the user
# selected "close client during game", otherwise hide the client windows.
# It does nothing unless potatoModeEnabled (client optimizations) is true.
if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then
pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml"
mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none)
case "$mode" in
close)
echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2
python3 "$runner_dir/close-league-client-in-game.py" --detach &
;;
hide)
echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2
python3 "$runner_dir/hide-league-client-in-game.py" --detach &
;;
*)
echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2
;;
esac
elif [ "${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
elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/hide-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.