Return to the launcher when a front-end game closes

A game started from the Games tab or another launcher's link left the
player in Big Picture when it closed. The session now watches for it:
once the game has started and stayed gone for 10 s (launchers restart
games), the app writes the session script's steam-stop request, so the
client quits cleanly with its cloud sync finished, the session ends on
its own and the activity returns to where the launch came from (the
front end, or Android Home for external launchers, per #300).

Covers fresh sessions started with a steam://rungameid/ URL and games
queued into a running client. A game is running while a command line
holds the client's reaper (SteamLaunch AppId=) or Proton's
waitforexitandrun. Gives up if the game never starts within 20 min.

New Steam setting "Return to DroidDeck after a game", on by default,
with strings for all 9 locales and a Device report line.
This commit is contained in:
The412Banner committed 2026-10-07 10:28:17 -04:00
1 parent 453f6f5180
commit 799ecfb267
14 files changed
+114

No files matched your search

@@ -119,6 +119,7 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var returnAfterGame by mutableStateOf(true)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
@@ -1199,6 +1200,7 @@ class MainActivity : ComponentActivity() {
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1288,6 +1290,7 @@ class MainActivity : ComponentActivity() {
steamChannel = SessionPrefs.steamChannel(this)
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
@@ -1403,6 +1406,7 @@ class MainActivity : ComponentActivity() {
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
returnAfterGame = SessionPrefs.returnAfterGame(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
@@ -167,6 +167,7 @@ object DeviceReport {
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Return to launcher after a game", SessionPrefs.returnAfterGame(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
k("On-screen controls", SessionPrefs.oscMode(context))
@@ -170,6 +170,16 @@ object SessionPrefs {
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
}
/**
* After a game launched from the front end (or another launcher's link) closes, the client
* exits cleanly and the session returns there, instead of staying in Big Picture.
*/
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
fun setReturnAfterGame(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("returnAfterGame", on).apply()
}
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
@@ -97,6 +97,8 @@ class SessionService : Service() {
/** One validated game request waiting for this Steam session to reach a usable state. */
private var pendingSteamGameId: String? = null
private var pendingSteamGameGeneration = -1
// Bumped to cancel a running game-exit watch (a new launch, or the session ending).
@Volatile private var gameExitWatchToken = 0
private val flushSteamGame = object : Runnable {
override fun run() = flushQueuedSteamGame()
}
@@ -254,6 +256,8 @@ class SessionService : Service() {
val gen = ++sessionGen
cancelQueuedSteamGame(clearMailbox = false)
clearSteamGameMailbox()
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl
?.takeIf { it.startsWith(GAME_URL_PREFIX) }?.let { watchGameExit(it.removePrefix(GAME_URL_PREFIX), gen) }
acquireLocks()
Thread({
// A tree the last session left behind (the app was killed or crashed, so its teardown
@@ -1071,9 +1075,71 @@ class SessionService : Service() {
pendingSteamGameId = null
pendingSteamGameGeneration = -1
SessionEvents.record("steam.game_launch_requested", mapOf("gameId" to gameId, "generation" to gen))
watchGameExit(gameId, gen)
Log.i(TAG, "queued Steam game $gameId for the running client")
}
/**
* A game launched from the front end (the Games tab, or another launcher's link): once it has
* started and then been gone for [GAME_EXIT_GRACE_MS], ask the client to exit through the
* session script's steam-stop request. The client quits cleanly, finishing its cloud sync, so
* the session ends on its own and the activity returns to where the launch came from instead
* of leaving the player in Big Picture. The grace covers launchers that restart the game.
*/
private fun watchGameExit(gameId: String, gen: Int) {
if (!SessionPrefs.returnAfterGame(this)) return
val token = ++gameExitWatchToken
Thread({
val startBy = android.os.SystemClock.elapsedRealtime() + GAME_START_TIMEOUT_MS
var seen = false
var goneSince = 0L
while (token == gameExitWatchToken && gen == sessionGen && SessionState.running && !SessionState.stopRequested) {
val now = android.os.SystemClock.elapsedRealtime()
if (steamGameRunning()) {
if (!seen) Log.i(TAG, "game $gameId started; returning to the launcher when it closes")
seen = true
goneSince = 0L
} else if (!seen) {
// Never started (an install, an update or a failed launch): leave the session be.
if (now > startBy) {
Log.i(TAG, "game $gameId did not start; not watching for its exit")
return@Thread
}
} else if (goneSince == 0L) {
goneSince = now
} else if (now - goneSince >= GAME_EXIT_GRACE_MS) {
mainHandler.post { returnAfterGame(gameId, gen, token) }
return@Thread
}
try { Thread.sleep(GAME_EXIT_POLL_MS) } catch (e: InterruptedException) { return@Thread }
}
}, "game-exit-watch").start()
}
private fun returnAfterGame(gameId: String, gen: Int, token: Int) {
if (token != gameExitWatchToken || gen != sessionGen || !SessionState.running ||
SessionState.stopRequested || SessionState.mode != MODE_STEAM || !SessionPrefs.returnAfterGame(this)) return
val written = runCatching { File(LinuxRuntime.sessionRoot(this), "steam-stop").writeText("") }
.onFailure { Log.w(TAG, "could not ask the client to exit after game $gameId", it) }.isSuccess
if (!written) return
SessionEvents.record("steam.return_after_game", mapOf("gameId" to gameId))
Log.i(TAG, "game $gameId closed: asking the client to exit and returning to the launcher")
}
/** A Steam game is running: the client runs each one under its reaper, and Proton's script
* waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */
private fun steamGameRunning(): Boolean {
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (text.contains("SteamLaunch AppId=") || text.contains("waitforexitandrun")) return true
}
return false
}
private fun resumeSession() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
@@ -1441,6 +1507,7 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
gameExitWatchToken++
completeNativeSleep()
synchronized(stopLock) {
if (!SessionState.running) return
@@ -1728,6 +1795,12 @@ class SessionService : Service() {
private const val GAME_LAUNCH_RETRY_MS = 250L
private const val DOWNLOAD_POLL_MS = 2_000L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val GAME_URL_PREFIX = "steam://rungameid/"
/** How long a front-end launch may take to start (downloads, first-run installs) before the exit watch gives up. */
private const val GAME_START_TIMEOUT_MS = 20 * 60_000L
/** How long the game must stay gone before the client is asked to exit: launchers restart games. */
private const val GAME_EXIT_GRACE_MS = 10_000L
private const val GAME_EXIT_POLL_MS = 2_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
@@ -92,6 +92,8 @@ class ModeSettings(
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
/** Steam only: a game launched from the front end returns there when it closes; null hides the row. */
val returnAfterGame: Boolean? = null,
val steamRepairQueued: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
val mangoapp: Boolean = true,
@@ -154,6 +156,7 @@ class ModeSettingsActions(
val onSteamRepair: () -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onReturnAfterGame: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onWifiDiscovery: (Boolean) -> Unit = {},
@@ -399,6 +402,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
stringResource(R.string.mode_mangoapp_hint),
s.mangoapp, onChange = a.onMangoapp,
)
if (s.returnAfterGame != null) ToggleRow(
host, "returnaftergame", stringResource(R.string.mode_return_after_game),
stringResource(R.string.mode_return_after_game_hint),
s.returnAfterGame, onChange = a.onReturnAfterGame,
)
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
else ChoiceRow(
+2
View File
@@ -289,6 +289,8 @@
<string name="mode_deck_mode_hint">La interfaz Deck de Steam y los controles de rendimiento de Quick Access. Se aplica en la próxima sesión.</string>
<string name="mode_mangoapp">Overlay de rendimiento</string>
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
<string name="mode_return_after_game">Volver a DroidDeck al cerrar un juego</string>
<string name="mode_return_after_game_hint">Cuando se cierra un juego que iniciaste desde DroidDeck u otro lanzador, Steam se cierra correctamente (primero termina de sincronizar las partidas en la nube) y vuelves allí. Desactivado: te quedas en Steam.</string>
<string name="mode_branch">Rama del cliente</string>
<string name="mode_branch_deck">Beta pública de Steam Deck (requerida por el modo Deck).</string>
<string name="mode_branch_hint">Se aplica en la próxima sesión. Steam puede actualizarse una vez.</string>
+2
View File
@@ -263,6 +263,8 @@
<string name="mode_deck_mode_hint">Interface Steam Deck et contrôles de performance à accès rapide. Prendra effet à la prochaine session.</string>
<string name="mode_mangoapp">Superposition de performance</string>
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
<string name="mode_return_after_game">Revenir à DroidDeck après un jeu</string>
<string name="mode_return_after_game_hint">Quand un jeu lancé depuis DroidDeck ou un autre lanceur se ferme, Steam se ferme proprement (après la synchronisation des sauvegardes cloud) et vous y revenez. Désactivé : vous restez dans Steam.</string>
<string name="mode_branch">Branche du client</string>
<string name="mode_branch_deck">Bêta Steam Deck (requis pour le mode Deck).</string>
<string name="mode_branch_hint">Prendra effet à la prochaine session. Steam peut se mettre à jour une fois de lui-même.</string>
+2
View File
@@ -289,6 +289,8 @@
<string name="mode_deck_mode_hint">Steam DeckのUIとクイックアクセスパフォーマンスコントロール。次回のセッションで有効になります。</string>
<string name="mode_mangoapp">パフォーマンスオーバーレイ</string>
<string name="mode_mangoapp_hint">Deckモードでゲームがクラッシュしたり画面が真っ暗になる場合は、これをオフにしてください。次回のセッションで有効になります。</string>
<string name="mode_return_after_game">ゲーム終了後にDroidDeckへ戻る</string>
<string name="mode_return_after_game_hint">DroidDeckや他のランチャーから起動したゲームを閉じると、Steamが正しく終了し(クラウドセーブの同期を先に完了)、起動元に戻ります。オフ:Steamに留まります。</string>
<string name="mode_branch">クライアントブランチ</string>
<string name="mode_branch_deck">Steam Deckベータ版(Deckモードに必要)。</string>
<string name="mode_branch_hint">次回のセッションで有効になります。Steamが自動的に一度アップデートされる場合があります。</string>
+2
View File
@@ -289,6 +289,8 @@
<string name="mode_deck_mode_hint">Steam Deck UI 및 빠른 액세스 성능 제어. 다음 세션에 적용됩니다.</string>
<string name="mode_mangoapp">성능 오버레이</string>
<string name="mode_mangoapp_hint">Deck 모드에서 게임이 충돌하거나 화면이 검게 변하면 이 옵션을 끄세요. 다음 세션에 적용됩니다.</string>
<string name="mode_return_after_game">게임 종료 후 DroidDeck으로 돌아가기</string>
<string name="mode_return_after_game_hint">DroidDeck 또는 다른 런처에서 실행한 게임을 닫으면 Steam이 정상적으로 종료되고(클라우드 저장 동기화를 먼저 완료) 실행한 곳으로 돌아갑니다. 끄면 Steam에 머무릅니다.</string>
<string name="mode_branch">클라이언트 브랜치</string>
<string name="mode_branch_deck">Steam Deck 베타(Deck 모드에 필요).</string>
<string name="mode_branch_hint">다음 세션에 적용됩니다. Steam이 자체적으로 한 번 업데이트될 수 있습니다.</string>
+2
View File
@@ -263,6 +263,8 @@
<string name="mode_deck_mode_hint">Интерфейс Steam Deck и управление производительностью через быстрый доступ. Вступит в силу в следующей сессии.</string>
<string name="mode_mangoapp">Оверлей производительности</string>
<string name="mode_mangoapp_hint">Отключите, если игры падают или появляется черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
<string name="mode_return_after_game">Возвращаться в DroidDeck после игры</string>
<string name="mode_return_after_game_hint">Когда игра, запущенная из DroidDeck или другого лаунчера, закрывается, Steam корректно завершает работу (сначала синхронизирует облачные сохранения), и вы возвращаетесь туда. Выкл.: остаётесь в Steam.</string>
<string name="mode_branch">Ветка клиента</string>
<string name="mode_branch_deck">Бета-версия Steam Deck (требуется для режима Deck).</string>
<string name="mode_branch_hint">Вступит в силу в следующей сессии. Steam может обновиться самостоятельно один раз.</string>
@@ -289,6 +289,8 @@
<string name="mode_deck_mode_hint">Steam 的 Deck 界面和快捷访问性能控制。将在下次会话生效。</string>
<string name="mode_mangoapp">性能覆盖层</string>
<string name="mode_mangoapp_hint">若游戏在 Deck 模式下崩溃或黑屏,请关闭性能覆盖层。将在下次会话生效。</string>
<string name="mode_return_after_game">游戏结束后返回 DroidDeck</string>
<string name="mode_return_after_game_hint">从 DroidDeck 或其他启动器启动的游戏关闭后,Steam 会正常退出(先完成云存档同步),并返回启动位置。关闭:留在 Steam 中。</string>
<string name="mode_branch">客户端分支</string>
<string name="mode_branch_deck">Steam Deck 公开测试版(Deck 模式所必需)。</string>
<string name="mode_branch_hint">将在下次会话生效。Steam 可能会自行更新一次。</string>
@@ -289,6 +289,8 @@
<string name="mode_deck_mode_hint">Steam 的 Deck 介面及快速存取效能控制。下次會話生效。</string>
<string name="mode_mangoapp">效能疊加層</string>
<string name="mode_mangoapp_hint">如果在 Deck 模式下遊戲當機或黑畫面,請關閉此項。下次會話生效。</string>
<string name="mode_return_after_game">遊戲結束後返回 DroidDeck</string>
<string name="mode_return_after_game_hint">從 DroidDeck 或其他啟動器啟動的遊戲關閉後,Steam 會正常退出(先完成雲端存檔同步),並返回啟動位置。關閉:留在 Steam 中。</string>
<string name="mode_branch">用戶端分支</string>
<string name="mode_branch_deck">Steam Deck 公開測試版(Deck 模式需要)。</string>
<string name="mode_branch_hint">下次會話生效。Steam 可能會自行更新一次。</string>
@@ -289,6 +289,8 @@
<string name="mode_deck_mode_hint">Steam Deck 介面和快速存取效能控制。下次工作階段生效。</string>
<string name="mode_mangoapp">效能重疊層</string>
<string name="mode_mangoapp_hint">如果 Deck 模式下遊戲崩潰或黑畫面,請關閉此項。下次工作階段生效。</string>
<string name="mode_return_after_game">遊戲結束後返回 DroidDeck</string>
<string name="mode_return_after_game_hint">從 DroidDeck 或其他啟動器啟動的遊戲關閉後,Steam 會正常退出(先完成雲端存檔同步),並返回啟動位置。關閉:留在 Steam 中。</string>
<string name="mode_branch">用戶端分支</string>
<string name="mode_branch_deck">Steam Deck 公開測試版(Deck 模式所需)。</string>
<string name="mode_branch_hint">下次工作階段生效。Steam 可能會自行更新一次。</string>
+2
View File
@@ -293,6 +293,8 @@
<string name="mode_deck_mode_hint">Steam\'s Deck interface and Quick Access performance controls. Applies next session.</string>
<string name="mode_mangoapp">Performance overlay</string>
<string name="mode_mangoapp_hint">Turn off if games crash or go black in Deck mode. Applies next session.</string>
<string name="mode_return_after_game">Return to DroidDeck after a game</string>
<string name="mode_return_after_game_hint">When a game you started from DroidDeck or another launcher closes, Steam exits cleanly (cloud saves finish first) and you go back there. Off: stay in Steam.</string>
<string name="mode_branch">Client branch</string>
<string name="mode_branch_deck">Steam Deck public beta (required by Deck mode).</string>
<string name="mode_branch_hint">Applies next session. Steam may update itself once.</string>