From 203a6df6eb9700ccb32a8272fc8e5cd9c5007e25 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:08:00 -0500 Subject: [PATCH] feat: add per-game Steam Input override --- .../launcher/frontend/SteamInputOverride.kt | 39 +++++++++ .../launcher/frontend/SteamLaunchOptions.kt | 78 +++++++++++++++--- .../droiddeck/launcher/ui/AddedGameEditor.kt | 81 +++++++++++++++++++ .../com/droiddeck/launcher/ui/GameDetail.kt | 1 + app/src/main/res/values/strings.xml | 8 ++ 5 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/SteamInputOverride.kt diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamInputOverride.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamInputOverride.kt new file mode 100644 index 000000000..216bed89e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamInputOverride.kt @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.frontend + +import android.content.Context +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionState + +object SteamInputOverride { + private const val CONFIG_KEY = "UseSteamControllerConfig" + private const val DEFAULT_VALUE = "1" + + enum class Mode(val value: String?) { + DEFAULT(null), ENABLED("2"), DISABLED("0"); + + companion object { + fun from(value: String): Mode? = when (value) { + DEFAULT_VALUE -> DEFAULT + "2" -> ENABLED + "0" -> DISABLED + else -> null + } + } + } + + enum class Saved { YES, NO, RUNNING } + + fun current(context: Context, appId: Int): Mode? = + SteamLaunchOptions.steamAppSetting( + SessionPrefs.activeSteamRoot(context), appId, CONFIG_KEY, DEFAULT_VALUE, + )?.let(Mode::from) + + fun set(context: Context, appId: Int, mode: Mode, shown: Mode): Saved { + if (SessionState.running) return Saved.RUNNING + val saved = SteamLaunchOptions.writeSteamAppSetting( + SessionPrefs.activeSteamRoot(context), appId, CONFIG_KEY, + shown.value ?: DEFAULT_VALUE, mode.value, DEFAULT_VALUE, + ) + return if (saved) Saved.YES else Saved.NO + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamLaunchOptions.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamLaunchOptions.kt index 9a8137d35..ff1beb3ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamLaunchOptions.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamLaunchOptions.kt @@ -36,13 +36,35 @@ object SteamLaunchOptions { val config = AddedGames.accountConfig(steamHome) ?: return null val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null if (section(text, STEAM) == null) return null - return launchOptions(text, appId).orEmpty() + return appValue(text, appId, "LaunchOptions").orEmpty() } internal fun write(steamHome: File, appId: Int, options: String): Boolean { val config = AddedGames.accountConfig(steamHome)?.let { File(it, "localconfig.vdf") } ?: return false val text = runCatching { config.readText() }.getOrNull() ?: return false val updated = withLaunchOptions(text, appId, options) ?: return false + return writeConfig(config, text, updated) + } + + internal fun steamAppSetting(steamHome: File, appId: Int, key: String, default: String): String? { + val config = AddedGames.accountConfig(steamHome) ?: return null + val text = runCatching { File(config, "localconfig.vdf").readText() }.getOrNull() ?: return null + if (section(text, STEAM) == null) return null + return appValue(text, appId, key) ?: default + } + + internal fun writeSteamAppSetting( + steamHome: File, appId: Int, key: String, expected: String, value: String?, default: String, + ): Boolean { + val config = AddedGames.accountConfig(steamHome)?.let { File(it, "localconfig.vdf") } ?: return false + val text = runCatching { config.readText() }.getOrNull() ?: return false + if (section(text, STEAM) == null || (appValue(text, appId, key) ?: default) != expected) return false + val updated = (if (value == null) withoutAppValue(text, appId, key) else withAppValue(text, appId, key, value)) + ?: return false + return writeConfig(config, text, updated) + } + + private fun writeConfig(config: File, text: String, updated: String): Boolean { if (updated == text) return true val staged = File(config.parentFile, config.name + ".staged") return try { @@ -55,36 +77,57 @@ object SteamLaunchOptions { true } catch (e: Exception) { staged.delete() - Log.w(TAG, "could not write launch options to ${config.path}", e) + Log.w(TAG, "could not write Steam app settings to ${config.path}", e) false } } internal fun launchOptions(text: String, appId: Int): String? { + return appValue(text, appId, "LaunchOptions") + } + + private fun appValue(text: String, appId: Int, key: String): String? { val app = section(text, STEAM + "apps" + appId.toString()) ?: return null - return entries(text, app.valueStart, app.end).firstOrNull { !it.section && it.key.equals("LaunchOptions", true) } + return entries(text, app.valueStart, app.end).firstOrNull { !it.section && it.key.equals(key, true) } ?.let { unescape(text.substring(it.valueStart + 1, it.end)) } } - internal fun withLaunchOptions(text: String, appId: Int, options: String): String? { + internal fun withLaunchOptions(text: String, appId: Int, options: String): String? = + withAppValue(text, appId, "LaunchOptions", options) + + private fun withAppValue(text: String, appId: Int, key: String, rawValue: String): String? { val newline = if (text.contains("\r\n")) "\r\n" else "\n" var updated = text var path = STEAM - for (key in listOf("apps", appId.toString())) { - updated = withSection(updated, section(updated, path) ?: return null, key, newline) ?: return null - path = path + key + for (pathPart in listOf("apps", appId.toString())) { + updated = withSection(updated, section(updated, path) ?: return null, pathPart, newline) ?: return null + path = path + pathPart } val app = section(updated, path) ?: return null - val value = "\"" + escape(options) + "\"" - entries(updated, app.valueStart, app.end).firstOrNull { !it.section && it.key.equals("LaunchOptions", true) }?.let { + val value = "\"" + escape(rawValue) + "\"" + entries(updated, app.valueStart, app.end).firstOrNull { !it.section && it.key.equals(key, true) }?.let { return updated.replaceRange(it.valueStart, it.end + 1, value) } val lineStart = closingLine(updated, app) ?: return null val indent = updated.substring(lineStart, app.end) + "\t" - return updated.substring(0, lineStart) + indent + "\"LaunchOptions\"\t\t" + value + newline + updated.substring(lineStart) + return updated.substring(0, lineStart) + indent + "\"${escape(key)}\"\t\t" + value + newline + updated.substring(lineStart) } - private class Entry(val key: String, val valueStart: Int, val end: Int, val section: Boolean) + private fun withoutAppValue(text: String, appId: Int, key: String): String { + val app = section(text, STEAM + "apps" + appId.toString()) ?: return text + val entry = entries(text, app.valueStart, app.end).firstOrNull { !it.section && it.key.equals(key, true) } ?: return text + val lineStart = text.lastIndexOf('\n', entry.start - 1) + 1 + val lineEnd = text.indexOf('\n', entry.end + 1).let { if (it < 0) text.length else it } + val onlyEntryOnLine = text.substring(lineStart, entry.start).all(Char::isWhitespace) && + text.substring(entry.end + 1, lineEnd).all(Char::isWhitespace) + return if (onlyEntryOnLine) { + text.removeRange(lineStart, if (lineEnd < text.length) lineEnd + 1 else lineEnd) + } else { + text.removeRange(entry.start, entry.end + 1) + } + } + + private class Entry(val key: String, val start: Int, val valueStart: Int, val end: Int, val section: Boolean) private fun section(text: String, path: List): Entry? { var found: Entry? = null @@ -98,18 +141,27 @@ object SteamLaunchOptions { private fun entries(text: String, open: Int, close: Int): List { val found = ArrayList() var key: String? = null + var keyStart = -1 var i = open + 1 while (i < close) { when { text[i] == '"' -> { val end = stringEnd(text, i) ?: return found - if (key == null) key = unescape(text.substring(i + 1, end)) else { found += Entry(key, i, end, false); key = null } + if (key == null) { + key = unescape(text.substring(i + 1, end)) + keyStart = i + } else { + found += Entry(key, keyStart, i, end, false) + key = null + keyStart = -1 + } i = end + 1 } text[i] == '{' -> { val end = sectionEnd(text, i) ?: return found - key?.let { found += Entry(it, i, end, true) } + key?.let { found += Entry(it, keyStart, i, end, true) } key = null + keyStart = -1 i = end + 1 } text.startsWith("//", i) -> i = text.indexOf('\n', i).let { if (it < 0) close else it } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt index 89e148086..215043dea 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddedGameEditor.kt @@ -72,8 +72,10 @@ import com.droiddeck.launcher.frontend.AddedGames import com.droiddeck.launcher.frontend.LogoPosition import com.droiddeck.launcher.frontend.SteamGameArt import com.droiddeck.launcher.frontend.SteamGridDb +import com.droiddeck.launcher.frontend.SteamInputOverride import com.droiddeck.launcher.frontend.SteamLaunchOptions import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -319,6 +321,85 @@ internal fun SteamLaunchOptionsPanel(appId: Int) { } } +@Composable +internal fun SteamInputOverridePanel(appId: Int) { + val app = LocalContext.current.applicationContext + val colors = MaterialTheme.colorScheme + val scope = rememberCoroutineScope() + var selected by remember(appId) { mutableStateOf(null) } + var version by remember(appId) { mutableIntStateOf(0) } + var saving by remember(appId) { mutableStateOf(false) } + var loading by remember(appId) { mutableStateOf(true) } + var error by remember(appId) { mutableStateOf(null) } + + LaunchedEffect(appId, version) { + selected = withContext(Dispatchers.IO) { SteamInputOverride.current(app, appId) } + loading = false + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, appId) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && !saving) { + loading = true + version++ + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + val choices = listOf( + SteamInputOverride.Mode.DEFAULT to R.string.edit_steam_input_default, + SteamInputOverride.Mode.ENABLED to R.string.edit_steam_input_enable, + SteamInputOverride.Mode.DISABLED to R.string.edit_steam_input_disable, + ) + Column(Modifier.fillMaxWidth().tabPanel(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + stringResource(R.string.edit_steam_input_override), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp), + ) + val sessionRunning = SessionState.running + choices.forEach { (mode, label) -> + MenuItem( + stringResource(label), + checked = selected == mode, + enabled = selected != null && !saving && !loading && !sessionRunning && selected != mode, + ) { + val before = selected ?: return@MenuItem + saving = true + scope.launch { + try { + val saved = withContext(Dispatchers.IO) { SteamInputOverride.set(app, appId, mode, before) } + error = when (saved) { + SteamInputOverride.Saved.YES -> null + SteamInputOverride.Saved.NO -> R.string.edit_steam_input_not_saved + SteamInputOverride.Saved.RUNNING -> R.string.edit_steam_input_running + } + if (saved == SteamInputOverride.Saved.YES) selected = mode + else version++ + } finally { + saving = false + } + } + } + } + val message = error ?: when { + sessionRunning -> R.string.edit_steam_input_running + selected == null && !loading -> R.string.edit_steam_input_unavailable + else -> R.string.edit_steam_input_hint + } + Text( + stringResource(message), + fontSize = 12.sp, + color = if (error == null) colors.onSurfaceVariant else colors.error, + modifier = Modifier.padding(start = 4.dp), + ) + } +} + private class LoadedPosition(val value: LogoPosition.Value?) @Composable diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt index 808c94485..2618791e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -812,6 +812,7 @@ private fun LaunchTab(game: Library.SteamGame, s: FrontEndState, a: FrontEndActi LaunchSettings(s, a, host, game, hopRail ?: GameRef.key(game)) if (canEditSteamLaunchOptions(game, s.shortcutPicker)) { SteamLaunchOptionsPanel(game.appId) + SteamInputOverridePanel(game.appId) } val folder = game.gameFiles if (game.library == Library.ADDED && game.source == Library.ADDED && !s.shortcutPicker && folder != null) ShortcutFields(game, folder) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a7a221a0..f8780499d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2704,6 +2704,14 @@ Shared with Steam\'s Properties: a change in either one shows in both. Could not save the launch options to Steam. Try again. Steam may be open in the desktop session. Change the launch options in its Properties, or close the session first. + Steam Input + Use Steam\'s default + Enable Steam Input + Disable Steam Input + Same as the game\'s Controller setting in Steam Properties. + Close the Steam or desktop session before changing this setting. + Could not save the Steam Input setting. Close Steam and try again. + Could not read this game\'s Steam Input setting. Artwork Other exe… Remove