Compare commits

...
13 changed files with 496 additions and 40 deletions

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 = {},
+14
View File
@@ -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"))
}
}
+12
View File
@@ -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)
+11
View File
@@ -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])