From e62698e39554c2bc57dee97b230bfef6e5f78be5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:33:24 -0400 Subject: [PATCH] fix: show when non-Steam games were last played, from Steam's shortcuts (#1551) --- .../droiddeck/launcher/frontend/Library.kt | 24 +++++++-- .../LibraryShortcutsLastPlayedTest.kt | 54 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 05f3f9869..5fb1a4ecd 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -204,9 +204,24 @@ object Library { (runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed && AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed }) } - return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId } + val played = shortcutsLastPlayed(SessionPrefs.steamRoot(context, arch)) + return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId } } + /** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */ + internal fun shortcutsLastPlayed(root: File): Map = runCatching { + val config = AddedGames.accountConfig(root) ?: return emptyMap() + val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap() + val vdf = SteamAppInfo.readMap(java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN), null) + (vdf["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance>()?.mapNotNull { s -> + fun int(key: String) = s.entries.firstOrNull { (it.key as? String).equals(key, ignoreCase = true) } + ?.let { (it.value as? String)?.toLongOrNull() }?.and(0xFFFFFFFFL) + val id = int("appid") ?: return@mapNotNull null + val time = int("LastPlayTime")?.takeIf { it > 0L } ?: return@mapNotNull null + id to time + }?.toMap() + }.getOrNull().orEmpty() + fun claimedFolders(steam: List): Set = steam.filter(::fromSteamClient).flatMap { g -> val dir = g.gameFiles ?: return@flatMap emptyList() listOfNotNull( @@ -215,11 +230,14 @@ object Library { ) }.toSet() - fun addedGame(context: Context, g: AddedGames.Game, roots: List = steamLibraries(context).map { it.first }): SteamGame = + fun addedGame( + context: Context, g: AddedGames.Game, roots: List = steamLibraries(context).map { it.first }, + lastPlayed: Long = 0L, + ): SteamGame = AddedGameArt.resolve(context, g).let { art -> SteamGame( g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId, - hero = art.hero ?: art.header, gameFiles = g.folder, + hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder, protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots), icon = art.icon?.takeIf { it.extension.lowercase() != "ico" }, source = g.source, storeId = g.storeId, diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt new file mode 100644 index 000000000..cc9a1ac7d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryShortcutsLastPlayedTest.kt @@ -0,0 +1,54 @@ +package com.droiddeck.launcher.frontend + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class LibraryShortcutsLastPlayedTest { + @get:Rule val tmp = TemporaryFolder() + + private fun steamRoot(): File = tmp.newFolder("Steam").also { root -> + File(root, "config").mkdirs() + File(root, "config/loginusers.vdf").writeText( + "\"users\"\n{\n\t\"76561198352563669\"\n\t{\n\t\t\"AccountName\"\t\t\"a\"\n\t\t\"MostRecent\"\t\t\"1\"\n\t}\n}\n") + } + + private class Vdf { + val out = ByteArrayOutputStream() + fun key(type: Int, k: String) { out.write(type); out.write(k.toByteArray()); out.write(0) } + fun map(k: String, body: Vdf.() -> Unit) { key(0, k); body(); out.write(8) } + fun str(k: String, v: String) { key(1, k); out.write(v.toByteArray()); out.write(0) } + fun int(k: String, v: Long) { key(2, k); out.write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()) } + } + + @Test fun readsEachShortcutsLastPlayTimeByUnsignedAppId() { + val root = steamRoot() + val vdf = Vdf().apply { + map("shortcuts") { + map("0") { int("appid", 3388195926L); str("AppName", "Rayman 2"); int("LastPlayTime", 1792931476L) } + map("1") { int("appid", 2228102537L); str("AppName", "Do Not Disturb"); int("LastPlayTime", 0L) } + map("2") { int("appid", 1234L); str("appname", "Old"); int("lastplaytime", 1700000000L) } + } + out.write(8) + } + File(root, "userdata/392297941/config").apply { mkdirs() }.resolve("shortcuts.vdf").writeBytes(vdf.out.toByteArray()) + assertEquals(mapOf(3388195926L to 1792931476L, 1234L to 1700000000L), Library.shortcutsLastPlayed(root)) + } + + @Test fun missingOrBrokenShortcutsGiveNothing() { + val root = steamRoot() + assertEquals(emptyMap(), Library.shortcutsLastPlayed(root)) + File(root, "userdata/392297941/config").apply { mkdirs() }.resolve("shortcuts.vdf").writeBytes(byteArrayOf(0, 's'.code.toByte())) + assertEquals(emptyMap(), Library.shortcutsLastPlayed(root)) + } +}