fix(launcher): fail closed on automatic mode edge cases

- restrict preference reads to the lol-user-experience section;
- distinguish a failed close-preference read from a missing key;
- reject xprop's 'not found' text as a WM_CLASS value;
- track a window before unmap so an interrupting signal still restores it;
- report the tricks-mode opt-out in the hide helper's startup message;
- cover the new cases with preference, window and interruption tests.
This commit is contained in:
3AvisD committed 2026-10-22 15:07:47 -06:00
1 parent 8de6bceeae
commit 9bcd41e12f
4 files changed
+107 -21

No files matched your search

+22 -4
View File
@@ -51,12 +51,30 @@ class ClientInGameTricksTests(unittest.TestCase):
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')
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: false\n'
' closeLeagueClientDuringGame: false\n'
' potatoModeEnabled: "true"\n'
' closeLeagueClientDuringGame: "true"\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_keys_outside_the_named_section_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true\n'
'other-section:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: true\n')
self.assertEqual(tricks.mode(self.path), 'hide')
def test_second_read_failure_fails_closed(self):
self.write(True, False)
text = self.path.read_text()
with patch.object(Path, 'read_text', side_effect=[text, OSError()]):
self.assertEqual(tricks.mode(self.path), 'none')
def test_main_prints_mode(self):
self.write(True, False)
output = io.StringIO()
+43
View File
@@ -156,6 +156,49 @@ class HideClientInGameTests(unittest.TestCase):
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(classes.call_count, 1)
def test_missing_wm_class_is_not_cached_as_a_class(self):
with patch.object(hide_in_game, 'xprop_value',
return_value='WM_CLASS: not found.'):
self.assertEqual(hide_in_game.window_class('0x10'), '')
def test_unmap_failure_removes_the_window_from_hidden(self):
class FailingX11(FakeX11):
def unmap(self, window):
raise RuntimeError('unmap failed')
watcher = hide_in_game.Watcher(
self.prefix, FailingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, set())
def test_interrupted_unmap_keeps_the_window_tracked(self):
shown = []
class InterruptingX11:
def unmap(self, window):
raise KeyboardInterrupt
def show(self, window):
shown.append(window)
watcher = hide_in_game.Watcher(
self.prefix, InterruptingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
with self.assertRaises(KeyboardInterrupt):
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
watcher.restore()
self.assertEqual(shown, [0xabc])
def test_hides_then_shows_on_match_end(self):
states = iter([True, True, False])
x11 = FakeX11()
+35 -14
View File
@@ -7,32 +7,45 @@ 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/unreadable preferences -> no action (none).
- missing preferences or an unreadable file -> no action (none).
This helper only selects a mode. It does not change preferences or files.
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
does not change preferences or files.
"""
import re
import sys
from pathlib import Path
SECTION = 'lol-user-experience'
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
def read_bool(path, key, section=SECTION):
"""Return True/False for the last key in section, or None when absent.
OSError/UnicodeError is raised for an unreadable file so callers can fail
closed instead of treating a read race as a missing preference.
"""
lines = Path(path).read_text().splitlines()
in_section = False
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()
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if not line.startswith((' ', '\t')):
if stripped == section + ':':
in_section = True
elif in_section:
break
continue
if not in_section or not stripped.startswith(key + ':'):
continue
value = stripped.split(':', 1)[1].strip().strip('"\'').lower()
if value in TRUE_VALUES:
return True
if value in FALSE_VALUES:
@@ -41,9 +54,17 @@ def read_bool(path, key):
def mode(path):
if read_bool(path, OPTIMIZATION_KEY) is not True:
try:
optimization = read_bool(path, OPTIMIZATION_KEY)
except (OSError, UnicodeError):
return 'none'
return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide'
if optimization is not True:
return 'none'
try:
close = read_bool(path, CLOSE_KEY)
except (OSError, UnicodeError):
return 'none'
return 'close' if close is True else 'hide'
def main(argv=None):
+7 -3
View File
@@ -129,7 +129,8 @@ def xprop_value(window, prop):
def window_class(window):
return xprop_value(window, 'WM_CLASS').lower()
value = xprop_value(window, 'WM_CLASS').lower()
return value if '=' in value else ''
def window_pid(window):
@@ -295,11 +296,12 @@ class Watcher:
for window in windows:
if window in self.hidden:
continue
self.hidden.add(window)
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:
self.hidden.discard(window)
print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
file=sys.stderr, flush=True)
@@ -372,8 +374,10 @@ def main(argv=None):
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(signum, stop)
optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks
else 'RCLOL_HIDE_CLIENT_IN_GAME=0')
print('hide-league-client-in-game: watching %s for a running match; '
'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True)
'opt out with %s' % (prefix, optout), flush=True)
try:
watcher.run()
except KeyboardInterrupt: