diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index bd606a0d9..a5da8b357 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -38,6 +38,8 @@ 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 import com.droiddeck.launcher.ui.ProtonPage @@ -111,6 +113,14 @@ 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?>(null) + private var saveGames by mutableStateOf>(emptyList()) + private var saveTool by mutableStateOf(null) + private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB) + private var saveBusy by mutableStateOf(null) + private var pendingSaveGame: GameSaves.Game? = null private var controllerSettings by mutableStateOf(null) private var catalog by mutableStateOf?>(null) private var catalogLoading by mutableStateOf(false) @@ -151,6 +161,26 @@ class MainActivity : ComponentActivity() { private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> 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" } ?: "") + } + } + } + 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) + "Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}" + } + } + } private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path) @@ -275,6 +305,7 @@ class MainActivity : ComponentActivity() { showPerformance -> { { PerformanceHost() } } showProtons -> { { ProtonHost() } } showComponents -> { { ComponentsHost() } } + showSaves -> { { SavesHost() } } showMapping -> { { MappingHost() } } else -> null } @@ -289,7 +320,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 (showMapping) "controller-mapping" else null, + 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, theme = theme, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, @@ -348,7 +379,8 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, + onGameSaves = { openSaves() }, + onPerformance = { refreshCores(); showProtons = false; showComponents = false; showSaves = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onBrowseFiles = { dir -> @@ -373,7 +405,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onCredits = { showCredits = true }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, + onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLauncherFullscreen = { on -> SessionPrefs.setLauncherFullscreen(this, on) @@ -452,7 +484,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; showMapping = true }, + onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -556,6 +588,14 @@ 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 @@ -659,10 +699,74 @@ 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() + } + + private fun saveAction(label: String, work: () -> String) { + if (saveBusy != null) return + saveBusy = label + Thread({ + val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" } + 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 + showComponents = false; showSaves = false showMapping = false showProtons = true protons.refreshProtons() @@ -689,6 +793,7 @@ class MainActivity : ComponentActivity() { showPerformance = false showProtons = false showMapping = false + showSaves = false showComponents = true components.refreshComponents(snapshotFirst = true) } @@ -944,7 +1049,7 @@ class MainActivity : ComponentActivity() { private fun openModeSettings(mode: String) { showPerformance = false showProtons = false - showComponents = false + showComponents = false; showSaves = false showMapping = false drivers.refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt new file mode 100644 index 000000000..5c33d4275 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt @@ -0,0 +1,335 @@ +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 +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** + * Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's + * drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at + * "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//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. + */ +object GameSaves { + enum class Layout(val label: String, val rootPrefix: String, val user: String) { + GAMEHUB("GameHub zip", "/drive_c/", "steamuser"), + 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) { + 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 { + val out = LinkedHashMap() + 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 { + 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() + 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): List { + 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()) + } + } + + // ------------------------------------------------------------------ finding saves + + /** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */ + private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow") + private const val KEEP_THRESHOLD = 50 + + private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER") + + /** + * Folders under the save roots, one and two deep, whose names match the game's name or its folder's. + * Nested matches keep the outer folder only. + */ + fun locate(prefix: File, game: Library.SteamGame): List { + val profile = profile(prefix) + if (!profile.isDirectory) return emptyList() + val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() } + if (ids.isEmpty()) return emptyList() + val hits = LinkedHashMap() + fun consider(dir: File) { + val d = ident(dir.name) + if (d.norm.isEmpty()) return + val s = ids.maxOf { score(it, d) } + if (s < KEEP_THRESHOLD) return + val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/') + if ((hits[rel] ?: -1) < s) hits[rel] = s + } + for (root in SAVE_ROOTS) { + File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 -> + consider(d1) + d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider) + } + } + val kept = ArrayList() + for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) { + if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel) + } + return kept.map { rel -> + var files = 0 + var bytes = 0L + File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() } + SaveDir(rel, files, bytes) + }.filter { it.files > 0 } + } + + private data class Ident(val norm: String, val tokens: Set) + + private fun ident(raw: String): Ident { + val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "") + return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet()) + } + + /** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */ + private fun score(a: Ident, b: Ident): Int { + if (a.norm.isEmpty() || b.norm.isEmpty()) return 0 + if (a.norm == b.norm) return 100 + if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70 + if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() && + a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50 + return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0 + } + + private fun levenshtein(a: String, b: String): Int { + val prev = IntArray(b.length + 1) { it } + val cur = IntArray(b.length + 1) + for (i in 1..a.length) { + cur[0] = i + for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1) + System.arraycopy(cur, 0, prev, 0, cur.size) + } + return prev[b.length] + } + + // ------------------------------------------------------------------ export + + private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date()) + private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" } + + /** + * Zips the game's saves into [dir] as "_.zip" in [layout]. The found save folders when + * there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip. + */ + fun export(game: Game, layout: Layout, dir: File): Pair { + val prefix = game.prefix ?: error("${game.game.name} has not been launched yet") + dir.mkdirs() + val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip") + val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out) + if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") } + return out to count + } + + private fun zipProfile(profile: File, roots: List?, layout: Layout, out: File): Int { + if (!profile.isDirectory) return 0 + val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile) + var count = 0 + ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos -> + for (top in scope) top.walkTopDown() + .onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } } + .filter { it.isFile } + .forEach { f -> + val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/') + zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel")) + f.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + count++ + } + } + return count + } + + // ------------------------------------------------------------------ import + + /** + * Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser + * profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher + * shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null). + */ + fun import(game: Game, zip: File): Pair { + val prefix = game.prefix ?: error("${game.game.name} has not been launched yet") + val driveC = File(prefix, "drive_c") + if (!driveC.isDirectory) error("${game.game.name} has not been launched yet") + if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip") + + backupsDir().mkdirs() + val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip") + val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup) + if (backedUp == 0) backup.delete() + + val base = driveC.canonicalFile + var written = 0 + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { + remap(e.name)?.let { rel -> + val out = File(driveC, rel).canonicalFile + if (out.path.startsWith(base.path + File.separator)) { + if (e.isDirectory) out.mkdirs() + else { + out.parentFile?.mkdirs() + Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING) + written++ + } + } + } + zis.closeEntry() + e = zis.nextEntry + } + } + return written to backup.takeIf { it.exists() } + } + + /** Which layout a zip was written in, from its first user folder; null when it has none. */ + fun layoutOf(zip: File): Layout? = runCatching { + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { + val segs = driveCSegments(e.name) + if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) + return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB + e = zis.nextEntry + } + null + } + }.getOrNull() + + private fun looksLikeSaveZip(zip: File): Boolean = runCatching { + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry } + false + } + }.getOrDefault(false) + + /** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */ + private fun remap(raw: String): String? { + val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null + if (segs.any { it == ".." }) return null + // GameHub also packs its own launcher shortcuts; they are never saves. + if (segs[0].equals("proton_shortcuts", true)) return null + val last = segs.last().lowercase() + if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null + if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER + return segs.joinToString("/") + } + + private fun driveCSegments(raw: String): MutableList? { + val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." } + 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 = 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) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 7822ef44e..eaca8c603 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -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 -private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { +internal 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() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index d245f0346..88d1f3b62 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -133,6 +133,8 @@ 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 = {}, val onPerformance: () -> Unit, val onRoms: () -> Unit, val onFiles: () -> Unit, @@ -358,7 +360,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 -> "steam" + s.pageKey?.startsWith("settings:steam") == true || s.pageKey == "game-saves" -> "steam" s.pageKey?.startsWith("settings:") == true -> "desktop" selected.startsWith("app:") -> "games" selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 7d034a138..ac4491c80 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -31,6 +31,7 @@ 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 @@ -235,3 +236,18 @@ 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)) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt new file mode 100644 index 000000000..2cbac137f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt @@ -0,0 +1,198 @@ +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?, + games: List, + 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 { 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() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 7be01984c..0cb88356e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -105,6 +105,7 @@ 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) } } }