From 6bcd431ca6e05f74c028d4d5aacfbcc3e5ee3267 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Mon, 9 Nov 2026 18:23:24 -0500 Subject: [PATCH] fix(session): wait for Steam readiness before launch (#2362) * fix(session): wait for Steam readiness before launch * fix(session): keep the launch fallback when Steam readiness or the interstitial is unavailable * fix(session): cap the Steam readiness wait by elapsed time and bound each check * fix(session): keep waiting for Steam readiness through focus changes and never pass a zero timeout --- .../overlay/usr/local/bin/droiddeck-agent | 40 +++++++++++++++++++ .../overlay/usr/local/bin/droiddeck-session | 13 +++++- tools/tests/test_droiddeck_agent.py | 20 ++++++++++ tools/tests/test_steam_cold_launch.py | 38 ++++++++++++------ 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 234be2f00..5ae8031e6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -927,6 +927,33 @@ def steam_shortcut_state(agent_dir, game_id, timeout=6): time.sleep(min(0.5, max(0, deadline - time.monotonic()))) +def steam_ready(agent_dir): + expression = """(() => { + const store = globalThis.appStore; + return !!store?.m_bIsInitialized && typeof globalThis.SteamClient?.Apps?.ContinueGameAction === 'function'; + })()""" + return cdp_eval(agent_dir, expression, await_promise=False, timeout=3).get("value") is True + + +def steam_clear_pending_interstitial(agent_dir, app_id): + if not isinstance(app_id, str) or not app_id.isascii() or not app_id.isdigit() or int(app_id) <= 0: + raise AgentError("INVALID_APP_ID", "appId must be a positive decimal Steam app id") + expression = """(async () => { + const apps = globalThis.SteamClient?.Apps; + if (typeof apps?.GetActiveGameActions !== 'function' || typeof apps.ContinueGameAction !== 'function') return false; + const active = await apps.GetActiveGameActions() || []; + for (const action of active) { + const appid = String(action?.strAppID ?? action?.gameid ?? action?.nAppID ?? ''); + if (appid === %s && action?.strTaskName === 'ShowInterstitials') { + apps.ContinueGameAction(action.nGameActionID, appid, 'ShowInterstitials'); + return true; + } + } + return false; + })()""" % json.dumps(app_id) + return cdp_eval(agent_dir, expression, timeout=3).get("value") is True + + def game_pids(app_id): wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()] me = os.getpid() @@ -1261,6 +1288,19 @@ def main(argv): return 1 print(state) return {"ready": 0, "missing": 4, "not-shortcut": 3}.get(state, 1) + if argv and argv[0] == "--steam-ready": + try: + return 0 if steam_ready(Path(base) / "agent") else 1 + except (AgentError, OSError, ValueError): + return 1 + if argv and argv[0] == "--steam-clear-pending-interstitial": + if len(argv) != 2: + print("usage: droiddeck-agent --steam-clear-pending-interstitial ", file=sys.stderr) + return 2 + try: + return 0 if steam_clear_pending_interstitial(Path(base) / "agent", argv[1]) else 1 + except (AgentError, OSError, ValueError): + return 1 serve(Path(base) / "agent", focus="--no-focus" not in argv) return 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 907364572..df301aada 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -1244,7 +1244,14 @@ case $mode in sleep 1 done [ "${#game}" -gt 10 ] && shortcut=1 - [ -n "$shortcut" ] || sleep 4 + _bl_ready_end=$((SECONDS + 30)) + while [ "$waited" -lt 30 ] && [ "$((_bl_ready_end - SECONDS))" -gt 0 ]; do + timeout "$((_bl_ready_end - SECONDS))" /usr/local/bin/droiddeck-agent --steam-ready >/dev/null 2>&1 && break + [ "$((_bl_ready_end - SECONDS))" -gt 1 ] || break + sleep 1 + waited=$((waited + 1)) + done + waited=0 echo "== steam: interface up; launching the requested game $game" _bl_hand_off [ -n "$shortcut" ] || return 0 @@ -1264,7 +1271,9 @@ case $mode in elif _bl_starting; then echo "== steam: the requested game started while waiting; not launching again" elif [ "$(_bl_focused)" = 769 ]; then - echo "== steam: launching the requested game again $game (overview status $status)" + echo "== steam: clearing the pending launch interstitial for $game (overview status $status)" + /usr/local/bin/droiddeck-agent --steam-clear-pending-interstitial "$(((game >> 32) & 0xffffffff))" >/dev/null 2>&1 && return 0 + echo "== steam: launching the requested game again $game" _bl_hand_off else echo "== steam: the game took focus while waiting; not launching again" diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index 02e53f960..a1adca90c 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -457,6 +457,26 @@ class AgentTest(unittest.TestCase): self.assertEqual(1, agent.main(["--steam-shortcut-state", game_id])) self.assertEqual(2, agent.main(["--steam-shortcut-state"])) + def test_steam_ready_uses_steams_initialized_store(self): + with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate: + self.assertTrue(agent.steam_ready(self.dir)) + self.assertIn("m_bIsInitialized", evaluate.call_args.args[1]) + with patch.object(agent, "cdp_eval", return_value={"value": False}): + self.assertFalse(agent.steam_ready(self.dir)) + + def test_pending_interstitial_is_continued_by_steam(self): + with patch.object(agent, "cdp_eval", return_value={"value": True}) as evaluate: + self.assertTrue(agent.steam_clear_pending_interstitial(self.dir, "3551182975")) + expression = evaluate.call_args.args[1] + self.assertIn("ContinueGameAction", expression) + self.assertIn("ShowInterstitials", expression) + self.assertIn('"3551182975"', expression) + self.assertIn("await apps.GetActiveGameActions()", expression) + with patch.object(agent, "cdp_eval", return_value={"value": False}): + self.assertFalse(agent.steam_clear_pending_interstitial(self.dir, "3551182975")) + with self.assertRaises(agent.AgentError): + agent.steam_clear_pending_interstitial(self.dir, "0") + def test_asleep_command_reports_steams_own_sleep_state(self): with patch.dict(os.environ, {"BL_LAUNCH_DIR": str(self.dir.parent)}): for value, code in (("yes", 0), ("no", 5), ("unknown", 1), (None, 1)): diff --git a/tools/tests/test_steam_cold_launch.py b/tools/tests/test_steam_cold_launch.py index 1695ad0e1..0fe085ff9 100644 --- a/tools/tests/test_steam_cold_launch.py +++ b/tools/tests/test_steam_cold_launch.py @@ -41,6 +41,7 @@ class SteamColdLaunchTest(unittest.TestCase): "#!/bin/sh\n" "printf '%s\n' \"$@\" > \"$AGENT_ARGS\"\n" "n=$(cat \"$AGENT_COUNT\" 2>/dev/null || echo 0); n=$((n + 1)); echo $n > \"$AGENT_COUNT\"\n" + "[ \"$1\" = --steam-ready ] && exit \"${READY_STATUS:-0}\"\n" "exit \"$AGENT_STATUS\"\n") agent.chmod(0o755) sleep = self.bin / "sleep" @@ -55,12 +56,13 @@ class SteamColdLaunchTest(unittest.TestCase): pgrep.chmod(0o755) self.block = LAUNCH_BLOCK.replace("/usr/local/bin/droiddeck-agent", str(agent)) - def run_launch(self, target, focus="769", helper_status=0, running_after=999): + def run_launch(self, target, focus="769", helper_status=0, running_after=999, ready_status=0): env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch_dir), PATH=str(self.bin) + os.pathsep + os.environ["PATH"], AGENT_ARGS=str(self.agent_args), SLEEP_ARGS=str(self.sleep_args), AGENT_COUNT=str(self.agent_count), AGENT_STATUS=str(helper_status), - FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after)) + FOCUS=focus, FOCUS_COUNT=str(self.focus_count), RUNNING_AFTER=str(running_after), + READY_STATUS=str(ready_status)) script = self.block + "\nprintf 'ARG:%s\n' \"$@\"\nwait\n" url = target if target.startswith("steam://") else "steam://rungameid/" + target result = subprocess.run(["bash", "-c", script, "cold-launch", url], @@ -72,28 +74,40 @@ class SteamColdLaunchTest(unittest.TestCase): return [l for l in result.stdout.splitlines() if "launching the requested game" in l] def test_a_shortcut_that_takes_focus_is_handed_over_once_without_waiting_for_the_overview(self): - result = self.run_launch(SHORTCUT, focus="769 769 4242") + result = self.run_launch(SHORTCUT, focus="769 4242") self.assertEqual(SHORTCUT, (self.launch_dir / "steam-game").read_text().strip()) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertFalse(self.agent_args.exists(), result.stdout) - self.assertEqual(["1"], self.sleep_args.read_text().split()) + self.assertEqual(["--steam-ready"], self.agent_args.read_text().splitlines()) + self.assertFalse(self.sleep_args.exists()) def test_a_shortcut_that_never_takes_focus_is_handed_over_again_after_the_overview_wait(self): result = self.run_launch(SHORTCUT, focus="769", helper_status=0) - self.assertEqual(2, len(self.hand_offs(result)), result.stdout) - self.assertIn("launching the requested game again", result.stdout) - self.assertEqual(["--wait-steam-shortcut-overview", SHORTCUT, "45"], self.agent_args.read_text().splitlines()) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertIn("clearing the pending launch interstitial", result.stdout) + self.assertEqual(["--steam-clear-pending-interstitial", "3551182975"], self.agent_args.read_text().splitlines()) self.assertEqual(["1"] * 20, self.sleep_args.read_text().split()) def test_a_timed_out_overview_still_gets_one_more_hand_off(self): result = self.run_launch(SHORTCUT, focus="769", helper_status=1) self.assertEqual(2, len(self.hand_offs(result)), result.stdout) self.assertIn("overview status 1", result.stdout) + self.assertIn("launching the requested game again", result.stdout) + + def test_a_steam_that_never_reports_ready_still_gets_the_launch(self): + result = self.run_launch("413150", ready_status=1) + self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip()) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertEqual(["1"] * 30, self.sleep_args.read_text().split()) + + def test_a_focus_change_does_not_end_the_readiness_wait(self): + result = self.run_launch("413150", focus="769 4242", ready_status=1) + self.assertEqual(1, len(self.hand_offs(result)), result.stdout) + self.assertEqual(["1"] * 30, self.sleep_args.read_text().split()) def test_a_game_still_starting_without_a_window_is_not_launched_twice(self): result = self.run_launch(SHORTCUT, running_after=1) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertFalse(self.agent_args.exists()) + self.assertIn("--steam-ready", self.agent_args.read_text()) self.assertEqual(["-f", "reaper SteamLaunch AppId=3551182975( |$)"], (self.root / "pgrep-args").read_text().splitlines()) self.assertIn("already starting", result.stdout) @@ -108,15 +122,15 @@ class SteamColdLaunchTest(unittest.TestCase): focus = " ".join(["769"] * 23) + " 4242" result = self.run_launch(SHORTCUT, focus=focus, helper_status=0) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertIn("took focus while waiting", result.stdout) + self.assertNotIn("clearing the pending launch interstitial", result.stdout) self.assertNotIn("launching the requested game again", result.stdout) def test_native_app_id_keeps_the_short_delay_and_is_handed_over_once(self): result = self.run_launch("413150") self.assertEqual("413150", (self.launch_dir / "steam-game").read_text().strip()) self.assertEqual(1, len(self.hand_offs(result)), result.stdout) - self.assertEqual("4", self.sleep_args.read_text().strip()) - self.assertFalse(self.agent_args.exists()) + self.assertFalse(self.sleep_args.exists()) + self.assertIn("--steam-ready", self.agent_args.read_text()) def test_a_restart_for_a_game_uses_the_same_hand_off(self): loop = SOURCE[SOURCE.index(' if [ -s "$HOME/.bl-restart-game" ]; then'):SOURCE.index(' if [ -f "$HOME/.bl-restart-once" ]; then')]