fix: Keep game art in step with Steam's grid folder; apply review fixes (#948)

This commit is contained in:
FX1922 authored and GitHub committed 2026-10-16 16:14:49 -04:00
1 parent 953c11ed48
commit d8df18df82
12 files changed
+393 -38

No files matched your search

@@ -1519,10 +1519,17 @@ class MainActivity : ComponentActivity() {
}, "added-games-update").start()
}
private val steamArtRefreshes = HashMap<Int, Int>()
private fun refreshSteamGameArt(appId: Int) {
val version = (steamArtRefreshes[appId] ?: 0) + 1
steamArtRefreshes[appId] = version
Thread({
val fresh = com.droiddeck.launcher.frontend.Library.steamGames(this).firstOrNull { it.appId == appId } ?: return@Thread
ui.post { steamGames = steamGames.map { if (it.appId == appId && it.library != Library.ADDED) fresh else it } }
ui.post {
if (steamArtRefreshes[appId] != version) return@post
steamGames = steamGames.map { if (it.appId == appId && it.library != Library.ADDED) fresh else it }
}
}, "steam-art-update").start()
}
@@ -1666,6 +1673,7 @@ class MainActivity : ComponentActivity() {
else -> "Steam"
} else null
val scanGeneration = ++libraryScanGeneration
val artRefreshesAtStart = HashMap(steamArtRefreshes)
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
@@ -1694,7 +1702,11 @@ class MainActivity : ComponentActivity() {
}
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
steamGames = all.map { g ->
val newer = current[g.appId]
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
}
shortcutLibraryScanning = false
handleGameLink(all)
}
@@ -40,10 +40,10 @@ object AddedGameArt {
class Found<T>(val items: List<T>, val status: SteamGridDb.Status)
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
private val CHOSEN = Regex("""chosen-(p|hero|logo|icon)(?:-\d+)?\.(\w+)""")
internal fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.maxWithOrNull(compareBy({ it.lastModified() }, { it.name }))
private val IMAGE = listOf("png", "jpg", "jpeg")
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
@@ -147,7 +147,7 @@ object AddedGameArt {
fun searchGames(context: Context, term: String): Found<SteamGridDb.Game> = sgdb(context) { SteamGridDb.search(it, term) }
fun gameImages(context: Context, id: Int, slot: Slot): Found<Option> = sgdb(context) { key ->
SteamGridDb.page(key, id, slot).map { (thumb, full) -> Option(SGDB, thumb, full) }
SteamGridDb.all(key, id, slot).map { (thumb, full) -> Option(SGDB, thumb, full) }
}
private fun <T> sgdb(context: Context, call: (String) -> List<T>): Found<T> {
@@ -168,15 +168,30 @@ object AddedGameArt {
}
internal fun store(cache: File, slot: Slot, option: Option): Boolean {
val fresh = storeNew(cache, slot, option) ?: return false
dropOlder(cache, slot, fresh)
return true
}
internal fun dropOlder(cache: File, slot: Slot, keep: File) {
cache.listFiles { f -> f != keep && CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.forEach { it.delete() }
}
private val lastStamp = java.util.concurrent.atomic.AtomicLong(0)
private fun nextStamp(): Long = lastStamp.updateAndGet { maxOf(it + 1, System.currentTimeMillis()) }
internal fun storeNew(cache: File, slot: Slot, option: Option): File? {
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
bytes ?: return false
bytes ?: return null
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
cache.mkdirs()
chosen(cache, slot)?.delete()
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
val suffix = if (ext == "jpeg") "jpg" else ext
var dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
while (dst.exists()) dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
val tmp = File(cache, dst.name + ".tmp")
tmp.writeBytes(bytes)
return tmp.renameTo(dst)
return if (tmp.renameTo(dst)) dst else null
}
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
@@ -36,19 +36,24 @@ object AddedGameEdits {
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
val game = game(context, folder) ?: return false
if (!AddedGameArt.choose(context, game, slot, option)) return false
applyArt(context, game, slot)
return true
return synchronized(SteamGrid.lock) {
if (!AddedGameArt.choose(context, game, slot, option)) return@synchronized false
applyArt(context, game, slot)
true
}
}
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
val game = game(context, folder) ?: return
AddedGameArt.reset(context, game, slot)
applyArt(context, game, slot)
synchronized(SteamGrid.lock) {
AddedGameArt.reset(context, game, slot)
applyArt(context, game, slot)
}
}
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
if (slot != AddedGameArt.Slot.ICON) SteamGrid.write(context, SteamGrid.target(game, context), slot, AddedGameArt.chosen(AddedGameArt.cacheDir(context, game), slot))
publish(context) {
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
@@ -330,7 +330,7 @@ object AddedGames {
}
}
private fun accountConfig(root: File): File? {
internal fun accountConfig(root: File): File? {
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
@@ -17,6 +17,9 @@ object Library {
val source: String = SOURCE_STEAM,
val storeId: String? = null,
) {
fun withArt(art: File?, hero: File?, icon: File?) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
}
@@ -138,7 +141,11 @@ object Library {
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
val list = added ?: AddedGames.scan(context)
var steam = steamGames(context, strictRead)
val targets = steam.filter { it.library != ADDED && it.source == SOURCE_STEAM }.map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead)
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
}
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
@@ -10,6 +10,11 @@ object SteamGameArt {
private fun sourceFile(context: Context, appId: Int, slot: AddedGameArt.Slot) = File(dir(context, appId), "source-${slot.key}")
internal fun setSource(context: Context, appId: Int, slot: AddedGameArt.Slot, value: String) {
val file = sourceFile(context, appId, slot)
if (value.isEmpty()) file.delete() else file.apply { parentFile?.mkdirs() }.writeText(value)
}
fun chosenFile(context: Context, appId: Int, slot: AddedGameArt.Slot): File? = AddedGameArt.chosen(dir(context, appId), slot)
fun withOverrides(context: Context, game: Library.SteamGame): Library.SteamGame {
@@ -55,22 +60,31 @@ object SteamGameArt {
}
fun choose(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
val folder = dir(context, game.appId)
if (!AddedGameArt.store(folder, slot, option)) return false
sourceFile(context, game.appId, slot).writeText(option.group + "|" + option.full)
push(context, game, slot)
return true
return synchronized(SteamGrid.lock) {
val folder = dir(context, game.appId)
val fresh = AddedGameArt.storeNew(folder, slot, option) ?: return@synchronized false
if (!push(context, game, slot, fresh)) {
fresh.delete()
return@synchronized false
}
AddedGameArt.dropOlder(folder, slot, fresh)
setSource(context, game.appId, slot, option.group + "|" + option.full)
true
}
}
fun reset(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot) {
AddedGameArt.chosen(dir(context, game.appId), slot)?.delete()
sourceFile(context, game.appId, slot).delete()
push(context, game, slot)
synchronized(SteamGrid.lock) {
if (!push(context, game, slot, null)) return@synchronized
AddedGameArt.chosen(dir(context, game.appId), slot)?.delete()
setSource(context, game.appId, slot, "")
}
}
private fun push(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot) {
val type = slot.assetType ?: return
val file = chosenFile(context, game.appId, slot)
private fun push(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, file: File?): Boolean {
val type = slot.assetType ?: return true
if (!SteamGrid.write(context, SteamGrid.target(game, context), slot, file)) return false
AddedGamesPublisher.later(context) { SteamLiveShortcuts.setArt(context, game.appId.toLong(), type, file) }
return true
}
}
@@ -0,0 +1,119 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
object SteamGrid {
private const val TAG = "SteamGrid"
/** Every change to a game's art, here or in a scan, takes this first so two of them never interleave. */
internal val lock = Any()
private const val MANIFEST = ".droiddeck-art.json"
private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo")
private val EXTS = listOf("png", "jpg", "jpeg", "webp")
class Target(val artDir: File, val id: Long, val setSource: (AddedGameArt.Slot, String) -> Unit)
fun target(game: Library.SteamGame, context: Context) = Target(SteamGameArt.dir(context, game.appId), game.appId.toLong()) { slot, value ->
SteamGameArt.setSource(context, game.appId, slot, value)
}
fun target(game: AddedGames.Game, context: Context) = Target(AddedGameArt.cacheDir(context, game), game.appId and 0xFFFFFFFFL) { slot, value ->
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, value)
}
internal fun dir(context: Context): File? {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return AddedGames.accountConfig(root)?.let { File(it, "grid") }
}
private fun files(grid: File, id: Long, slot: AddedGameArt.Slot): List<File> =
EXTS.map { File(grid, "$id${SUFFIX[slot]}.$it") }.filter { it.isFile }
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
private fun sigFile(target: Target, slot: AddedGameArt.Slot) = File(target.artDir, "grid-${slot.key}")
private fun recorded(target: Target, slot: AddedGameArt.Slot): String? = sigFile(target, slot).takeIf { it.isFile }?.readText()
private fun record(target: Target, slot: AddedGameArt.Slot, value: String) {
target.artDir.mkdirs()
sigFile(target, slot).writeText(value)
}
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
val manifest = JSONObject(File(grid, MANIFEST).readText())
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
}.getOrDefault(emptySet())
/** Puts a choice (or, with null, the lack of one) where Steam reads it, whether or not Steam is running. */
fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) }
private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean {
val suffix = SUFFIX[slot] ?: return true
val grid = dir(context) ?: return true
if (file == null) {
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
record(target, slot, "")
return true
}
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
if (ext !in EXTS) return false
grid.mkdirs()
val dst = File(grid, "${target.id}$suffix.$ext")
val tmp = File(grid, dst.name + ".tmp")
val placed = runCatching { file.copyTo(tmp, overwrite = true); if (tmp.renameTo(dst)) dst else null }.getOrNull()
if (placed == null) {
tmp.delete()
return false
}
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
record(target, slot, signature(placed))
return true
}
/** Takes over what changed in Steam since the last look; returns true when a choice was added, replaced or dropped. */
fun sync(context: Context, targets: List<Target>): Boolean = synchronized(lock) { reconcile(context, targets) }
private fun reconcile(context: Context, targets: List<Target>): Boolean {
val grid = dir(context) ?: return false
val ours = writtenByDroidDeck(grid)
var changed = false
for (target in targets) for (slot in SUFFIX.keys) {
try {
val own = AddedGameArt.chosen(target.artDir, slot)
val present = files(grid, target.id, slot)
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
val now = signature(theirs)
val before = recorded(target, slot)
when {
before == null && own != null && theirs == null -> write(context, target, slot, own)
before == null || now != before -> {
if (theirs != null) {
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(target, slot, now)
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
record(target, slot, now)
changed = true
}
} else {
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
record(target, slot, "")
}
}
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
own != null && present.isEmpty() -> write(context, target, slot, own)
}
} catch (e: Exception) {
Log.w(TAG, "${target.id} ${slot.key}: ${e.javaClass.simpleName}")
}
}
if (changed) Log.i(TAG, "took over art changed in Steam")
return changed
}
}
@@ -131,6 +131,26 @@ object SteamGridDb {
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
}
private const val PAGE_SIZE = 50
private const val MAX_PAGES = 4
fun all(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
val raw = JSONArray()
for (n in 0 until MAX_PAGES) {
val url = "$API/${path(id, slot)}" + if (n > 0) "&page=$n" else ""
val data: JSONArray? = try {
json(key, url)?.optJSONArray("data")
} catch (e: Failed) {
if (n == 0) throw e
null
}
val items = data ?: break
for (i in 0 until items.length()) raw.put(items.get(i))
if (items.length() < PAGE_SIZE) break
}
return ranked(raw, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
}
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> =
page(key, id, slot).take(limit)
@@ -148,7 +148,7 @@ internal class ArtTarget(
val name: String,
val chosenRef: (Context, AddedGameArt.Slot) -> String,
val choices: (Context, AddedGameArt.Slot) -> AddedGameArt.Choices,
val set: (Context, AddedGameArt.Slot, AddedGameArt.Option) -> Unit,
val set: (Context, AddedGameArt.Slot, AddedGameArt.Option) -> Boolean,
val reset: (Context, AddedGameArt.Slot) -> Unit,
) {
companion object {
@@ -182,7 +182,7 @@ internal fun SteamGameArtEditor(
var arts by remember { mutableStateOf<List<AddedGameArt.SlotState>>(emptyList()) }
var artSlot by remember { mutableStateOf<AddedGameArt.Slot?>(null) }
val target = remember(game.appId) { ArtTarget.of(game) }
LaunchedEffect(version) { arts = withContext(Dispatchers.IO) { SteamGameArt.describe(app, game) } }
LaunchedEffect(version, game) { arts = withContext(Dispatchers.IO) { SteamGameArt.describe(app, game) } }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
AppDialog(shown, close, "steamArtEditor", wide = false, maxWidth = 460.dp) {
@@ -438,9 +438,11 @@ private fun ArtChooserDialog(
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
val apply: (AddedGameArt.Option) -> Unit = { option ->
scope.launch {
withContext(Dispatchers.IO) { target.set(app, slot, option) }
onChanged()
back()
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
if (saved) {
onChanged()
back()
}
}
}
val slotName = slot.label()
@@ -38,7 +38,7 @@ class SteamGameArtTest {
assertTrue(SteamGameArt.choose(app, game(stock), AddedGameArt.Slot.COVER, option))
val shown = SteamGameArt.withOverrides(app, game(stock))
assertEquals("chosen-p.png", shown.art?.name)
assertTrue(shown.art!!.name.matches(Regex("chosen-p-\\d+\\.png")))
val states = SteamGameArt.describe(app, game(stock))
assertTrue(states.first { it.slot == AddedGameArt.Slot.COVER }.chosen)
assertFalse(states.first { it.slot == AddedGameArt.Slot.BACKGROUND }.chosen)
@@ -60,6 +60,6 @@ class SteamGameArtTest {
SteamGameArt.choose(app, game(), AddedGameArt.Slot.BACKGROUND, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))
val other = Library.SteamGame(730, "Counter-Strike 2", null, "Steam library")
assertTrue(SteamGameArt.withOverrides(app, other) === other)
assertEquals("chosen-hero.png", SteamGameArt.withOverrides(app, game()).hero?.name)
assertTrue(SteamGameArt.withOverrides(app, game()).hero!!.name.startsWith("chosen-hero"))
}
}
@@ -0,0 +1,153 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
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.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class SteamGridTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
private lateinit var grid: File
@Before fun account() {
AddedGamesPublisher.now = true
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
SessionPrefs.setAddedGamesDirs(app, emptyList())
grid = File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config/grid")
}
private fun game() = Library.SteamGame(620, "Portal 2", null, "Steam library")
private fun target() = SteamGrid.target(game(), app)
private fun image(name: String, size: Int) = File(tmp.root, name).apply { writeBytes(ByteArray(size) { 1 }) }
@Test fun aChoiceIsPlacedInTheGridFolderWhileSteamIsClosed() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val picked = image("mine.png", 12)
SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))
assertEquals(12L, File(grid, "620p.png").length())
SteamGameArt.reset(app, game(), AddedGameArt.Slot.COVER)
assertFalse(File(grid, "620p.png").exists())
}
@Test fun artSetInSteamIsTakenOverAndRemovingItInSteamDropsIt() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
grid.mkdirs()
val theirs = File(grid, "620_hero.jpg").apply { writeBytes(ByteArray(20) { 2 }) }
assertTrue(SteamGrid.sync(app, listOf(target())))
assertEquals(20L, SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.BACKGROUND)?.length())
assertEquals(AddedGameArt.STEAM, SteamGameArt.chosenSource(app, 620, AddedGameArt.Slot.BACKGROUND).first)
assertFalse(SteamGrid.sync(app, listOf(target())))
theirs.delete()
assertTrue(SteamGrid.sync(app, listOf(target())))
assertNull(SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.BACKGROUND))
}
@Test fun aChoiceMadeHereIsNotTakenBackAsASteamChange() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val picked = image("mine.png", 12)
SteamGameArt.choose(app, game(), AddedGameArt.Slot.LOGO, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))
File(grid, "620_logo.png").writeBytes(picked.readBytes())
assertFalse(SteamGrid.sync(app, listOf(target())))
assertEquals(AddedGameArt.FILE, SteamGameArt.chosenSource(app, 620, AddedGameArt.Slot.LOGO).first)
}
@Test fun artDroidDeckPlacedItselfIsNotTakenAsAChoice() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
grid.mkdirs()
File(grid, "620p.png").writeBytes(ByteArray(7))
File(grid, ".droiddeck-art.json").writeText("""{"620p.png": 7}""")
assertFalse(SteamGrid.sync(app, listOf(target())))
assertNull(SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER))
}
@Test fun withoutASteamAccountNothingHappens() {
assertFalse(SteamGrid.sync(app, listOf(target())))
}
@Test fun aSteamImageAlreadyThereIsKeptOverAnOlderChoiceOnTheFirstLook() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val old = image("old.png", 5)
val dir = SteamGameArt.dir(app, 620)
assertTrue(AddedGameArt.store(dir, AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, old.path, old.path)))
grid.mkdirs()
File(grid, "620p.png").writeBytes(ByteArray(30) { 3 })
assertTrue(SteamGrid.sync(app, listOf(target())))
assertEquals(30L, SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER)?.length())
assertEquals(30L, File(grid, "620p.png").length())
}
@Test fun aChoiceSurvivesWhenOnlyDroidDecksOwnWriterFileIsInTheFolder() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val picked = image("mine.png", 12)
SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))
File(grid, "620p.png").writeBytes(ByteArray(12) { 9 })
File(grid, ".droiddeck-art.json").writeText("""{"620p.png": 12}""")
assertFalse(SteamGrid.sync(app, listOf(target())))
assertEquals(12L, SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER)?.length())
}
@Test fun anImageSteamCannotReadIsNotWrittenAndTheCurrentOneStays() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val png = image("mine.png", 12)
SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, png.path, png.path))
val ico = image("mine.ico", 6)
SteamGrid.write(app, target(), AddedGameArt.Slot.COVER, ico)
assertEquals(12L, File(grid, "620p.png").length())
assertFalse(File(grid, "620p.ico").exists())
}
@Test fun aChoiceSteamCannotReadIsRefusedAndNotKept() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val ico = image("mine.ico", 6)
assertFalse(SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, ico.path, ico.path)))
assertNull(SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER))
}
@Test fun aStaleFileLeftByAnEarlierFailedWriteIsReplacedByTheLocalChoice() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val picked = image("mine.png", 12)
SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))
SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER)!!.writeBytes(ByteArray(15) { 4 })
assertFalse(SteamGrid.sync(app, listOf(target())))
assertEquals(15L, File(grid, "620p.png").length())
}
@Test fun aRefusedReplacementLeavesTheLastGoodChoiceInPlace() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val png = image("mine.png", 12)
assertTrue(SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, png.path, png.path)))
val kept = SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER)
val ico = image("mine.ico", 6)
assertFalse(SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, ico.path, ico.path)))
assertEquals(kept, SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER))
assertEquals(12L, File(grid, "620p.png").length())
}
@Test fun choicesMadeInTheSameMillisecondGetDifferentNamesAndTheLastOneWins() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val dir = SteamGameArt.dir(app, 620)
val names = (1..5).map { n ->
val image = image("pick$n.png", n)
AddedGameArt.storeNew(dir, AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, image.path, image.path))!!.name
}
assertEquals(5, names.toSet().size)
assertEquals(names.sorted(), names)
assertEquals(5L, AddedGameArt.chosen(dir, AddedGameArt.Slot.COVER)?.length())
}
}
+12 -4
View File
@@ -68,7 +68,7 @@ store search whose result has the same name), then SteamGridDB, then the exe's i
art chooser (an Artwork row) shows the game folder's images, Steam's official piece and
SteamGridDB's best few (fetched when it opens); a tap applies one, "Search SteamGridDB…"
opens a search (it starts with the game's name and opens the match at once when one is
exact; it lists every image SteamGridDB returns for the slot), "Pick image…" takes a file through the same
exact; it lists every image SteamGridDB returns for the slot, up to four pages of 50), "Pick image…" takes a file through the same
picker (png, jpg, jpeg, webp, ico), "Reset to automatic" drops the choice. A choice is a
copy in `files/added-art/<appid>/chosen-<slot>.<ext>`, its source in the preferences.
@@ -86,8 +86,16 @@ SteamGridDB's best few, "Search SteamGridDB…", "Pick image…", "Reset to auto
through `ArtTarget`. A choice is a copy in `files/steam-art/<appid>/chosen-<slot>.<ext>`
(`SteamGameArt`); `Library.steamGames` shows it in place of the librarycache image, and with a
client running the Cover / Background / Logo is also set in Steam
(`SetCustomArtworkForApp` / `ClearCustomArtworkForApp`). Choosing while Steam is closed changes
the launcher only. Icon is launcher-only. Games from GOG, Epic and Amazon are not covered.
(`SetCustomArtworkForApp` / `ClearCustomArtworkForApp`). Icon is launcher-only.
`SteamGrid` keeps both sides in step through the client's grid folder
(`userdata/<account>/config/grid`, `<appid>p`, `_hero`, `_logo`), for Steam and Custom games alike.
A choice is written there at once, so it reaches Steam while the client is closed. Every library
scan (`Library.launchableGames`) compares the folder with what was last written or read
(`grid-<slot>` in the game's art folder): a file Steam changed becomes the choice (source Steam),
a file removed in Steam drops it, and files the shortcuts writer placed itself
(`.droiddeck-art.json`) are not mistaken for choices. A chosen image is stored as
`chosen-<slot>-<time>.<ext>`, so a new choice has a new path and shows at once. Games from GOG, Epic and Amazon are not covered.
Setup › Launcher › Games holds the Artwork switch (automatic fetching) and the
SteamGridDB API key: the user's own, sealed with `CredentialCipher`, wins over the build's
@@ -98,5 +106,5 @@ skipped.
`AddedExesTest`, `AddedGameEditsTest` (bulk add and skip, edits to the listing on a stable
appid, Steam-side read-back, art choice and reset), `AddedGamesCandidatesTest`,
`AddedGameArtLookupTest` (key precedence), `SteamGameArtTest` (Steam game art choice and reset), `GameIdentifierTest`, `PickListingTest`, and
`AddedGameArtLookupTest` (key precedence), `SteamGameArtTest` (Steam game art choice and reset), `SteamGridTest` (grid folder both ways), `GameIdentifierTest`, `PickListingTest`, and
`tools/tests/test_steam_shortcuts.py` for the writer.