diff --git a/README.md b/README.md index 51a3cf4..689f53f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/SETUP.md b/docs/SETUP.md index 2679892..548a02f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0c37ba5..7869d27 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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 diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 53a0db3..752435b 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -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') diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index f03e8c1..867f2f3 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -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): diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index 1b7a314..2d4330c 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -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): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 22b9ca2..f9135af 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -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 - try: - print(f'hide-league-client-in-game: hidden 0x{window:x}', - file=self.out, flush=True) - except Exception: - # Logging must not lose a successfully unmapped window. - pass + self._log(f'hide-league-client-in-game: hidden 0x{window:x}') + + def _windows_present(self): + try: + return bool(self.window_source(self.prefix, self.class_cache)) + except Exception: + # 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 diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 7d76a6d..611c810 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -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.