mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213dd39f6f | ||
|
|
e76b49f64a | ||
|
|
6b340b78fb | ||
|
|
31e6f524c2 | ||
|
|
4956c63db8 |
No files matched your search
@@ -302,6 +302,28 @@ class MainActivity : ComponentActivity() {
|
||||
GameSaves.savesDir().mkdirs()
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
|
||||
}
|
||||
private var pendingAddedGame: String? = null
|
||||
private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val folder = pendingAddedGame ?: return@registerForActivityResult
|
||||
pendingAddedGame = null
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
updateGameImport { com.droiddeck.launcher.frontend.AddedGames.changeExecutable(this, folder, path) }
|
||||
}
|
||||
}
|
||||
private var addedGamesDirs by mutableStateOf<List<String>>(emptyList())
|
||||
private var addedGamesArt by mutableStateOf(true)
|
||||
@Volatile private var artFetchRunning = false
|
||||
|
||||
private fun updateGameImport(action: () -> Unit) {
|
||||
Thread({
|
||||
val result = runCatching(action)
|
||||
ui.post {
|
||||
if (result.isSuccess) { refreshAddedGames(); refresh() }
|
||||
else android.widget.Toast.makeText(this, R.string.games_import_failed, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "game-import").start()
|
||||
}
|
||||
|
||||
/** The Games tab's +: the picked .exe becomes a game (or selects / switches the one it belongs to). */
|
||||
private fun addGameExe(path: String) {
|
||||
val exe = java.io.File(path)
|
||||
@@ -326,8 +348,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}, "add-game-exe").start()
|
||||
}
|
||||
private var addedGamesArt by mutableStateOf(true)
|
||||
@Volatile private var artFetchRunning = false
|
||||
/** The user entered a SteamGridDB API key of their own. */
|
||||
private var sgdbUserKey by mutableStateOf(false)
|
||||
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
@@ -478,6 +498,10 @@ class MainActivity : ComponentActivity() {
|
||||
return false
|
||||
}
|
||||
if (SessionState.running) {
|
||||
if (game.importPending) {
|
||||
android.widget.Toast.makeText(this, R.string.games_pending_steam, android.widget.Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
|
||||
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
|
||||
return true
|
||||
@@ -609,6 +633,17 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
onAddGame = { selection -> updateGameImport { com.droiddeck.launcher.frontend.AddedGames.add(this, selection) } },
|
||||
onImportGamesFolder = { folder -> updateGameImport {
|
||||
SessionPrefs.setAddedGamesDirs(this, SessionPrefs.addedGamesDirs(this) + folder.path)
|
||||
ui.post { addedGamesDirs = SessionPrefs.addedGamesDirs(this) }
|
||||
} },
|
||||
onChangeGameExe = { game ->
|
||||
game.gameFiles?.let { folder ->
|
||||
pendingAddedGame = folder.path
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), getString(R.string.games_change_exe), folder.path))
|
||||
}
|
||||
},
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
@@ -1356,6 +1391,7 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
},
|
||||
|
||||
onDeckyInstall = { release -> decky.installDecky(release) },
|
||||
onDeckyCheck = { decky.refreshDecky() },
|
||||
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
|
||||
@@ -1582,10 +1618,10 @@ class MainActivity : ComponentActivity() {
|
||||
// One update with the whole list: the wall places games by their position in it, so a
|
||||
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
|
||||
// covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val games = Library.launchableGames(this)
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
@@ -29,9 +30,10 @@ object AddedGames {
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
val nonSteam: Boolean = false,
|
||||
/** Where the game came from: a store's id, or [Library.ADDED] for a folder the user added. */
|
||||
val source: String = Library.ADDED,
|
||||
/** The store's own id for the game, from its sidecar; null for a plain added folder. */
|
||||
/** The store's own id for a store install; null for a plain added folder. */
|
||||
val storeId: String? = null,
|
||||
/** The exe is the picker's guess and nothing clearly won ([GameExePicker.Ranking.uncertain]). */
|
||||
val exeUncertain: Boolean = false,
|
||||
@@ -46,6 +48,65 @@ object AddedGames {
|
||||
/** One Games folder and where the session sees it. */
|
||||
class Root(val host: File, val guest: String)
|
||||
|
||||
data class Selection(val folder: File, val exe: File, val name: String = folder.name)
|
||||
private data class Entry(val selection: Selection, val id: Long)
|
||||
|
||||
private fun entries(context: Context): List<Entry> = runCatching {
|
||||
val array = JSONArray(SessionPrefs.addedGameEntries(context))
|
||||
List(array.length()) { i ->
|
||||
val o = array.getJSONObject(i)
|
||||
Entry(Selection(File(o.getString("folder")), File(o.getString("exe")), o.getString("name")), o.getLong("id"))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Explicit executable choices keep their identity when their name or target changes. */
|
||||
fun add(context: Context, selection: Selection) {
|
||||
require(selection.name.isNotBlank())
|
||||
require(selection.exe.isFile && selection.exe.extension.equals("exe", true))
|
||||
require(selection.folder.isDirectory && selection.exe.canonicalPath.startsWith(selection.folder.canonicalPath + "/"))
|
||||
val old = entries(context)
|
||||
val key = selection.folder.canonicalPath
|
||||
val existing = old.firstOrNull { it.selection.folder.canonicalPath == key }
|
||||
val scanned = scan(context).firstOrNull { it.folder.canonicalPath == key }
|
||||
val id = existing?.id ?: scanned?.appId ?: (CRC32().apply {
|
||||
update((selection.exe.canonicalPath + selection.name).toByteArray())
|
||||
}.value or 0x80000000L)
|
||||
val array = JSONArray()
|
||||
for (entry in old.filterNot { it.selection.folder.canonicalPath == key } + Entry(selection, id)) {
|
||||
array.put(JSONObject().put("folder", entry.selection.folder.path).put("exe", entry.selection.exe.path)
|
||||
.put("name", entry.selection.name).put("id", entry.id))
|
||||
}
|
||||
SessionPrefs.setAddedGameEntries(context, array.toString())
|
||||
SessionPrefs.setAddedGameExe(context, selection.folder.path, selection.exe.path)
|
||||
}
|
||||
|
||||
fun changeExecutable(context: Context, folder: String, exe: String) {
|
||||
val current = scan(context).firstOrNull { it.folder.path == folder } ?: return
|
||||
require(File(exe).isFile && exe.endsWith(".exe", true))
|
||||
require(File(exe).canonicalPath.startsWith(current.folder.canonicalPath + "/"))
|
||||
SessionPrefs.setAddedGameId(context, folder, current.appId)
|
||||
SessionPrefs.setAddedGameExe(context, folder, exe)
|
||||
}
|
||||
|
||||
fun suggestedFolder(exe: File): File {
|
||||
var folder = requireNotNull(exe.parentFile)
|
||||
while (folder.name.lowercase() in setOf("bin", "binaries", "win64", "win32", "x64", "x86", "amd64")) {
|
||||
folder = folder.parentFile ?: break
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
/** Accept one game or a collection; preview only launchable games, not installers. */
|
||||
fun preview(folder: File): List<Selection> {
|
||||
val children = folder.listFiles().orEmpty()
|
||||
if (children.any { it.isFile && it.extension.equals("exe", true) && !SKIP.matches(it.name) } ||
|
||||
children.any { it.isDirectory && (it.name.equals("Binaries", true) || it.name.equals("bin", true)) }) {
|
||||
return candidates(folder).firstOrNull()?.let { listOf(Selection(folder, it)) } ?: emptyList()
|
||||
}
|
||||
return children.filter { it.isDirectory }.sortedBy { it.name.lowercase() }
|
||||
.mapNotNull { game -> candidates(game).firstOrNull()?.let { Selection(game, it) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -54,11 +115,14 @@ object AddedGames {
|
||||
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 collections = 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")
|
||||
}
|
||||
return collections + entries(context).map { it.selection.folder }.distinctBy { it.canonicalPath }
|
||||
.filter { folder -> collections.none { folder.canonicalPath == it.host.canonicalPath || folder.canonicalPath.startsWith(it.host.canonicalPath + "/") } }
|
||||
.map { Root(it, "$GUEST_DIR/game-" + "%08x".format(CRC32().apply { update(it.canonicalPath.toByteArray()) }.value)) }
|
||||
}
|
||||
|
||||
private val SKIP = Regex(
|
||||
@@ -71,22 +135,24 @@ object AddedGames {
|
||||
|
||||
/** Where a host path appears inside the session, or null when the session cannot see it. */
|
||||
fun guestPath(context: Context, host: File): String? {
|
||||
val path = host.absolutePath
|
||||
val path = host.canonicalPath
|
||||
// The Games folders are bound on their own, so a folder anywhere - an SD card, a USB
|
||||
// drive - works without being inside one of the other binds.
|
||||
for (root in roots(context)) {
|
||||
val dir = root.host.absolutePath
|
||||
val dir = root.host.canonicalPath
|
||||
if (path == dir) return root.guest
|
||||
if (path.startsWith("$dir/")) return root.guest + "/" + path.removePrefix("$dir/")
|
||||
}
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
val dir = File(roms).canonicalPath
|
||||
if (path.startsWith("$dir/")) return "/root/ROMs/" + path.removePrefix("$dir/")
|
||||
}
|
||||
// A store root on a card has a bind of its own, before the library rule: the same folder
|
||||
// keeps the same guest path (and appid) whether or not that card is also the Steam library.
|
||||
StoreInstallRoot.externalGuestPath(context, host)?.let { return it }
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
val dir = File(lib.path).canonicalPath
|
||||
if (path.startsWith("$dir/")) return "$LIBRARY/" + path.removePrefix("$dir/")
|
||||
}
|
||||
// Inside the runtime's own tree (store installs on internal storage live at
|
||||
// root/Games/Stores): the tree is the session's / and needs no bind.
|
||||
@@ -98,7 +164,7 @@ object AddedGames {
|
||||
return null
|
||||
}
|
||||
|
||||
/** The .exe files a game folder offers, best first ([GameExePicker]); also what its exe box lists, in that order. */
|
||||
/** The .exe files a game folder offers, best first ([GameExePicker]). */
|
||||
fun candidates(folder: File): List<File> = rank(folder).exes
|
||||
|
||||
/** [GameExePicker]'s ranking of [folder], without the names this list never offers. */
|
||||
@@ -163,7 +229,13 @@ object AddedGames {
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
val explicit = entries(context)
|
||||
val explicitFolders = explicit.map { it.selection.folder.canonicalPath }.toSet()
|
||||
for (entry in explicit) {
|
||||
val selection = entry.selection
|
||||
scanGame(context, selection.folder, out, selection, entry.id)
|
||||
}
|
||||
val folders = SessionPrefs.addedGamesDirs(context).map(::File) + listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
@@ -171,11 +243,14 @@ object AddedGames {
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
val selectedFolders = if (dir in SessionPrefs.addedGamesDirs(context).map(::File)) preview(dir).map { it.folder }
|
||||
else dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()
|
||||
for (folder in selectedFolders) {
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
// Removed from the list by the user (the folder stays on disk); restored from the settings page.
|
||||
if (runCatching { folder.canonicalPath in explicitFolders }.getOrDefault(false)) continue
|
||||
// Removed from the list by the user (the folder stays on disk); restored from settings.
|
||||
if (folder.path in removed) continue
|
||||
// The store install root sits inside the Games storage; its games are scanned below, per store.
|
||||
// Store installs are scanned below, per store.
|
||||
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
@@ -360,47 +435,47 @@ object AddedGames {
|
||||
|
||||
private val INSTALL_DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** [single]: the entry a game added from the Games tab has; its exe stands in when no other is chosen and the user picked it. */
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
|
||||
private fun scanGame(
|
||||
context: Context, folder: File, out: MutableList<Game>,
|
||||
selection: Selection? = null, id: Long? = null, single: AddedExes.Entry? = null,
|
||||
) {
|
||||
run {
|
||||
// A store install carries a sidecar; every one of them is a shortcut.
|
||||
val sidecar = StoreGameSidecar.read(folder)
|
||||
val ranking = rank(folder)
|
||||
val candidates = ranking.exes
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val saved = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile }
|
||||
// A store install says which exe it is; the folder's guess only when that file is gone.
|
||||
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
// The store's launcher .bat stands in front of the exe when the game needs arguments or
|
||||
// environment; it is what the shortcut runs. Dropped when the user picked another exe.
|
||||
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val found = if (selection != null) saved ?: selection.exe.takeIf { it.isFile } ?: return
|
||||
else saved ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
|
||||
val launch = if (selection == null && saved == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
|
||||
val foundGuest = guestPath(context, launch)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
|
||||
// A store install's title; else the title the disk gives (the shortcut's appid is kept
|
||||
// per folder from its first scan, so a name found later does not move it); else the folder.
|
||||
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val autoName = selection?.name ?: sidecar?.title ?: identifiedName(folder, found) ?: folder.name
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + autoName).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val appId = when {
|
||||
selection != null -> id ?: SessionPrefs.addedGameId(context, folder.path).takeIf { it != 0L } ?: (crc or 0x80000000L)
|
||||
else -> SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
}
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
// Name, Start in and launch options edited in Steam's own Properties become the app's.
|
||||
if (sidecar == null && record != null) adoptSteamEdits(context, folder, record)
|
||||
val name = SessionPrefs.addedGameName(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() } ?: autoName
|
||||
val adopted = guestPath(context, folder)
|
||||
if (selection == null && sidecar == null && record != null) adoptSteamEdits(context, folder, record)
|
||||
val name = selection?.name ?: SessionPrefs.addedGameName(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() } ?: autoName
|
||||
val adopted = if (selection == null) guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) } else null
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = SessionPrefs.addedGameStartIn(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() }
|
||||
?: guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
val guestDir = if (selection != null) guestPath(context, found.parentFile ?: folder) ?: return
|
||||
else SessionPrefs.addedGameStartIn(context, folder.path).takeIf { sidecar == null && it.isNotEmpty() }
|
||||
?: guestPath(context, (if (adopted != null) adopted else launch).parentFile ?: folder) ?: return
|
||||
val steamId = if (selection != null) null else config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
|
||||
nonSteam = selection != null,
|
||||
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
|
||||
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
|
||||
exeUncertain = selection == null && sidecar == null && saved == null && adopted == null && ranking.uncertain,
|
||||
launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "",
|
||||
single = single != null))
|
||||
}
|
||||
@@ -419,6 +494,14 @@ object AddedGames {
|
||||
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
fun pending(context: Context, game: Game): Boolean = runCatching {
|
||||
val array = JSONArray(File(context.filesDir, "session/added-games.json").readText())
|
||||
(0 until array.length()).none { i ->
|
||||
val o = array.getJSONObject(i)
|
||||
o.getLong("appid") == game.appId && o.getString("exe") == game.guestExe && o.getString("name") == game.name
|
||||
}
|
||||
}.getOrDefault(true)
|
||||
|
||||
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||
}.getOrNull()
|
||||
@@ -471,6 +554,7 @@ object AddedGames {
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"nonSteam\":").append(g.nonSteam)
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
|
||||
@@ -24,6 +24,7 @@ object Library {
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
val icon: File? = null,
|
||||
val importPending: Boolean = false,
|
||||
/** Where the game came from: [SOURCE_STEAM], a store's id (gog, epic, amazon) or [ADDED] for a folder the user added. */
|
||||
val source: String = SOURCE_STEAM,
|
||||
/** The store's own id for a store install; null otherwise. */
|
||||
@@ -166,6 +167,7 @@ object Library {
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
importPending = AddedGames.pending(context, g),
|
||||
source = g.source, storeId = g.storeId,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ object LibraryCache {
|
||||
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
|
||||
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
|
||||
icon = o.file("icon"),
|
||||
importPending = o.optBoolean("importPending"),
|
||||
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
|
||||
storeId = o.optString("storeId").ifEmpty { null },
|
||||
)
|
||||
@@ -38,6 +39,7 @@ object LibraryCache {
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.put("importPending", g.importPending)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon)
|
||||
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
|
||||
)
|
||||
|
||||
@@ -627,6 +627,18 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
|
||||
}
|
||||
|
||||
fun addedGameEntries(context: Context): String = prefs(context).getString("addedGameEntries", "[]") ?: "[]"
|
||||
|
||||
fun setAddedGameEntries(context: Context, entries: String) {
|
||||
prefs(context).edit().putString("addedGameEntries", entries).apply()
|
||||
}
|
||||
|
||||
fun addedGameId(context: Context, folder: String): Long = prefs(context).getLong("addedId:$folder", 0L)
|
||||
|
||||
fun setAddedGameId(context: Context, folder: String, id: Long) {
|
||||
prefs(context).edit().putLong("addedId:$folder", id).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.
|
||||
@@ -673,7 +685,7 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("addedAdopted:$field:$folderPath", value).apply()
|
||||
}
|
||||
|
||||
/** Where an added game's art for [slot] comes from when the user chose it ("folder", "steam", "sgdb", "file"); "" = automatic. */
|
||||
/** Where an added game's art for [slot] comes from when the user chose it; "" = automatic. */
|
||||
fun addedGameArtSource(context: Context, folderPath: String, slot: String): String =
|
||||
prefs(context).getString("addedArt:$slot:$folderPath", "") ?: ""
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
internal fun AddGameDialog(onDismiss: () -> Unit, onAdd: (AddedGames.Selection) -> Unit, onImport: (File) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var executable by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var gameFolder by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var importFolder by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var advanced by rememberSaveable { mutableStateOf(false) }
|
||||
val pickExecutable = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(result.data)?.let { exe ->
|
||||
executable = exe.path
|
||||
gameFolder = AddedGames.suggestedFolder(exe).path
|
||||
name = AddedGames.suggestedFolder(exe).name.takeUnless { it.equals("Download", true) || it.equals("Downloads", true) }
|
||||
?: exe.nameWithoutExtension
|
||||
importFolder = null
|
||||
}
|
||||
}
|
||||
val pickImport = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedPath(result.data)?.let {
|
||||
importFolder = it; executable = null
|
||||
}
|
||||
}
|
||||
val pickGameFolder = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedPath(result.data)?.let { gameFolder = it }
|
||||
}
|
||||
val preview by produceState<List<AddedGames.Selection>?>(null, importFolder) {
|
||||
value = null
|
||||
value = importFolder?.let { withContext(Dispatchers.IO) { AddedGames.preview(File(it)) } }
|
||||
}
|
||||
val file = executable?.let(::File)
|
||||
val folder = gameFolder?.let(::File)
|
||||
val valid = file != null && folder != null && name.isNotBlank() &&
|
||||
file.canonicalPath.startsWith(folder.canonicalPath + "/")
|
||||
AppDialog(shown, close, "addGame", wide = false) {
|
||||
DialogHeader(stringResource(R.string.content_games), stringResource(R.string.games_add))
|
||||
PrimaryButton(stringResource(if (file == null) R.string.games_choose_exe else R.string.games_change_exe),
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
pickExecutable.launch(InAppFilePicker.buildIntent(context, listOf("exe"), context.getString(R.string.games_choose_exe), gameFolder))
|
||||
}
|
||||
SecondaryButton(stringResource(R.string.games_import_folder), modifier = Modifier.fillMaxWidth()) {
|
||||
pickImport.launch(InAppFilePicker.buildDirIntent(context, context.getString(R.string.games_import_folder), importFolder))
|
||||
}
|
||||
if (file != null) {
|
||||
AdbTextField(name, { name = it.take(80) }, stringResource(R.string.add_app_name), KeyboardType.Text, ImeAction.Done, compact = true)
|
||||
Text(file.path, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
SecondaryButton(stringResource(R.string.drawer_effects_advanced), compact = true) { advanced = !advanced }
|
||||
if (advanced) ActionRow(stringResource(R.string.games_game_folder), gameFolder, stringResource(R.string.add_app_change_file)) {
|
||||
pickGameFolder.launch(InAppFilePicker.buildDirIntent(context, context.getString(R.string.games_game_folder), gameFolder))
|
||||
}
|
||||
}
|
||||
if (importFolder != null) {
|
||||
Text(importFolder!!, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
|
||||
when {
|
||||
preview == null -> Small(stringResource(R.string.games_scanning))
|
||||
preview!!.isEmpty() -> Note(stringResource(R.string.games_import_empty))
|
||||
else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (game in preview!!) {
|
||||
Text(game.name, color = MaterialTheme.colorScheme.onBackground)
|
||||
Small(game.exe.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogFooter(
|
||||
note = if (file != null && !valid && name.isNotBlank()) stringResource(R.string.games_exe_outside_folder) else null,
|
||||
confirm = if (importFolder != null) pluralStringResource(R.plurals.games_import_count, preview?.size ?: 0, preview?.size ?: 0) else stringResource(R.string.add_app_confirm),
|
||||
enabled = if (importFolder != null) !preview.isNullOrEmpty() else valid,
|
||||
onCancel = close,
|
||||
onConfirm = {
|
||||
if (importFolder != null) onImport(File(importFolder!!))
|
||||
else if (valid) onAdd(AddedGames.Selection(folder!!, file!!, name.trim()))
|
||||
close()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -69,6 +70,22 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
@Composable
|
||||
internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
|
||||
var adding by rememberSaveable { androidx.compose.runtime.mutableStateOf(false) }
|
||||
if (adding) AddGameDialog(onDismiss = { adding = false }, onAdd = a.onAddGame, onImport = a.onImportGamesFolder)
|
||||
Column(modifier) {
|
||||
if (!s.shortcutPicker) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
|
||||
PrimaryButton(stringResource(R.string.games_add), enabled = !s.busy, compact = true, icon = Icons.Filled.Add) { adding = true }
|
||||
}
|
||||
}
|
||||
GamesContent(s, a, selected, onSelect, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GamesContent(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
|
||||
val narrow = LocalNarrowPane.current
|
||||
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
|
||||
@@ -148,8 +165,10 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) {
|
||||
|
||||
@Composable
|
||||
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit) {
|
||||
if (g.importPending) Note(stringResource(R.string.games_pending_steam))
|
||||
Actions {
|
||||
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
|
||||
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch),
|
||||
enabled = !s.busy && (s.shortcutPicker || !s.sessionRunning || !g.importPending), main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
|
||||
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
|
||||
SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) }
|
||||
}
|
||||
@@ -159,6 +178,9 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
|
||||
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
|
||||
}
|
||||
if (!s.shortcutPicker) GameShortcutMenu(g, a)
|
||||
if (!s.shortcutPicker && g.library == Library.ADDED && g.gameFiles != null) {
|
||||
SecondaryButton(stringResource(R.string.games_change_exe), compact = true) { a.onChangeGameExe(g) }
|
||||
}
|
||||
// A game added in DroidDeck (Custom): its editor, Steam's Properties for a non-Steam game.
|
||||
if (!s.shortcutPicker && g.library == Library.ADDED && g.source == Library.ADDED && g.gameFiles != null) EditGameButton { onEdit(g) }
|
||||
BusyChip(s)
|
||||
|
||||
@@ -152,6 +152,9 @@ class FrontEndActions(
|
||||
val onPlay: () -> Unit,
|
||||
val onPlayDesktopUi: () -> Unit,
|
||||
val onSteamGame: (Library.SteamGame) -> Unit,
|
||||
val onAddGame: (com.droiddeck.launcher.frontend.AddedGames.Selection) -> Unit = {},
|
||||
val onImportGamesFolder: (File) -> Unit = {},
|
||||
val onChangeGameExe: (Library.SteamGame) -> Unit = {},
|
||||
val onGameShortcut: (Library.SteamGame) -> Unit = {},
|
||||
val onExportGameFile: (Library.SteamGame) -> Unit = {},
|
||||
val onSyncGameFiles: () -> Unit = {},
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<string name="hud_fg_failed">%1$s: engine failed</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · starting</string>
|
||||
<string name="added_games_import_hint">Choose one Windows game folder or a folder containing several games. Games you own on Steam use their Steam entries; the rest become non-Steam shortcuts.</string>
|
||||
<string name="second_library_import_hint">Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts.</string>
|
||||
<string name="game_env_title">Game environment</string>
|
||||
<string name="game_env_hint">Environment variables for Proton games.</string>
|
||||
@@ -930,6 +931,19 @@
|
||||
<string name="games_last_played">Last played %1$s</string>
|
||||
<string name="games_internal">Internal storage</string>
|
||||
<string name="games_added">Added game</string>
|
||||
<string name="games_choose_exe">Choose executable</string>
|
||||
<string name="games_change_exe">Change executable</string>
|
||||
<string name="games_import_folder">Import folder</string>
|
||||
<string name="games_game_folder">Game folder</string>
|
||||
<string name="games_scanning">Scanning folder…</string>
|
||||
<string name="games_import_empty">No game executables found. Choose an executable instead.</string>
|
||||
<string name="games_exe_outside_folder">Choose a game folder that contains the executable.</string>
|
||||
<plurals name="games_import_count">
|
||||
<item quantity="one">Import %1$d game</item>
|
||||
<item quantity="other">Import %1$d games</item>
|
||||
</plurals>
|
||||
<string name="games_pending_steam">Available next Steam start</string>
|
||||
<string name="games_import_failed">Couldn’t add the game. Check the selected files and folder.</string>
|
||||
<string name="games_custom">Custom game</string>
|
||||
<string name="games_store_storage">Games storage</string>
|
||||
<string name="gpu_auto_hint">The best pair for this GPU, kept up to date</string>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.junit.Assert.*
|
||||
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 AddedGameImportTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun reset() {
|
||||
SessionPrefs.setAddedGamesDirs(context, emptyList())
|
||||
SessionPrefs.setAddedGameEntries(context, "[]")
|
||||
SessionPrefs.setGameStorage(context, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
File(context.filesDir, "session/added-games.json").delete()
|
||||
}
|
||||
|
||||
private fun exe(folder: File, path: String) = File(folder, path).apply { parentFile!!.mkdirs(); writeText("game") }
|
||||
|
||||
@Test fun previewsEitherOneGameOrACollectionAndSkipsInstallers() {
|
||||
val collection = tmp.newFolder("Games")
|
||||
val first = File(collection, "Example").apply { mkdirs() }
|
||||
val second = File(collection, "Other").apply { mkdirs() }
|
||||
exe(first, "Example.exe")
|
||||
exe(first, "setup.exe")
|
||||
exe(second, "Binaries/Win64/Other.exe")
|
||||
exe(File(collection, "Installer"), "setup.exe")
|
||||
assertEquals(listOf("Example", "Other"), AddedGames.preview(collection).map { it.name })
|
||||
assertEquals(first, AddedGames.preview(first).single().folder)
|
||||
assertEquals(second, AddedGames.preview(second).single().folder)
|
||||
}
|
||||
|
||||
@Test fun individuallySelectedNestedExecutableIsBoundAndListedImmediately() {
|
||||
val folder = tmp.newFolder("Example")
|
||||
val file = exe(folder, "Binaries/Win64/Custom.exe")
|
||||
assertEquals(folder, AddedGames.suggestedFolder(file))
|
||||
AddedGames.add(context, AddedGames.Selection(folder, file, "My mod"))
|
||||
val game = AddedGames.scan(context).single()
|
||||
assertEquals("My mod", game.name)
|
||||
assertTrue(game.nonSteam)
|
||||
assertNull(game.steamAppId)
|
||||
assertEquals(folder, AddedGames.roots(context).single().host)
|
||||
assertTrue(game.guestExe.endsWith("/Binaries/Win64/Custom.exe"))
|
||||
assertTrue(AddedGames.pending(context, game))
|
||||
assertEquals("My mod", Library.launchableGames(context).single().name)
|
||||
AddedGames.writeListing(context, listOf(game))
|
||||
assertFalse(AddedGames.pending(context, game))
|
||||
}
|
||||
|
||||
@Test fun steamLibraryContainerIsNotImportedAsAGame() {
|
||||
val library = tmp.newFolder("Library")
|
||||
exe(library, "steamapps/common/Owned/Owned.exe")
|
||||
val unowned = exe(library, "steamapps/common/Other/Binaries/Win64/Other.exe")
|
||||
File(library, "steamapps/appmanifest_123.acf").writeText("\"installdir\" \"Owned\"")
|
||||
SessionPrefs.setGameStorage(context, library.path, "Library")
|
||||
assertTrue(AddedGames.candidates(File(library, "steamapps")).isEmpty())
|
||||
assertTrue(AddedGames.preview(library).isEmpty())
|
||||
assertEquals(unowned, AddedGames.scan(context).single().exe)
|
||||
assertEquals("Other", AddedGames.scan(context).single().name)
|
||||
}
|
||||
|
||||
@Test fun editingTargetAndNameKeepsShortcutIdentityAndMarksItPending() {
|
||||
val folder = tmp.newFolder("Example")
|
||||
val first = exe(folder, "Example.exe")
|
||||
val alternate = exe(folder, "Mod.exe")
|
||||
AddedGames.add(context, AddedGames.Selection(folder, first))
|
||||
val original = AddedGames.scan(context).single()
|
||||
AddedGames.writeListing(context, listOf(original))
|
||||
AddedGames.changeExecutable(context, folder.path, alternate.path)
|
||||
val edited = AddedGames.scan(context).single()
|
||||
assertEquals(original.gameId, edited.gameId)
|
||||
assertEquals(alternate, edited.exe)
|
||||
assertTrue(AddedGames.pending(context, edited))
|
||||
AddedGames.add(context, AddedGames.Selection(folder, alternate, "Renamed mod"))
|
||||
assertEquals(original.gameId, AddedGames.scan(context).single().gameId)
|
||||
}
|
||||
|
||||
@Test fun existingFolderImportsKeepTheirIdentityWhenChangingExecutable() {
|
||||
val collection = tmp.newFolder("Games")
|
||||
val folder = File(collection, "Example").apply { mkdirs() }
|
||||
exe(folder, "Example.exe")
|
||||
val alternate = exe(folder, "Mod.exe")
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(collection.path))
|
||||
val original = AddedGames.scan(context).single()
|
||||
AddedGames.changeExecutable(context, folder.path, alternate.path)
|
||||
assertEquals(original.appId, AddedGames.scan(context).single().appId)
|
||||
assertFalse(AddedGames.scan(context).single().nonSteam)
|
||||
}
|
||||
|
||||
@Test fun overlappingFolderAndIndividualImportsDoNotDuplicateAGame() {
|
||||
val collection = tmp.newFolder("Games")
|
||||
val folder = File(collection, "Example").apply { mkdirs() }
|
||||
val file = exe(folder, "Example.exe")
|
||||
AddedGames.add(context, AddedGames.Selection(folder, file))
|
||||
val original = AddedGames.scan(context).single().gameId
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(collection.path))
|
||||
assertEquals(1, AddedGames.roots(context).size)
|
||||
assertEquals(original, AddedGames.scan(context).single().gameId)
|
||||
}
|
||||
|
||||
@Test fun executableOutsideGameFolderIsRejectedWithoutChangingEntry() {
|
||||
val folder = tmp.newFolder("Example")
|
||||
val file = exe(folder, "Example.exe")
|
||||
val outside = exe(tmp.newFolder("Other"), "Other.exe")
|
||||
AddedGames.add(context, AddedGames.Selection(folder, file))
|
||||
assertThrows(IllegalArgumentException::class.java) { AddedGames.changeExecutable(context, folder.path, outside.path) }
|
||||
assertEquals(file, AddedGames.scan(context).single().exe)
|
||||
}
|
||||
|
||||
@Test fun aSingleGameFolderCanBeImportedWithoutAParentCollection() {
|
||||
val folder = tmp.newFolder("Example")
|
||||
val file = exe(folder, "Example.exe")
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(folder.path))
|
||||
assertEquals(file, AddedGames.scan(context).single().exe)
|
||||
}
|
||||
|
||||
@Test fun missingExplicitTargetDoesNotSilentlySwitchToAnotherExecutable() {
|
||||
val folder = tmp.newFolder("Example")
|
||||
val file = exe(folder, "Mod.exe")
|
||||
exe(folder, "Example.exe")
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(folder.parentFile!!.path))
|
||||
AddedGames.add(context, AddedGames.Selection(folder, file))
|
||||
file.delete()
|
||||
assertTrue(AddedGames.scan(context).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun storageAliasesShareTheExistingBindWithoutLosingTheManualGame() {
|
||||
val collection = tmp.newFolder("Games")
|
||||
val folder = File(collection, "Example").apply { mkdirs() }
|
||||
val file = exe(folder, "Example.exe")
|
||||
val alias = File(tmp.root, "Alias")
|
||||
java.nio.file.Files.createSymbolicLink(alias.toPath(), collection.toPath())
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(alias.path))
|
||||
AddedGames.add(context, AddedGames.Selection(folder, file))
|
||||
assertEquals(1, AddedGames.roots(context).size)
|
||||
assertTrue(AddedGames.scan(context).single().guestExe.endsWith("/Example/Example.exe"))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# Steam game imports
|
||||
|
||||
Games has a persistent **Add game** action, including for an empty library. Choose an
|
||||
executable and confirm its name to add one non-Steam game, or use **Import folder** to
|
||||
preview one game folder or a collection before importing. Folder imports retain Steam
|
||||
ownership matching; explicit executable entries remain non-Steam shortcuts. Advanced
|
||||
lets the user change the game folder when the inferred folder is not correct.
|
||||
|
||||
Added entries appear in DroidDeck immediately. **Available next Steam start** means
|
||||
the executable or name differs from the last session's import listing. A running
|
||||
session cannot launch that pending entry; starting a new Steam session refreshes the
|
||||
binds and shortcuts. **Change executable** on an added game's page keeps its shortcut
|
||||
ID, so its Proton prefix and saves stay associated with the same game.
|
||||
|
||||
Second Library still registers the selected Steam library. It now also scans Windows
|
||||
executables in its immediate game subfolders and `steamapps/common`, using the same
|
||||
importer as Added Games. Existing Steam manifests are preserved.
|
||||
|
||||
@@ -75,6 +75,8 @@ def manifest_ids(steamapps, name):
|
||||
|
||||
|
||||
def identify(game):
|
||||
if game.get("nonSteam"):
|
||||
return None
|
||||
folder = Path(game["folder"])
|
||||
steamapps = folder.parent.parent
|
||||
for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]:
|
||||
@@ -386,6 +388,9 @@ def route(steam_root, acct, games):
|
||||
except OSError as error:
|
||||
print(f"droiddeck-steam-games: set-aside installs not restored ({error})")
|
||||
for game in games:
|
||||
if game.get("nonSteam"):
|
||||
shortcuts.append(game)
|
||||
continue
|
||||
folder = game.get("folder", "")
|
||||
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
|
||||
stamp = source_stamp(game)
|
||||
|
||||
@@ -55,6 +55,17 @@ class ImportsTest(unittest.TestCase):
|
||||
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
|
||||
self.assertEqual(b'game', self.exe.read_bytes())
|
||||
|
||||
def test_explicit_non_steam_executable_is_not_promoted_to_owned_title(self):
|
||||
self.snapshot()
|
||||
self.game['nonSteam'] = True
|
||||
with patch.object(imports.urllib.request, 'urlopen') as search:
|
||||
self.assertIsNone(imports.identify(self.game))
|
||||
search.assert_not_called()
|
||||
games, routes = imports.route(self.steam, self.acct, [self.game])
|
||||
self.assertEqual([self.game], games)
|
||||
self.assertEqual({}, routes)
|
||||
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
|
||||
|
||||
def test_migrates_only_unchanged_legacy_import_manifests(self):
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
|
||||
Reference in new issue
Block a user