mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
feat: add per-game Steam Input override
This commit is contained in:
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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user