fix(launcher): address in-game helper review

- restore hidden windows even when the launcher output pipe is closed;
- exit the hide watcher after ten idle minutes and start it only after the
  installed-client check;
- reject missing, malformed or invalid close-during-game preferences instead
  of silently selecting hide;
- document the preference contract and idle behavior.
This commit is contained in:
3AvisD committed 2026-10-23 06:08:45 -06:00
1 parent 732e9c8881
commit f8bb14624e
8 files changed
+176 -67

No files matched your search

+7 -5
View File
@@ -117,11 +117,13 @@ RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh
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
client UI if `closeLeagueClientDuringGame` is true, and hides the
`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or
invalid preference values, disabled client optimizations and unreadable files
select no action. The `RCLOL_CLOSE_CLIENT_IN_GAME` and
`RCLOL_HIDE_CLIENT_IN_GAME` low-level switches remain available when the
automatic switch is not set. The helper exits after ten minutes without a
client or match once it has seen one. See
[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured effect and caveats.
+6 -5
View File
@@ -81,13 +81,14 @@ for the observed failures, recovery guards and NSURLSession limitations.
`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
UI when `closeLeagueClientDuringGame: true`, and hides the managed
`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or
invalid preference values, disabled client optimizations and unreadable files
select no action. 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
does not patch client binaries, CEF or the game. The hide helper exits after ten
minutes without a client or match once it has seen one. See
[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured CPU effect and limitations.
+4 -1
View File
@@ -206,13 +206,16 @@ It reads `lol-user-experience` from the installed client's
| Client preference | Launcher action |
|---|---|
| `potatoModeEnabled: false` or unreadable | no client-in-game helper |
| `potatoModeEnabled: true`, close preference missing or invalid | no action |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows |
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.
close path uses the UI-close helper described above. The hide helper exits after
ten minutes without a client or match once it has seen one; a missing or invalid
close preference selects no helper.
The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and
`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic
+6 -2
View File
@@ -36,9 +36,12 @@ class ClientInGameTricksTests(unittest.TestCase):
self.write(True, False)
self.assertEqual(tricks.mode(self.path), 'hide')
def test_hide_when_close_preference_is_missing(self):
def test_none_when_close_preference_is_missing_or_invalid(self):
self.write(True)
self.assertEqual(tricks.mode(self.path), 'hide')
self.assertEqual(tricks.mode(self.path), 'none')
for value in ('maybe', '"true', 'true#note', '"true"junk'):
self.write(True, value)
self.assertEqual(tricks.mode(self.path), 'none', value)
def test_none_when_optimizations_are_off(self):
self.write(False, True)
@@ -64,6 +67,7 @@ class ClientInGameTricksTests(unittest.TestCase):
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: false\n'
'other-section:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: true\n')
+45
View File
@@ -217,6 +217,46 @@ class HideClientInGameTests(unittest.TestCase):
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
def test_restore_log_failure_still_shows_the_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher.hidden = {0xabc}
watcher._show_all('test')
self.assertEqual(x11.shown, [0xabc])
self.assertEqual(watcher.hidden, set())
def test_idle_timeout_leaves_after_client_disappears(self):
states = iter([True, False, False])
windows = iter([[0xabc], [], []])
times = iter([0.0, 10.0, 700.0])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: next(windows),
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
clock=lambda: next(times),
)
self.assertEqual(watcher.run(max_steps=3), 0)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('no client or match', output.getvalue())
def test_hides_then_shows_on_match_end(self):
states = iter([True, True, False])
x11 = FakeX11()
@@ -278,6 +318,11 @@ class HideClientInGameTests(unittest.TestCase):
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_helper_starts_after_the_installed_client_check(self):
source = (ROOT / 'tools/run-riot-client.sh').read_text()
self.assertLess(source.index('Riot Client is not installed in'),
source.index('RCLOL_CLIENT_IN_GAME_TRICKS'))
def test_master_tricks_switch_enables_hide(self):
class FakeX11:
def close(self):
+41 -16
View File
@@ -7,7 +7,8 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads
- 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 preferences or an unreadable file -> no action (none).
- missing or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action
(none).
Only the named `lol-user-experience` section is considered, so identical keys
in another section cannot select a helper. This helper only selects a mode; it
@@ -24,8 +25,39 @@ TRUE_VALUES = {'true', 'yes', '1'}
FALSE_VALUES = {'false', 'no', '0'}
def parse_bool(value):
"""Return True/False/None for one YAML scalar, ignoring a trailing comment."""
value = value.strip()
if not value:
return None
if value[0] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end == -1:
return None
scalar = value[1:end]
remainder = value[end + 1:]
if remainder and not remainder[0].isspace():
return None
remainder = remainder.strip()
if remainder and not remainder.startswith('#'):
return None
else:
scalar = value
for index, char in enumerate(scalar):
if char == '#' and (index == 0 or scalar[index - 1].isspace()):
scalar = scalar[:index].rstrip()
break
scalar = scalar.lower()
if scalar in TRUE_VALUES:
return True
if scalar in FALSE_VALUES:
return False
return None
def read_bool(path, key, section=SECTION):
"""Return True/False for the last key in section, or None when absent.
"""Return True/False for the last key in section, or None when invalid.
OSError/UnicodeError is raised for an unreadable file so callers can fail
closed instead of treating a read race as a missing preference.
@@ -45,19 +77,8 @@ def read_bool(path, key, section=SECTION):
continue
if not in_section or not stripped.startswith(key + ':'):
continue
value = stripped.split(':', 1)[1].strip()
if value[:1] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
value = value[1:end] if end != -1 else value[1:]
else:
value = value.split('#', 1)[0].strip()
value = value.lower()
if value in TRUE_VALUES:
return True
if value in FALSE_VALUES:
return False
return None
value = parse_bool(stripped.split(':', 1)[1])
return value
def mode(path):
@@ -71,7 +92,11 @@ def mode(path):
close = read_bool(path, CLOSE_KEY)
except (OSError, UnicodeError):
return 'none'
return 'close' if close is True else 'hide'
if close is True:
return 'close'
if close is False:
return 'hide'
return 'none'
def main(argv=None):
+40 -13
View File
@@ -39,6 +39,7 @@ GAME_NAME = 'LeagueofLegends'
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
POLL_SECONDS = 2.0
IDLE_SECONDS = 600
TARGET_CLASSES = ('leagueclientux', 'riot client')
@@ -263,35 +264,46 @@ 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):
state_source=game_running, sleep=time.sleep, out=sys.stdout,
clock=time.monotonic):
self.prefix = prefix
self.x11 = x11
self.window_source = window_source
self.state_source = state_source
self.sleep = sleep
self.out = out
self.clock = clock
self.hidden = set()
self.class_cache = {}
self.reported_no_window = False
self.seen = False
self.idle_since = None
def _log(self, message, stream=None):
try:
print(message, file=self.out if stream is None else stream, flush=True)
except Exception:
# A closed launcher pipe must not prevent window restoration.
pass
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)
self._log(f'hide-league-client-in-game: {reason}; showing the client')
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._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: shown 0x{window:x}')
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._log('hide-league-client-in-game: no managed client window; not hiding')
self.reported_no_window = True
for window in windows:
if window in self.hidden:
@@ -301,29 +313,44 @@ class Watcher:
self.x11.unmap(window)
except Exception as error:
self.hidden.discard(window)
print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
file=sys.stderr, flush=True)
self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: hidden 0x{window:x}')
def _windows_present(self):
try:
print(f'hide-league-client-in-game: hidden 0x{window:x}',
file=self.out, flush=True)
return bool(self.window_source(self.prefix, self.class_cache))
except Exception:
# Logging must not lose a successfully unmapped window.
pass
# Uncertainty must not turn into an automatic exit.
return True
def run(self, max_steps=None):
steps = 0
while True:
now = self.clock()
state = self.state_source(self.prefix)
if state is None:
self._show_all('unreadable /proc state')
elif state:
self.seen = True
self.idle_since = None
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
if self._windows_present():
self.seen = True
self.idle_since = None
elif self.seen:
if self.idle_since is None:
self.idle_since = now
if now - self.idle_since >= IDLE_SECONDS:
self._log('hide-league-client-in-game: no client or match for '
f'{IDLE_SECONDS}s: leaving')
return 0
steps += 1
if max_steps is not None and steps >= max_steps:
return 0
+27 -25
View File
@@ -12,31 +12,6 @@ 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 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 &
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.
export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}"
@@ -84,6 +59,33 @@ if [ -z "${RIOT_LAUNCHER_HOST:-}" ] && [ ! -e "$client" ]; then
ln -s "../Users/Shared/Riot Games/Riot Client.app" "$client"
fi
fi
# Opt-in automatic client-in-game handling. Start the watcher only after the
# installed-client check above: a failed installer launch must not leave a
# helper behind. The preference selector returns no action for missing or
# invalid close-during-game values.
if [ -z "${RIOT_LAUNCHER_HOST:-}" ]; then
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 missing/invalid)' >&2
;;
esac
elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/close-league-client-in-game.py" --detach &
elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/hide-league-client-in-game.py" --detach &
fi
fi
A=${RIOT_LAUNCHER_HOST:-"$client/Contents/MacOS/RiotClientServices"}
# External XIM (for example Fcitx) consumes keys before Darling forwards them.
# Use Xlib's local input method; allow explicit testing of another modifier.