Games: old Games folders become entries, not Steam libraries (#542)

Games folders saved before the settings section went away were still
registered as Steam libraries on Automatic, with no way left to remove
them. Once, at app start and before any scan, each game such a folder
listed becomes an entry of its own, keeping everything keyed by its folder
(chosen exe, shortcut appid, name, Start in, launch options, art,
removal); store installs and steamapps are skipped. The folders stay bound
at their old guest paths, so no shortcut changes, and the saved roots are
cleared. A Games folder stays a library only when Steam installed a game
into it; the others leave the library list, and the runtime forgets them.
This commit is contained in:
The412Banner authored and GitHub committed 2026-10-10 16:19:17 -04:00
1 parent fe60b3c279
commit abbcb8e3ad
12 files changed
+266 -30

No files matched your search

@@ -905,6 +905,7 @@ class MainActivity : ComponentActivity() {
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
com.droiddeck.launcher.frontend.GamesFolderMigration.run(this)
SessionLogShare.clear(this)
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
@@ -43,22 +43,17 @@ object AddedGames {
fun folderName(): String = folder.name
}
/** One Games folder and where the session sees it. */
/** A Games folder an earlier build had, and where the session sees it. */
class Root(val host: File, val guest: String)
/**
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
* the host path when two chosen folders share a name (so a folder's guest path, and with it
* every shortcut's appid, does not change when another folder is added or removed).
* The Games folders earlier builds had, bound into the session at the guest paths they had
* then (/root/Games/<name>), so the games in them (entries now, [GamesFolderMigration]) keep
* their paths and shortcuts. Nothing adds one any more.
*/
fun roots(context: Context): List<Root> {
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
return hosts.map { host ->
val name = host.name.ifEmpty { "games" }
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
Root(host, "$GUEST_DIR/$guestName")
}
GamesFolderMigration.run(context)
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP = Regex(
@@ -189,7 +184,8 @@ object AddedGames {
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
val folders = roots(context).map { it.host } + listOfNotNull(
GamesFolderMigration.run(context)
val folders = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
@@ -263,17 +259,19 @@ object AddedGames {
private fun inside(path: String, folder: String) = path.startsWith(folder.trimEnd('/') + "/")
/**
* The game folder [exe] belongs to, without a scan: an entry from the Games tab, a removed
* game, a store install, or the first-level folder under a Games folder (or the Steam library)
* that holds it. Null when it is in none.
* The game folder [exe] belongs to, without a scan: an entry from the Games tab (Games folders
* of earlier builds included), a removed game, a store install, or the first-level folder under
* the Steam library that holds it. Null when it is in none.
*/
private fun ownerFolder(context: Context, exe: File): File? {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = roots(context).map { it.host } + listOfNotNull(
// Games folders of earlier builds are entries now, found above.
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
GameStorage.effective(context)?.let { File(it.path) },
)
@@ -347,12 +345,13 @@ object AddedGames {
/**
* The game folders already listed, without building any game: the first-level folders of the
* Games folders and the Steam library (as [scan] walks them), the Games tab's entries and the
* store installs. Canonical paths; removed games are left out.
* Steam library (as [scan] walks them), the Games tab's entries (Games folders of earlier builds
* included) and the store installs. Canonical paths; removed games are left out.
*/
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
val dirs = roots(context).map { it.host } + listOfNotNull(
GamesFolderMigration.run(context)
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
@@ -48,7 +48,7 @@ object GameFileSync {
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
}
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
}
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
import java.util.zip.CRC32
/**
* Games folders (the roots earlier builds let the user add in Steam settings) become what the
* Games tab's "Add all games in this folder" makes: one entry per game ([AddedExes]). Everything
* a game had is keyed by its own folder and stays as it was - the chosen exe, the shortcut appid,
* the name, Start in, launch options, art choices, a removal. The roots stay bound into the
* session at the same guest paths ([SessionPrefs.gameFolderBinds]), so every exe keeps its path
* inside the session and every Steam shortcut its target; they are no longer Steam libraries.
* Runs whenever saved roots remain (so twice is the same as once) and clears them after.
*/
object GamesFolderMigration {
private const val TAG = "GamesFolderMigration"
@Synchronized
fun run(context: Context) {
val dirs = SessionPrefs.addedGamesDirs(context)
if (dirs.isEmpty()) return
val roots = legacyRoots(SessionPrefs.gameFolderBinds(context).map { it.first } + dirs)
.filter { root -> root.host.path in dirs }
// The binds first: a game's guest path (and with it the shortcut's appid) depends on them.
SessionPrefs.setGameFolderBinds(context, SessionPrefs.gameFolderBinds(context) + roots.map { it.host.path to it.guest })
val removed = SessionPrefs.removedAddedGames(context).toSet()
val entries = AddedExes.list(context).map { it.folder }.toMutableSet()
val storeRoots = StoreInstallRoot.roots(context).map { it.absolutePath }
for (root in roots) {
var n = 0
val steamInstalls = AddedGames.steamInstallDirs(root.host)
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (folder.name.equals("steamapps", ignoreCase = true) || folder.name.lowercase() in steamInstalls) continue
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
entries.add(folder.path)
if (folder.path !in removed) n++
}
Log.i(TAG, "games: migrated ${root.host} → $n games")
}
SessionPrefs.setAddedGamesDirs(context, emptyList())
SessionPrefs.setGamesFoldersMigrated(context)
}
/**
* The guest paths earlier builds gave Games folders: /root/Games/<folder name>, with a short
* hash of the host path when two share a name. Kept exactly, so nothing moves in the session.
*/
internal fun legacyRoots(paths: List<String>): List<AddedGames.Root> {
val hosts = paths.distinct().map { File(it) }
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
return hosts.map { host ->
val name = host.name.ifEmpty { "games" }
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
AddedGames.Root(host, "${AddedGames.GUEST_DIR}/$guestName")
}
}
}
@@ -168,7 +168,7 @@ object Lossless {
val libraries = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
GameStorage.effective(context)?.path?.let(::File),
) + SessionPrefs.addedGamesDirs(context).map(::File)
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
}
@@ -68,12 +68,19 @@ object GameStorage {
class Library(val host: File, val guest: String, val label: String)
/**
* Games folders an earlier build registered as Steam libraries that Steam has installed games
* into (an appmanifest in their steamapps/): those stay libraries, so the games are not lost.
* Any other Games folder is no longer one; the session's library list leaves it out and the
* runtime's droiddeck-steam-library forgets it.
*/
fun gamesFolderLibraries(context: Context): List<Library> {
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
val card = effective(context)?.let { canonical(File(it.path)) }
val sm = context.getSystemService(StorageManager::class.java)
return AddedGames.roots(context).mapNotNull { root ->
val host = canonical(root.host)
if (!hasInstalledGames(root.host)) return@mapNotNull null
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
@@ -83,6 +90,10 @@ object GameStorage {
}
}
/** Steam has installed a game into [root] as a library: a manifest in its steamapps/. */
internal fun hasInstalledGames(root: File): Boolean =
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
/** The label the client shows for a chosen folder: its last name, or the volume's. */
@@ -629,8 +629,9 @@ object SessionPrefs {
}
/**
* The folders of the user's own Windows games (one game per subfolder), any number of them
* from anywhere on the device. The single folder an earlier build kept is carried in.
* The Games folders earlier builds let the user add (one game per subfolder); read only by
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
* even earlier build kept is carried in.
*/
fun addedGamesDirs(context: Context): List<String> {
val p = prefs(context)
@@ -644,6 +645,26 @@ object SessionPrefs {
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
}
/**
* The Games folders earlier builds had (host path, guest path), still bound into the session so
* their games keep their paths; written once by GamesFolderMigration.
*/
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
}
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
}
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
fun setGamesFoldersMigrated(context: Context) {
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
}
/**
* Game folders (by path) the user removed from the added games: a scan skips them, so they
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
@@ -83,7 +83,8 @@ class AddedExesTest {
val result = AddedGames.addExe(app, deep)
assertTrue(result is AddedGames.AddResult.Switched)
assertEquals(deep.path, AddedGames.scan(app).single().exe.path)
assertEquals(0, AddedExes.list(app).size)
// The Games folder's game is an entry of its own (GamesFolderMigration); no second one.
assertEquals(listOf(File(games, "Example").path), AddedExes.list(app).map { it.folder })
}
@Test fun anExeTheSessionCannotSeeIsNotAdded() {
@@ -0,0 +1,102 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.StoreGameSidecar
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
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
/** Games folders from earlier builds become entries, every game keeping what it had. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GamesFolderMigrationTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
private fun exe(root: File, rel: String) = File(root, rel).apply { parentFile!!.mkdirs(); writeBytes(ByteArray(16)) }
@Before fun setUp() {
SessionPrefs.setGameStorage(app, "", "")
AddedGamesPublisher.now = true
}
@Test fun eachGameBecomesAnEntryKeepingItsExeAppidNameAndRemoval() {
val pc = tmp.newFolder("PC")
val alpha = File(pc, "Alpha"); exe(pc, "Alpha/Alpha.exe")
val beta = File(pc, "Beta"); exe(pc, "Beta/Beta.exe"); val pick = exe(pc, "Beta/bin/Beta64.exe")
val gone = File(pc, "Gone"); exe(pc, "Gone/Gone.exe")
exe(pc, "steamapps/common/Installed/Installed.exe")
val store = File(pc, "Store"); exe(pc, "Store/Store.exe")
StoreGameSidecar.file(store).writeText("""{"store":"gog","id":"1","title":"Store Game","exe":"Store.exe"}""")
File(pc, "Notes").mkdirs()
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
// What earlier scans had kept, by folder.
SessionPrefs.setAddedGameExe(app, beta.path, pick.path)
SessionPrefs.addedGameAppId(app, alpha.path, 0x8123_4567L)
SessionPrefs.addedGameAppId(app, beta.path, 0x8765_4321L)
SessionPrefs.setAddedGameName(app, alpha.path, "Alpha GOTY")
SessionPrefs.setAddedGameRemoved(app, gone.path, true)
GamesFolderMigration.run(app)
assertEquals(emptyList<String>(), SessionPrefs.addedGamesDirs(app))
assertTrue(SessionPrefs.gamesFoldersMigrated(app))
val entries = AddedExes.list(app).associateBy { it.folder }
assertEquals(setOf(alpha.path, beta.path, gone.path), entries.keys)
assertTrue(entries.getValue(beta.path).picked)
assertEquals(pick.path, entries.getValue(beta.path).exe)
assertFalse(entries.getValue(alpha.path).picked)
val games = AddedGames.scan(app).associateBy { it.folder.path }
assertEquals(setOf(alpha.path, beta.path), games.keys)
assertEquals(0x8123_4567L, games.getValue(alpha.path).appId)
assertEquals(0x8765_4321L, games.getValue(beta.path).appId)
assertEquals("Alpha GOTY", games.getValue(alpha.path).name)
assertEquals(pick.path, games.getValue(beta.path).exe.path)
// The same path inside the session as before: the shortcut keeps its target.
assertEquals(AddedGames.GUEST_DIR + "/PC/Alpha/Alpha.exe", games.getValue(alpha.path).guestExe)
assertEquals(AddedGames.GUEST_DIR + "/PC/Beta/bin/Beta64.exe", games.getValue(beta.path).guestExe)
assertTrue(SessionPrefs.removedAddedGames(app).contains(gone.path))
// Not a library any more.
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
@Test fun runningAgainChangesNothing() {
val pc = tmp.newFolder("PC")
exe(pc, "Alpha/Alpha.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
GamesFolderMigration.run(app)
val entries = AddedExes.list(app).map { it.folder to it.exe }
val binds = SessionPrefs.gameFolderBinds(app)
val before = AddedGames.scan(app).map { it.appId to it.guestExe }
GamesFolderMigration.run(app)
GamesFolderMigration.run(app)
assertEquals(entries, AddedExes.list(app).map { it.folder to it.exe })
assertEquals(binds, SessionPrefs.gameFolderBinds(app))
assertEquals(before, AddedGames.scan(app).map { it.appId to it.guestExe })
assertEquals(listOf(pc.path to AddedGames.GUEST_DIR + "/PC"), binds)
}
@Test fun twoFoldersOfOneNameKeepTheirOwnGuestPaths() {
val a = File(tmp.newFolder("a"), "Games").apply { mkdirs() }
val b = File(tmp.newFolder("b"), "Games").apply { mkdirs() }
exe(a, "One/One.exe")
exe(b, "Two/Two.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(a.path, b.path))
val guests = GamesFolderMigration.legacyRoots(listOf(a.path, b.path)).map { it.guest }
val games = AddedGames.scan(app).associateBy { it.name }
assertEquals(guests[0] + "/One/One.exe", games.getValue("One").guestExe)
assertEquals(guests[1] + "/Two/Two.exe", games.getValue("Two").guestExe)
assertTrue(guests[0] != guests[1])
}
}
@@ -17,10 +17,14 @@ class GameStorageTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
@Test fun gamesFoldersBecomeLibrariesOnAutomatic() {
@Test fun onlyAGamesFolderSteamInstalledIntoStaysALibrary() {
val pc = tmp.newFolder("PC")
File(pc, "steamapps").mkdirs()
File(pc, "steamapps/appmanifest_220.acf").writeText("\"AppState\" { \"appid\" \"220\" }")
val plain = tmp.newFolder("Games")
File(plain, "steamapps/common").mkdirs()
val missing = File(tmp.root, "Unplugged")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, missing.path))
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, plain.path, missing.path))
val libraries = GameStorage.gamesFolderLibraries(app)
assertEquals(listOf(pc), libraries.map { it.host })
assertEquals(listOf(AddedGames.GUEST_DIR + "/PC"), libraries.map { it.guest })
@@ -28,7 +32,10 @@ class GameStorageTest {
}
@Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path))
val pc = tmp.newFolder("PC")
File(pc, "steamapps").mkdirs()
File(pc, "steamapps/appmanifest_220.acf").writeText("x")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen")
@@ -36,7 +43,9 @@ class GameStorageTest {
}
@Test fun namesTheClientCannotStoreAreLeftOut() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path))
val odd = listOf(tmp.newFolder("Quote\"d"), tmp.newFolder("Back\\slash"))
odd.forEach { File(it, "steamapps").mkdirs(); File(it, "steamapps/appmanifest_220.acf").writeText("x") }
SessionPrefs.setAddedGamesDirs(app, odd.map { it.path })
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
}
+9 -2
View File
@@ -23,8 +23,15 @@ Steam client that runs under `droiddeck-proton-arm64`. They show with the Custom
already listed are counted, not added. A summary follows: games added, how many were
there already, one row per game (cover, name, exe, "?" when unsure); a row opens that
game's editor, straight on Target when unsure, with back to the summary.
- Games folders added before this (Steam settings had a Games section) keep listing their
games; there is no place to add a new one.
- Games folders added before this (Steam settings had a Games section) are migrated once
(`GamesFolderMigration`, at app start and before any scan): each game in one becomes an entry
as "Add all games in this folder" would make it, keeping everything keyed by its folder (the
chosen exe, the shortcut appid, name, Start in, launch options, art, a removal). The folders
stay bound into the session at the guest paths they had (`/root/Games/<name>`,
`SessionPrefs.gameFolderBinds`), so no exe path or shortcut changes. They are no longer Steam
libraries - the session's library list leaves them out and `droiddeck-steam-library` forgets
them - unless Steam installed a game into one (a manifest in its `steamapps/`), which then
stays a library. Log: `games: migrated <root> → N games`.
## Exe and name
+19
View File
@@ -7,6 +7,25 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-10 - `fix/games-folder-migration`: Games folders become entries, not Steam libraries
- **Seen on a device:** Steam's Install dialog listed "Games (Internal shared storage)" and "Games
(android)", the user's two Games folders saved before #492, still registered as libraries
while Second library is Automatic; with the settings section gone they could not be removed.
- **Migration** (`GamesFolderMigration`, once at app start and before any scan, idempotent):
every game a Games folder listed becomes an entry (`AddedExes`), keeping its chosen exe, stored
appid, name / Start in / launch-option overrides, art choices and removal (all keyed by its
folder). Store installs and `steamapps` are skipped. The folders stay bound at their old guest
paths (`SessionPrefs.gameFolderBinds`), so the listing and every Steam shortcut are unchanged;
`addedGamesDirs` is cleared. Log: `games: migrated <root> → N games`.
- **Libraries:** `GameStorage.gamesFolderLibraries` returns only a Games folder Steam installed
into (an appmanifest in its `steamapps/`); the others drop out of `BL_STEAM_LIBRARIES` and the
runtime's `droiddeck-steam-library` forgets the `/root/Games/...` entries it had registered.
The user's two folders have `steamapps/` without manifests (checked on the device), so both go.
- **Tests:** `GamesFolderMigrationTest` (exe / appid / name / removal kept, guest paths unchanged,
idempotent, store and steamapps skipped, libraries empty, same-name folders), `GameStorageTest`
updated. Not yet on a device.
## 2026-10-09 - `feat/games-add-edit`: add games from the Games tab, edit them like Steam's Properties
- **Steam ⚙ settings:** the Games section is gone (it was a feature, not a setting). Games