Game saves: Manage saves on each game's page instead of a separate page

The floppy disk beside the Steam cog and its Game saves page are gone. A game's
page now has Manage saves beside Proton prefix, a menu with Import saves (a
GameHub or Winlator zip, picked in the app's file picker; the saves there now are
backed up first), Export as GameHub zip, Export as Winlator zip (a folder picked
the same way) and Open save folder. The game's saves are looked for when the menu
opens. The Proton list and config.vdf reader only that page needed are removed.
This commit is contained in:
The412Banner committed 2026-09-29 23:21:40 -04:00
1 parent 174321c0ec
commit f22b8253fd
8 files changed
+97 -413

No files matched your search

@@ -38,7 +38,6 @@ import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.ui.GameSavesPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
@@ -113,14 +112,9 @@ class MainActivity : ComponentActivity() {
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var showMapping by mutableStateOf(false)
// Game saves page: import / export save zips per game (the floppy beside the Steam cog).
private var showSaves by mutableStateOf(false)
private var saveTools by mutableStateOf<List<GameSaves.Tool>?>(null)
private var saveGames by mutableStateOf<List<GameSaves.Game>>(emptyList())
private var saveTool by mutableStateOf<String?>(null)
private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB)
private var saveBusy by mutableStateOf<String?>(null)
private var pendingSaveGame: GameSaves.Game? = null
private var saveBusy: String? = null
/** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */
private var onSavePicked: ((File) -> Unit)? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
private var catalogLoading by mutableStateOf(false)
@@ -162,24 +156,40 @@ class MainActivity : ComponentActivity() {
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val game = pendingSaveGame ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { zip ->
saveAction("Importing into ${game.game.name}") {
val (written, backup) = GameSaves.import(game, zip)
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
"Imported $written files from the $kind into ${game.game.name}" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
}
}
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val game = pendingSaveGame ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { dir ->
val layout = saveLayout
saveAction("Exporting ${game.game.name}") {
val (zip, count) = GameSaves.export(game, layout, dir)
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
return
}
onSavePicked = { zip ->
saveAction("Importing into $name") {
val (written, backup) = GameSaves.import(game(), zip)
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
}
}
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
}
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
onSavePicked = { dir ->
saveAction("Exporting $name") {
val (zip, count) = GameSaves.export(game(), layout, dir)
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
}
}
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
@@ -305,7 +315,6 @@ class MainActivity : ComponentActivity() {
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
showSaves -> { { SavesHost() } }
showMapping -> { { MappingHost() } }
else -> null
}
@@ -320,7 +329,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showSaves) "game-saves" else if (showMapping) "controller-mapping" else null,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -379,8 +388,10 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onGameSaves = { openSaves() },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showSaves = false; showMapping = false; showPerformance = true },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
@@ -405,7 +416,7 @@ class MainActivity : ComponentActivity() {
offline = OfflineMode.enabled(this)
},
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = false },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
@@ -484,7 +495,7 @@ class MainActivity : ComponentActivity() {
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = true },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
@@ -588,14 +599,6 @@ class MainActivity : ComponentActivity() {
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// On the Game saves page LB / RB flip the zip type exports are written in.
if (showSaves && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = GameSaves.Layout.entries
saveLayout = all[(saveLayout.ordinal + 1) % all.size]
}
return true
}
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
@@ -699,32 +702,7 @@ class MainActivity : ComponentActivity() {
}, "catalog-desktop").start()
}
private fun openSaves() {
settingsMode = null
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
showSaves = true
refreshSaves()
}
/** Protons, which game runs with which, and the saves found in each prefix: files only, off the main thread. */
private fun refreshSaves() {
val all = steamGames
Thread({
val tools = runCatching { GameSaves.tools(this) }.getOrDefault(emptyList())
val games = runCatching { GameSaves.games(this, all) }.getOrDefault(emptyList())
ui.post {
saveTools = tools
saveGames = games
// Open on the Proton the most launched games use, unless one was picked already.
if (saveTool == null || tools.none { it.name == saveTool }) saveTool =
tools.maxByOrNull { t -> games.count { it.tool == t.name && it.launched } }?.name
}
}, "game-saves").start()
}
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
private fun saveAction(label: String, work: () -> String) {
if (saveBusy != null) return
saveBusy = label
@@ -733,40 +711,14 @@ class MainActivity : ComponentActivity() {
ui.post {
saveBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshSaves()
}
}, "game-saves-action").start()
}
@Composable
private fun SavesHost() {
GameSavesPage(
tools = saveTools,
games = saveGames,
toolName = saveTool,
layout = saveLayout,
busy = saveBusy,
sessionRunning = SessionState.running,
onTool = { saveTool = it },
onLayout = { saveLayout = it },
onImport = { g ->
pendingSaveGame = g
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for ${g.game.name}",
GameSaves.savesDir().parentFile?.parentFile?.path))
},
onExport = { g ->
pendingSaveGame = g
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save ${g.game.name}", GameSaves.savesDir().path))
},
onBack = { showSaves = false },
)
}
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false; showSaves = false
showComponents = false
showMapping = false
showProtons = true
protons.refreshProtons()
@@ -793,7 +745,6 @@ class MainActivity : ComponentActivity() {
showPerformance = false
showProtons = false
showMapping = false
showSaves = false
showComponents = true
components.refreshComponents(snapshotFirst = true)
}
@@ -1049,7 +1000,7 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
showPerformance = false
showProtons = false
showComponents = false; showSaves = false
showComponents = false
showMapping = false
drivers.refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
@@ -1,9 +1,7 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
@@ -23,9 +21,8 @@ import java.util.zip.ZipOutputStream
* "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but
* Public becomes steamuser, and on the way out the chosen layout decides the user segment.
*
* Saves belong to the game's prefix (compatdata/<appid>/pfx), not to a Proton: the Proton a game is
* set to (the client's CompatToolMapping) only decides where the page lists it. Ported from
* Bannerlator's GameSaveBackup and SaveLocator.
* Saves belong to the game's prefix (compatdata/<appid>/pfx), whichever Proton it runs with.
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
*/
object GameSaves {
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
@@ -33,64 +30,21 @@ object GameSaves {
WINLATOR("Winlator zip", "drive_c/", "xuser"),
}
/** A compatibility tool the client can run a game with, by the name it records per game. */
data class Tool(val name: String, val label: String)
/** A game as the page lists it: its Proton, and where its saves are. */
data class Game(val game: Library.SteamGame, val tool: String?, val prefix: File?, val saves: List<SaveDir>) {
/** A game, its prefix, and the save folders found in it. */
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true
}
/** A folder of saves, relative to the steamuser profile. */
data class SaveDir(val relPath: String, val files: Int, val bytes: Long)
/** DroidDeck's launcher around Valve's ARM64 Proton Experimental (bannerlator-steam-compat). */
private const val WRAPPER_TOOL = "bannerlator-proton-arm64"
private const val USER = "steamuser"
private fun steam(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves")
private fun backupsDir(): File = File(savesDir(), "backups")
// ------------------------------------------------------------------ Protons and games
/** The compatibility tools installed for the client, each by its registered name and shown name. */
fun tools(context: Context): List<Tool> {
val out = LinkedHashMap<String, Tool>()
File(steam(context), "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir ->
val vdf = File(dir, "compatibilitytool.vdf").takeIf { it.isFile } ?: return@forEach
val tools = runCatching { Vdf.parse(vdf.readText()) }.getOrNull()
?.child("compatibilitytools")?.child("compat_tools") ?: return@forEach
for ((name, v) in tools.children) {
val shown = (v as? Vdf.Node)?.string("display_name") ?: name
out[name] = Tool(name, if (name == WRAPPER_TOOL) "Proton Experimental (ARM64)" else shown)
}
}
return out.values.toList()
}
/** The client's per-game compatibility tool, by app id; "0" is its default for every other game. */
fun mapping(context: Context): Map<String, String> {
val config = File(steam(context), "config/config.vdf").takeIf { it.isFile } ?: return emptyMap()
val root = runCatching { Vdf.parse(config.readText()) }.getOrNull() ?: return emptyMap()
val map = root.find("CompatToolMapping") ?: return emptyMap()
val out = HashMap<String, String>()
for ((id, v) in map.children) (v as? Vdf.Node)?.string("name")?.takeIf { it.isNotEmpty() }?.let { out[id] = it }
return out
}
/** Every game with the tool it runs with (null = none set) and the saves found in its prefix. */
fun games(context: Context, all: List<Library.SteamGame>): List<Game> {
val mapping = mapping(context)
val fallback = mapping["0"]
return all.map { g ->
val unsigned = (g.appId.toLong() and 0xffffffffL).toString()
val tool = mapping[unsigned] ?: mapping[g.appId.toString()] ?: mapping[g.gameId.toString()] ?: fallback
val prefix = g.protonPrefix
Game(g, tool, prefix, prefix?.let { locate(it, g) } ?: emptyList())
}
}
/** [game] with its prefix and the saves found there; reads files, so not on the main thread. */
fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList())
// ------------------------------------------------------------------ finding saves
@@ -281,55 +235,4 @@ object GameSaves {
if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null
return segs.drop(1).toMutableList()
}
// ------------------------------------------------------------------ Valve KeyValues
/** The client's text KeyValues (VDF): quoted keys, quoted values or { nested blocks }. */
internal object Vdf {
class Node(val children: LinkedHashMap<String, Any> = LinkedHashMap()) {
fun child(key: String): Node? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? Node
fun string(key: String): String? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? String
/** The first block named [key] anywhere below. */
fun find(key: String): Node? {
child(key)?.let { return it }
for (v in children.values) (v as? Node)?.find(key)?.let { return it }
return null
}
}
fun parse(text: String): Node {
var i = 0
fun token(): String? {
while (i < text.length) {
val c = text[i]
when {
c.isWhitespace() -> i++
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> { while (i < text.length && text[i] != '\n') i++ }
c == '{' || c == '}' -> { i++; return c.toString() }
c == '"' -> {
val sb = StringBuilder(); i++
while (i < text.length && text[i] != '"') {
if (text[i] == '\\' && i + 1 < text.length) { sb.append(text[i + 1]); i += 2 } else sb.append(text[i++])
}
i++
return "\"" + sb
}
else -> { val s = i; while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}') i++; return "\"" + text.substring(s, i) }
}
}
return null
}
fun block(into: Node) {
while (true) {
val k = token() ?: return
if (k == "}") return
if (k == "{") continue
val key = k.drop(1)
val v = token() ?: return
if (v == "{") { val n = Node(); block(n); into.children[key] = n } else into.children[key] = v.drop(1)
}
}
return Node().also { block(it) }
}
}
}
@@ -318,7 +318,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal
* fill with an outline in the text colour - unmistakable in a column of identical buttons.
*/
@Composable
internal fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
@@ -120,11 +120,55 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
}
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
ManageSaves(g, dir, a)
}
BusyChip(s)
}
}
/**
* Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as
* either, or open the save folder found for it. The saves are looked for when the menu opens.
*/
@Composable
private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) {
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
val saves by androidx.compose.runtime.produceState<List<com.droiddeck.launcher.session.GameSaves.SaveDir>?>(null, g.gameId, open) {
if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList())
}
}
val found = saves
val summary = when {
found == null -> "Looking for this game's saves…"
found.isEmpty() -> "No save folder found by name: an export takes the whole user folder."
else -> {
val mb = found.sumOf { it.bytes } / 1048576.0
(if (found.size == 1) found[0].relPath else "${found.size} save folders") + " · ${found.sumOf { it.files }} files · " +
(if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb))
}
}
Box {
SecondaryButton("Manage saves", compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = "Game saves", note = summary) { first ->
MenuItem("Import saves…", checked = false, detail = "A GameHub or Winlator zip · the saves there now are backed up first", focusRequester = first) {
open = false; a.onSaveImport(g)
}
MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB)
}
MenuItem("Export as Winlator zip…", checked = false, detail = "Saves under xuser, for Winlator, WinNative and Bannerlator") {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR)
}
found?.firstOrNull()?.let { d ->
MenuItem("Open save folder", checked = false, detail = d.relPath) {
open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath))
}
}
}
}
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
@@ -133,8 +133,9 @@ class FrontEndActions(
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: (focusContent: Boolean) -> Unit,
/** The Game saves page: import and export save zips per game (the floppy beside the Steam cog). */
val onGameSaves: () -> Unit = {},
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
val onPerformance: () -> Unit,
val onRoms: () -> Unit,
val onFiles: () -> Unit,
@@ -360,7 +361,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
val railSelection = when {
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true || s.pageKey == "game-saves" -> "steam"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
selected.startsWith("app:") -> "games"
selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop"
@@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.ui.graphics.vector.ImageVector
@@ -236,18 +235,3 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
}
/** Game saves: a floppy disk beside the cog, 70% of its size, drawn the same way. */
@Composable
internal fun SaveDisk(size: androidx.compose.ui.unit.Dp = 38.dp, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "saveEdge")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("saves").downToFirstTile().size(size).clip(Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
) { Icon(Icons.Outlined.Save, "Game saves", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(16.dp)) }
}
@@ -1,198 +0,0 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.GameSaves
/**
* Game saves, opened from the floppy disk beside the Steam page's cog. A page like Components:
* the Proton at the top, the save zip type beside it (LB / RB turn it), and the games set to that
* Proton, each with Import (pick a zip in the app's file picker) and Export (pick a folder there).
* A zip of either type imports; the type only decides how an export is written.
*/
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
fun GameSavesPage(
tools: List<GameSaves.Tool>?,
games: List<GameSaves.Game>,
toolName: String?,
layout: GameSaves.Layout,
busy: String?,
sessionRunning: Boolean,
onTool: (String) -> Unit,
onLayout: (GameSaves.Layout) -> Unit,
onImport: (GameSaves.Game) -> Unit,
onExport: (GameSaves.Game) -> Unit,
onBack: () -> Unit,
requestInitialFocus: Boolean = true,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
BackHandler(onBack = onBack)
var toolMenu by remember { mutableStateOf(false) }
val list = tools ?: emptyList()
val tool = list.firstOrNull { it.name == toolName } ?: list.firstOrNull()
val shown = games.filter { it.tool == tool?.name }
.sortedWith(compareByDescending<GameSaves.Game> { it.launched }.thenBy { it.game.name.lowercase() })
val layouts = GameSaves.Layout.entries
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Text(
"Game saves", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 1, modifier = Modifier.weight(1f),
)
if (tools != null) Text(
if (shown.size == 1) "1 game on this Proton" else "${shown.size} games on this Proton",
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1,
)
}
// ---- which Proton, which zip type -----------------------------------------------------------
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (tool != null) Box {
ValueChip(tool.label, toolMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { toolMenu = !toolMenu }
AnchoredMenu(toolMenu, onDismiss = { toolMenu = false }, title = "Proton",
note = "Saves belong to the game, not the Proton: switch a game to another Proton and its saves go with it.") { first ->
list.forEachIndexed { i, t ->
val n = games.count { it.tool == t.name }
MenuItem(
t.label, checked = t.name == tool.name,
detail = t.name + " · " + if (n == 1) "1 game" else if (n == 0) "no games yet" else "$n games",
focusRequester = if (i == 0) first else null,
) { onTool(t.name); toolMenu = false }
}
}
}
TabStrip(layouts.map { it.label }, layouts.indexOf(layout), { onLayout(layouts[it]) })
}
// ---- what the type means, or what is happening -----------------------------------------------
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface)
.border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp),
) {
if (busy != null) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(14.dp))
else Box(Modifier.size(8.dp).background(if (sessionRunning) Color(0xFFFFB86B) else pal.good, CircleShape))
Text(
when {
busy != null -> "$busy…"
sessionRunning -> "Steam is running: close the session before importing, or the game may save over it."
layout == GameSaves.Layout.GAMEHUB -> "Exports keep saves under steamuser, as GameHub does. Imports take either type and back up the saves already there first."
else -> "Exports keep saves under xuser, as Winlator does. Imports take either type and back up the saves already there first."
},
fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
}
// ---- the games ------------------------------------------------------------------------------
when {
tools == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text("Looking for games and their saves…", fontSize = 14.sp, color = colors.onSurfaceVariant)
}
tool == null -> Text(
"No Proton is installed in the Linux runtime yet. Start the Steam client once so it sets one up.",
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp),
)
else -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) {
val first = remember { FocusRequester() }
LaunchedEffect(tool.name, requestInitialFocus) { if (requestInitialFocus) runCatching { first.requestFocus() } }
SettingsGroup("Games on this Proton · ${shown.size}") {
if (shown.isEmpty()) Text(
"No game is set to ${tool.label}. Pick it for a game in Steam (Properties › Compatibility) and launch the game once.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
)
var firstSet = false
shown.forEach { g ->
val canImport = g.launched && busy == null && !sessionRunning
val canExport = g.launched && busy == null
val mod = if (!firstSet && canExport) { firstSet = true; Modifier.focusRequester(first) } else Modifier
GameSaveLine(g, canImport, canExport, mod, { onImport(g) }, { onExport(g) })
}
}
}
}
}
}
@Composable
private fun GameSaveLine(
g: GameSaves.Game, canImport: Boolean, canExport: Boolean, firstButton: Modifier,
onImport: () -> Unit, onExport: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
val detail = when {
!g.launched -> "Launch it once first: its save folder is made on the first launch"
g.saves.isEmpty() -> (if (g.game.library == "added") "Added game" else "Steam game") + " · no save folder found, Export takes the whole user folder"
else -> {
val files = g.saves.sumOf { it.files }
val mb = g.saves.sumOf { it.bytes } / 1048576.0
(if (g.game.library == "added") "Added game" else "Steam game") + " · " +
(if (g.saves.size == 1) g.saves[0].relPath else "${g.saves.size} save folders") +
" · $files files · " + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb))
}
}
val text: @Composable (Modifier) -> Unit = { m ->
Column(m) {
Text(g.game.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 12.sp, color = if (g.launched) colors.onSurfaceVariant else Color(0xFFFFB86B), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
val buttons: @Composable () -> Unit = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SmallButton("Import", enabled = canImport, modifier = if (canImport) firstButton else Modifier, onClick = onImport)
SmallButton("Export", enabled = canExport, modifier = if (!canImport) firstButton else Modifier, onClick = onExport)
}
}
if (narrow) Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
text(Modifier.fillMaxWidth()); buttons()
} else Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
) { text(Modifier.weight(1f)); buttons() }
}
@@ -105,7 +105,6 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton("Play Steam", enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay)
Cog(size = 54.dp, onClick = a.onSteamSettings)
SaveDisk(size = 38.dp, onClick = a.onGameSaves)
}
}
}