mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
fix: Keep game art in step with Steam's grid folder; apply review fixes (#948)
This commit is contained in:
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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user