feat: add per-game Steam Input override

This commit is contained in:
Kurt Himebauch committed 2026-11-10 09:08:00 -05:00
1 parent 98ab71a814
commit 203a6df6eb
5 files changed
+194 -13

No files matched your search

@@ -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
}
}
@@ -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<String>): Entry? {
var found: Entry? = null
@@ -98,18 +141,27 @@ object SteamLaunchOptions {
private fun entries(text: String, open: Int, close: Int): List<Entry> {
val found = ArrayList<Entry>()
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 }
@@ -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<SteamInputOverride.Mode?>(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<Int?>(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
@@ -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)
+8
View File
@@ -2704,6 +2704,14 @@
<string name="edit_launch_options_steam_hint">Shared with Steam\'s Properties: a change in either one shows in both.</string>
<string name="edit_launch_options_not_saved">Could not save the launch options to Steam. Try again.</string>
<string name="edit_launch_options_desktop">Steam may be open in the desktop session. Change the launch options in its Properties, or close the session first.</string>
<string name="edit_steam_input_override">Steam Input</string>
<string name="edit_steam_input_default">Use Steam\'s default</string>
<string name="edit_steam_input_enable">Enable Steam Input</string>
<string name="edit_steam_input_disable">Disable Steam Input</string>
<string name="edit_steam_input_hint">Same as the game\'s Controller setting in Steam Properties.</string>
<string name="edit_steam_input_running">Close the Steam or desktop session before changing this setting.</string>
<string name="edit_steam_input_not_saved">Could not save the Steam Input setting. Close Steam and try again.</string>
<string name="edit_steam_input_unavailable">Could not read this game\'s Steam Input setting.</string>
<string name="edit_artwork">Artwork</string>
<string name="edit_other_exe">Other exe…</string>
<string name="edit_remove">Remove</string>