Make Games folders Steam libraries and skip the zero-filled reservation (#388)

With Second library on Automatic, every writable Games folder is now a
Steam library: it gets the same private prefix and tool binds as the card,
the app hands the session the list (BL_STEAM_LIBRARIES), and
droiddeck-steam-library registers, relabels and forgets entries from it.
Steam offers them in the install dialog as "<folder> (<volume>)", and
Proton mapping, esync, the redist seeder, desktop entries and the compat
beat read every registered library instead of only the card. Games folder
libraries stop at the fast path's bind budget.

Steam's preallocation on Android's FUSE storage either failed outright
(NTFS answers EPERM, so installs were cancelled as "not enough disk
space") or zero-filled the whole download first (exFAT, minutes for a
small game). After fallocate is refused, the Steam client's ftruncate
extension of its own staging files is reported as done; the writes that
follow fill the file. A 76 MB install to a USB SSD now preallocates
instantly instead of taking about 3.5 minutes.

A library that is not there is left out of the list and forgotten until
it returns, imports whose folder is missing are set aside rather than
offered as broken installs, and the front end refreshes when storage is
mounted or removed.
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-07 09:31:33 -04:00
1 parent bc0f6e7271
commit 6ee8866599
29 files changed
+746 -70

No files matched your search

@@ -175,6 +175,16 @@ class MainActivity : ComponentActivity() {
refreshWifiDiscovery()
}
}
private val mediaRefresh = Runnable {
refresh()
refreshAddedGames()
}
private val mediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
ui.removeCallbacks(mediaRefresh)
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
}
}
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
@@ -927,6 +937,11 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
addDataScheme("file")
})
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
refreshSecondScreenDisplays()
}
@@ -938,6 +953,8 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
unregisterReceiver(wifiLocationReceiver)
unregisterReceiver(mediaReceiver)
ui.removeCallbacks(mediaRefresh)
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
@@ -1625,6 +1642,7 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
@@ -98,7 +98,7 @@ object AddedGames {
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()) {
if (folder.name.lowercase() in steamInstalls) continue
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
scanGame(context, folder, out)
}
}
@@ -86,17 +86,20 @@ object Library {
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
}
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
fun protonPrefix(
context: Context, appId: Long, preferredLibrary: File? = null,
libraries: List<File> = steamLibraries(context).map { it.first },
): File? {
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
val secondary = GameStorage.effective(context)?.let { File(it.path) }
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet()
fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary)
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
else root
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot)
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
return roots.asSequence()
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
@@ -107,6 +110,7 @@ object Library {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val roots = libraries.map { it.first }
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
@@ -132,7 +136,7 @@ object Library {
out[appId] = SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
)
@@ -142,16 +146,19 @@ object Library {
}
/** The same installed-game inventory used for links, shortcuts and file exports. */
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
)
}
}).distinctBy { it.gameId }
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
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" },
)
}
}).distinctBy { it.gameId }
}
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
@@ -6,6 +6,7 @@ import android.os.StatFs
import android.os.storage.StorageManager
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
/**
@@ -65,6 +66,25 @@ object GameStorage {
}
}
class Library(val host: File, val guest: String, val label: String)
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 ('"' 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 }
val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) }
val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)
Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' '))
}
}
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. */
fun labelFor(context: Context, path: String): String =
options(context).firstOrNull { it.path == path }?.name
@@ -23,8 +23,10 @@ object SecondaryLibrary {
return File(files, "steam-libraries/$digest")
}
val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")
/** Parent binds come first; the more specific private directories override them in PRoot. */
fun binds(files: File, library: File): List<String> {
fun binds(files: File, library: File, guests: List<String> = CARD_GUESTS): List<String> {
val private = privateRoot(files, library)
val apps = File(library, "steamapps")
val tools = LinkedHashMap(bootstrapTools)
@@ -57,7 +59,7 @@ object SecondaryLibrary {
if (lower != relative) overrides.add(target to lower)
}
}
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
return guests.flatMap { guest ->
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
}
}
@@ -426,7 +426,7 @@ class SessionService : Service() {
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest)
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir)
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
val fastPathKey = if (ProotFastPath.enabled(this)) {
val prootBinds = LinuxRuntime.binds(
@@ -777,6 +777,8 @@ class SessionService : Service() {
fakeInputDir: File,
sessionDir: File,
guest: MutableList<String>,
sessionRoot: File,
runtimeDir: File,
): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
@@ -897,6 +899,45 @@ class SessionService : Service() {
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
val libraries = ArrayList<Pair<String, String>>()
if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label)
val gamesLibraries = GameStorage.gamesFolderLibraries(this)
if (gamesLibraries.isNotEmpty()) {
var room = if (ProotFastPath.enabled(this)) {
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
} else Int.MAX_VALUE
for (games in gamesLibraries) {
if (games.host.path + ":" + games.guest !in binds) continue
val problem = GameStorage.prepare(this, games.host.path)
if (problem != null) {
Log.w(TAG, "steam library: $problem")
continue
}
try {
val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1)
if (extra.size > room) {
Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session")
continue
}
binds.addAll(extra)
room -= extra.size
libraries.add(games.guest to games.label)
Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")")
} catch (e: Exception) {
Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e)
}
}
}
try {
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
val json = org.json.JSONArray()
for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' ')))
listing.writeText(json.toString())
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path)
} catch (e: Exception) {
Log.w(TAG, "steam library: the list could not be written; the card alone this session", e)
}
return binds
}
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s: falló el motor</string>
<string name="hud_fg_needs_lossless">%1$s: requiere Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · iniciando</string>
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam. Con Segunda biblioteca en Automático, Steam también puede instalar juegos en esta carpeta.</string>
<string name="second_library_import_hint">Añade una biblioteca de Steam y busca juegos de Windows en ella. Los juegos que tienes usan sus entradas de Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
<string name="added_games_forget_hint">Olvidar quita los accesos directos en la próxima sesión. Los archivos se conservan.</string>
<string name="game_env_title">Entorno del juego</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">¿Activar la detección de Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android exige el permiso de ubicación para leer los nombres de Wi-Fi y detectar redes cercanas. DroidDeck usa este acceso para mostrar esas redes en Steam. No rastreamos tu ubicación. El acceso a internet funciona sin este permiso.</string>
<string name="mode_storage">Almacenamiento de juegos</string>
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente</string>
<string name="mode_storage_auto_none">Automático - la tarjeta SD si está presente (ninguna ahora)</string>
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente, y tus carpetas de juegos</string>
<string name="mode_storage_auto_none">Automático - tus carpetas de juegos, y la tarjeta SD si está presente (ninguna ahora)</string>
<string name="mode_storage_internal">Solo interno</string>
<string name="mode_storage_folder">Carpeta: %1$s</string>
<string name="mode_second_library">Segunda biblioteca</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s : échec du moteur</string>
<string name="hud_fg_needs_lossless">%1$s : nécessite Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · Démarrage en cours</string>
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam.</string>
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam. Si Bibliothèque secondaire est sur Auto, Steam peut aussi installer des jeux dans ce dossier.</string>
<string name="second_library_import_hint">Ajoutez une bibliothèque Steam et analysez-y les jeux Windows. Les jeux déjà possédés utiliseront les entrées Steam correspondantes, les autres deviendront des raccourcis non-Steam.</string>
<string name="added_games_forget_hint">« Oublier » supprimera ces raccourcis lors de la prochaine session. Mais les fichiers resteront sur le disque.</string>
<string name="game_env_title">Environnement de jeu</string>
@@ -300,8 +300,8 @@
<string name="mode_wifi_explain_title">Activer la découverte Wi-Fi ?</string>
<string name="mode_wifi_explain_text">Android a besoin de l\'autorisation de localisation pour lire les noms Wi-Fi et découvrir les réseaux à proximité. DroidDeck utilise cette autorisation pour afficher ces réseaux dans Steam. Nous ne suivons pas votre position. L\'accès à Internet reste possible sans cette autorisation.</string>
<string name="mode_storage">Stockage des jeux</string>
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible</string>
<string name="mode_storage_auto_none">Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement)</string>
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible, ainsi que vos dossiers de jeux</string>
<string name="mode_storage_auto_none">Auto - Utiliser vos dossiers de jeux, ainsi que la carte SD si disponible (aucune carte SD actuellement)</string>
<string name="mode_storage_internal">Stockage interne uniquement</string>
<string name="mode_storage_folder">Dossier : %1$s</string>
<string name="mode_second_library">Bibliothèque secondaire</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:エンジンエラー</string>
<string name="hud_fg_needs_lossless">%1$s:Lossless Scalingが必要です</string>
<string name="hud_fg_starting">%1$s %2$s · 起動中</string>
<string name="added_games_import_hint">複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。</string>
<string name="added_games_import_hint">複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。第2ライブラリが自動の場合、Steamはこのフォルダにもゲームをインストールできます。</string>
<string name="second_library_import_hint">Steamライブラリを追加し、その中のWindowsゲームをスキャンします。所有しているゲームはSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。</string>
<string name="added_games_forget_hint">「忘れる」を選択すると、次回のセッションでこれらのショートカットが削除されます。ただし、ファイルはディスク上に残ります。</string>
<string name="game_env_title">ゲーム環境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">Wi-Fi検出を有効にしますか?</string>
<string name="mode_wifi_explain_text">AndroidがWi-Fi名を読み取り、付近のネットワークを検出するには位置情報の権限が必要です。DroidDeckはこの権限を使用してSteamにこれらのネットワークを表示します。位置情報を追跡することはありません。この権限がなくてもインターネットにアクセスできます。</string>
<string name="mode_storage">ゲームストレージ</string>
<string name="mode_storage_auto">自動 - SDカードがあればSDカード</string>
<string name="mode_storage_auto_none">自動 - SDカードがあればSDカード(現在なし)</string>
<string name="mode_storage_auto">自動 - SDカードがあればSDカード、およびゲームフォルダ</string>
<string name="mode_storage_auto_none">自動 - ゲームフォルダ、およびSDカードがあればSDカード(現在なし)</string>
<string name="mode_storage_internal">内部ストレージのみ</string>
<string name="mode_storage_folder">フォルダ:%1$s</string>
<string name="mode_second_library">第2ライブラリ</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s: 엔진 실패</string>
<string name="hud_fg_needs_lossless">%1$s: Lossless Scaling 필요</string>
<string name="hud_fg_starting">%1$s %2$s · 시작 중</string>
<string name="added_games_import_hint">여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다.</string>
<string name="added_games_import_hint">여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. 보조 라이브러리가 자동이면 Steam이 이 폴더에도 게임을 설치할 수 있습니다.</string>
<string name="second_library_import_hint">Steam 라이브러리를 추가하고 그 안의 Windows 게임을 검사합니다. 소유한 게임은 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다.</string>
<string name="added_games_forget_hint">\"잊기\"를 누르면 다음 세션에서 이 바로 가기가 제거됩니다. 하지만 파일은 디스크에 그대로 남습니다.</string>
<string name="game_env_title">게임 환경</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">Wi-Fi 검색을 활성화하시겠습니까?</string>
<string name="mode_wifi_explain_text">Android는 Wi-Fi 이름을 읽고 주변 네트워크를 검색하려면 위치 권한이 필요합니다. DroidDeck은 이 권한을 사용하여 Steam에 이러한 네트워크를 표시합니다. 위치를 추적하지 않습니다. 이 권한 없이도 인터넷에 액세스할 수 있습니다.</string>
<string name="mode_storage">게임 저장 공간</string>
<string name="mode_storage_auto">자동 - SD 카드가 있으면 SD 카드 사용</string>
<string name="mode_storage_auto_none">자동 - SD 카드가 있으면 SD 카드 사용(현재 SD 카드 없음)</string>
<string name="mode_storage_auto">자동 - SD 카드가 있으면 SD 카드 사용, 게임 폴더도 사용</string>
<string name="mode_storage_auto_none">자동 - 게임 폴더 사용, SD 카드가 있으면 SD 카드도 사용(현재 SD 카드 없음)</string>
<string name="mode_storage_internal">내부 저장 공간만</string>
<string name="mode_storage_folder">폴더: %1$s</string>
<string name="mode_second_library">보조 라이브러리</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s: сбой движка</string>
<string name="hud_fg_needs_lossless">%1$s: требуется Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · Запуск</string>
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam.</string>
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. Если для параметра «Вторая библиотека» выбрано «Авто», Steam также может устанавливать игры в эту папку.</string>
<string name="second_library_import_hint">Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam.</string>
<string name="added_games_forget_hint">«Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске.</string>
<string name="game_env_title">Игровая среда</string>
@@ -300,8 +300,8 @@
<string name="mode_wifi_explain_title">Включить обнаружение Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения.</string>
<string name="mode_storage">Хранилище игр</string>
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии</string>
<string name="mode_storage_auto_none">Авто - Использовать SD-карту при наличии (сейчас нет SD-карты)</string>
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии, а также ваши папки с играми</string>
<string name="mode_storage_auto_none">Авто - Использовать ваши папки с играми, а также SD-карту при наличии (сейчас нет SD-карты)</string>
<string name="mode_storage_internal">Только внутреннее хранилище</string>
<string name="mode_storage_folder">Папка: %1$s</string>
<string name="mode_second_library">Вторая библиотека</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:引擎失败</string>
<string name="hud_fg_needs_lossless">%1$s:需要 Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · 启动中</string>
<string name="added_games_import_hint">一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。</string>
<string name="added_games_import_hint">一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。第二个库设为自动时,Steam 也可以把游戏安装到此文件夹。</string>
<string name="second_library_import_hint">添加一个 Steam 库并扫描其中的 Windows 游戏。已拥有的游戏使用其 Steam 条目;其余变成非 Steam 快捷方式。</string>
<string name="added_games_forget_hint">“移除”会在下次会话删除这些快捷方式。文件仍保留在磁盘上。</string>
<string name="game_env_title">游戏环境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">启用 Wi-Fi 网络发现?</string>
<string name="mode_wifi_explain_text">Android 需要位置权限才能读取 Wi-Fi 名称并发现附近网络。DroidDeck 用此权限在 Steam 中显示这些网络。我们不会跟踪你的位置。没有此权限互联网访问也能正常工作。</string>
<string name="mode_storage">游戏存储</string>
<string name="mode_storage_auto">自动 — 有 SD 卡时使用 SD 卡</string>
<string name="mode_storage_auto_none">自动 — 有 SD 卡时使用 SD 卡(当前没有)</string>
<string name="mode_storage_auto">自动 — 有 SD 卡时使用 SD 卡,以及你的游戏文件夹</string>
<string name="mode_storage_auto_none">自动 — 使用你的游戏文件夹,有 SD 卡时也使用 SD 卡(当前没有)</string>
<string name="mode_storage_internal">仅内部存储</string>
<string name="mode_storage_folder">文件夹:%1$s</string>
<string name="mode_second_library">第二个库</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:引擎故障</string>
<string name="hud_fg_needs_lossless">%1$s:需要 Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · 正在啟動</string>
<string name="added_games_import_hint">一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。</string>
<string name="added_games_import_hint">一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。第二個收藏庫設為自動時,Steam 亦可以將遊戲安裝到此資料夾。</string>
<string name="second_library_import_hint">新增一個 Steam 收藏庫並掃描其中的 Windows 遊戲。已擁有的遊戲會使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。</string>
<string name="added_games_forget_hint">「忘記」會在下次會話移除這些捷徑,檔案仍會保留在磁碟上。</string>
<string name="game_env_title">遊戲環境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">啟用 Wi-Fi 探索?</string>
<string name="mode_wifi_explain_text">Android 需要位置權限才能讀取 Wi-Fi 名稱和尋找附近網絡。DroidDeck 只會用此權限在 Steam 中顯示這些網絡,不會追蹤你的位置。沒有此權限亦可正常上網。</string>
<string name="mode_storage">遊戲儲存位置</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡</string>
<string name="mode_storage_auto_none">自動 - 有 SD 卡時使用 SD 卡(目前沒有)</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾</string>
<string name="mode_storage_auto_none">自動 - 使用你的遊戲資料夾,有 SD 卡時亦使用 SD 卡(目前沒有)</string>
<string name="mode_storage_internal">只限內部儲存空間</string>
<string name="mode_storage_folder">資料夾:%1$s</string>
<string name="mode_second_library">第二個收藏庫</string>
+3 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:引擎失敗</string>
<string name="hud_fg_needs_lossless">%1$s:需要 Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · 正在啟動</string>
<string name="added_games_import_hint">一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。</string>
<string name="added_games_import_hint">一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。第二收藏庫設為自動時,Steam 也可以將遊戲安裝到此資料夾。</string>
<string name="second_library_import_hint">新增一個 Steam 收藏庫,並掃描其中的 Windows 遊戲。已擁有的遊戲會使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。</string>
<string name="added_games_forget_hint">「忘記」會在下次工作階段中移除這些捷徑。但檔案仍保留在磁碟上。</string>
<string name="game_env_title">遊戲環境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">啟用 Wi-Fi 探索?</string>
<string name="mode_wifi_explain_text">Android 需要定位權限才能讀取 Wi-Fi 名稱並探索附近網路。DroidDeck 使用此權限在 Steam 中顯示這些網路。我們不會追蹤你的位置。沒有此權限也可以存取網際網路。</string>
<string name="mode_storage">遊戲儲存空間</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡</string>
<string name="mode_storage_auto_none">自動 - 有 SD 卡時使用 SD 卡(目前無 SD 卡)</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾</string>
<string name="mode_storage_auto_none">自動 - 使用你的遊戲資料夾,有 SD 卡時也使用 SD 卡(目前無 SD 卡)</string>
<string name="mode_storage_internal">僅內部儲存空間</string>
<string name="mode_storage_folder">資料夾:%1$s</string>
<string name="mode_second_library">第二收藏庫</string>
+4 -3
View File
@@ -43,7 +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">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts.</string>
<string name="added_games_import_hint">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts. With Second library on Automatic, Steam can install games into this folder too.</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="added_games_forget_hint">Forget removes the shortcuts next session. Files stay on disk.</string>
<string name="game_env_title">Game environment</string>
@@ -357,8 +357,8 @@
<string name="mode_wifi_explain_title">Enable Wi-Fi discovery?</string>
<string name="mode_wifi_explain_text">Android requires Location permission to read Wi-Fi names and discover nearby networks. DroidDeck uses this access to show those networks in Steam. We don\'t track your location. Internet access works without this permission.</string>
<string name="mode_storage">Game storage</string>
<string name="mode_storage_auto">Automatic - the SD card when present</string>
<string name="mode_storage_auto_none">Automatic - the SD card when present (none now)</string>
<string name="mode_storage_auto">Automatic - the SD card when present, plus your Games folders</string>
<string name="mode_storage_auto_none">Automatic - your Games folders, plus the SD card when present (none now)</string>
<string name="mode_storage_internal">Internal only</string>
<string name="mode_storage_folder">Folder: %1$s</string>
<string name="mode_second_library">Second library</string>
@@ -1851,6 +1851,7 @@
<string name="gstore_sd_card">SD card</string>
<!-- %1$s is a size, like 12.3 GB -->
<string name="gstore_folder">Folder</string>
<string name="gstore_folder_on_volume" translatable="false">%1$s (%2$s)</string>
<string name="logshare_chooser">Share session logs</string>
<!-- Stands in for the Steam account name when the client did not record one -->
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import org.junit.Assert.*
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
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class AddedGamesScanTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
@Test fun theSteamLibraryInsideAGamesFolderIsNotAGame() {
val games = tmp.newFolder("PC")
File(games, "Example").mkdirs()
File(games, "Example/Example.exe").writeText("game")
File(games, "steamapps/downloading").mkdirs()
File(games, "steamapps/downloading/Depot.exe").writeText("depot")
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
SessionPrefs.setAddedGamesDirs(app, listOf(games.path))
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
import org.junit.Assert.*
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
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GameStorageTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
@Test fun gamesFoldersBecomeLibrariesOnAutomatic() {
val pc = tmp.newFolder("PC")
val missing = File(tmp.root, "Unplugged")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.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 })
assertEquals(listOf("PC"), libraries.map { it.label })
}
@Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path))
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen")
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
@Test fun namesTheClientCannotStoreAreLeftOut() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path))
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
}
@@ -25,6 +25,17 @@ class SecondaryLibraryTest {
}
}
@Test fun aGamesFolderLibraryIsBoundOnlyAtItsOwnPath() {
val files = tmp.newFolder("files")
val library = tmp.newFolder("PC")
val binds = SecondaryLibrary.binds(files, library, listOf("/root/Games/PC"))
val private = SecondaryLibrary.privateRoot(files, library)
assertEquals("${library.path}:/root/Games/PC", binds.first())
assertTrue(binds.contains("$private/steamapps/compatdata:/root/Games/PC/steamapps/compatdata"))
assertTrue(binds.drop(1).all { it.substringAfter(':').startsWith("/root/Games/PC/steamapps/") })
assertFalse(binds.any { it.contains("/mnt/") })
}
@Test fun migrationPreservesSavesLinksAndOriginalAndNeverOverwritesNewSaves() {
val files = tmp.newFolder("files")
val library = tmp.newFolder("card")
@@ -73,11 +73,19 @@ def client_entries():
return found
def registered():
try:
with open(os.path.join(STEAM_ROOT, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
return [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
except OSError:
return []
def write_entries():
os.makedirs(APPS_DIR, exist_ok=True)
wanted = {}
theirs = client_entries()
for library, _ in LIBRARIES:
for library in dict.fromkeys([library for library, _ in LIBRARIES] + registered()):
for appid, name in games(library):
if appid not in theirs:
wanted.setdefault(appid, name)
@@ -954,8 +954,19 @@ def choose(root, command, env, tool, stock):
return command, env
def libraries(steam_root):
found = [steam_root]
try:
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
except OSError:
pass
found.append(SD_LIBRARY)
return list(dict.fromkeys(found))
def find_tools(steam_root):
candidates = [os.path.join(library, "steamapps", "common", name) for library in (steam_root, SD_LIBRARY) for name in SOURCES]
candidates = [os.path.join(library, "steamapps", "common", name) for library in libraries(steam_root) for name in SOURCES]
folder = os.path.join(steam_root, "compatibilitytools.d")
try:
names = sorted(os.listdir(folder))
@@ -72,6 +72,7 @@ cache="$steam_root/.droiddeck-game-scripts"
libraries="$steam_root"
for library in $LIBRARIES; do libraries="$libraries
$library"; done
libraries=$(printf '%s\n%s\n' "$libraries" "$(sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)" | awk 'NF && !seen[$0]++')
sig=$(while IFS= read -r library; do ls -l --time-style=+%s "$library"/steamapps/appmanifest_*.acf 2>/dev/null; done <<<"$libraries" | cksum)
if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then
{
@@ -133,9 +134,12 @@ $(vcrt "$(date +%s)")"
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
# changed one as a new template and upgrades every prefix made from the old.
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg \
/root/Games/*/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] || continue
seeded=$(dirname "$reg")/.droiddeck-redists
legacy=$(dirname "$reg")/.bannerlator-redists
@@ -568,7 +568,7 @@ case $mode in
local d
[ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0
for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps /root/Games/*/steamapps; do
[ "$d" -nt "$compat_stamp" ] && return 0
done
return 1
@@ -16,6 +16,7 @@ STATE = ".droiddeck-games.json"
WINNATIVE = ".winnative-steam/steamapps"
LIBRARY = "/mnt/droiddeck-sd"
LEGACY_LIBRARY = "/mnt/bannerlator-sd"
PARKED = ".droiddeck-parked"
BASE_ID = 76561197960265728
@@ -282,6 +283,42 @@ def update_not_started(text, steamapps, appid, installdir):
and not (steamapps / "downloading" / str(appid)).exists())
def libraries(steam_root):
try:
text = (Path(steam_root) / "steamapps/libraryfolders.vdf").read_text(errors="replace")
except OSError:
text = ""
paths = [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)]
return list(dict.fromkeys([Path(steam_root), Path(LIBRARY)] + [Path(path) for path in paths]))
def ours(manifest, appid):
try:
return re.search(rf'"installdir"\s*"DroidDeck-{appid}"', manifest.read_text(errors="replace")) is not None
except OSError:
return False
def unpark(steamapps):
for manifest in sorted((steamapps / PARKED).glob("appmanifest_*.acf")):
appid = manifest.stem.removeprefix("appmanifest_")
live = steamapps / manifest.name
if os.path.lexists(live):
manifest.unlink(missing_ok=True)
elif (steamapps / "common" / f"DroidDeck-{appid}").is_dir():
os.replace(manifest, live)
def park(steamapps):
for target in sorted((steamapps / "common").glob("DroidDeck-*")):
appid = target.name.removeprefix("DroidDeck-")
manifest = steamapps / f"appmanifest_{appid}.acf"
if appid.isdigit() and target.is_symlink() and not target.exists() and manifest.is_file() and ours(manifest, appid):
(steamapps / PARKED).mkdir(exist_ok=True)
os.replace(manifest, steamapps / PARKED / manifest.name)
print(f"droiddeck-steam-games: {appid} is not here this session; its install is set aside until it is")
def seed_install(steam_root, game, appid):
steamapps = Path(steam_root) / "steamapps"
common = steamapps / "common"
@@ -305,7 +342,7 @@ def seed_install(steam_root, game, appid):
relink.unlink(missing_ok=True)
relink.symlink_to(source, target_is_directory=True)
os.replace(relink, target)
for root in [Path(steam_root), Path(LIBRARY)]:
for root in libraries(steam_root):
existing = root / "steamapps" / manifest.name
if existing.is_file():
if (existing == manifest and source.is_dir() and target.is_symlink()
@@ -343,6 +380,11 @@ def route(steam_root, acct, games):
if not all(isinstance(value, dict) for value in (owned, candidates, sources)):
owned, candidates, sources = {}, {}, {}
shortcuts, routes = [], {}
steamapps = Path(steam_root) / "steamapps"
try:
unpark(steamapps)
except OSError as error:
print(f"droiddeck-steam-games: set-aside installs not restored ({error})")
for game in games:
folder = game.get("folder", "")
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
@@ -359,6 +401,10 @@ def route(steam_root, acct, games):
except OSError as error:
print(f"droiddeck-steam-games: import unavailable ({error})")
shortcuts.append(game)
try:
park(steamapps)
except OSError as error:
print(f"droiddeck-steam-games: missing installs not set aside ({error})")
save_json(Path(acct) / "config/.droiddeck-routes.json", routes)
return shortcuts, routes
@@ -5,6 +5,7 @@
# droiddeck-steam-library <steam root>
#
# Ported from WinNative's gamescope runtime (GPL-3.0).
import json
import os
import re
import sys
@@ -168,6 +169,29 @@ def relabel(steam_root, target, label):
print("droiddeck-steam-library: labelled %s in its own marker too" % target)
GAMES_DIR = "/root/Games/"
def wanted():
listing = os.environ.get("BL_STEAM_LIBRARIES")
if not listing:
return None
try:
with open(listing, encoding="utf-8") as f:
entries = json.load(f)
return [(str(e["path"]), str(e["label"]).replace('"', " ").replace("\\", " ")) for e in entries]
except (OSError, ValueError, KeyError, TypeError):
return None
def registered(steam_root):
try:
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
return re.findall(r'"path"\s+"([^"]*)"', f.read())
except OSError:
return []
def main():
steam_root = sys.argv[1]
for library in RETIRED:
@@ -175,12 +199,19 @@ def main():
for old, new in RENAMED:
rename(steam_root, old, new)
relabel(steam_root, steam_root, MAIN_LABEL)
for library, label in LIBRARIES:
libraries = wanted()
managed = libraries is not None
for library, label in libraries if managed else LIBRARIES:
if os.path.isdir(os.path.join(library, "steamapps")):
register(steam_root, library, label)
relabel(steam_root, library, label)
else:
forget(steam_root, library)
if managed:
keep = {library for library, _ in libraries}
for library in registered(steam_root):
if library not in keep and (library == LIBRARIES[0][0] or library.startswith(GAMES_DIR)):
forget(steam_root, library)
if __name__ == "__main__":
@@ -277,9 +277,10 @@ done
# The depot this tool is named after, newest across the libraries that hold it; the others in the
# list stand in only when it is missing.
depot=
libraries=$(printf '%%s\\n' "$steam_root" /mnt/droiddeck-sd; sed -n 's/^[[:space:]]*"path"[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)
for name in %s; do
best=0
for library in "$steam_root" /mnt/droiddeck-sd; do
while IFS= read -r library; do
candidate="$library/steamapps/common/$name"
[ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue
build=0
@@ -289,7 +290,7 @@ for name in %s; do
depot=$candidate
best=$build
fi
done
done <<< "$libraries"
[ -n "$depot" ] && break
done
if [ -n "$depot" ]; then
@@ -615,10 +616,21 @@ NOT_GAMES = frozenset((
))
def libraries(steam_root):
found = [steam_root]
try:
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
except OSError:
pass
found.append(LIBRARY + "-sd")
return list(dict.fromkeys(found))
def installed_apps(steam_root):
"""App ids of the titles the client has installed, tools and runtimes excluded."""
apps = set()
for library in (steam_root, LIBRARY + "-sd"):
for library in libraries(steam_root):
steamapps = os.path.join(library, "steamapps")
try:
names = os.listdir(steamapps)
@@ -778,7 +790,7 @@ def fex_missing(steam_root):
missing = []
for app in FEX_DEPENDENCIES:
if not any(os.path.isfile(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app))
for library in (steam_root, LIBRARY + "-sd")):
for library in libraries(steam_root)):
missing.append(app)
return missing
@@ -1065,7 +1077,7 @@ def depot_build(depot):
def find_source(steam_root, sources=SOURCES):
"""The first of `sources` the client has downloaded, newest across the libraries holding it."""
for name in sources:
found = [os.path.join(root, "steamapps", "common", name) for root in (steam_root, "/mnt/droiddeck-sd")
found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root)
if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))]
if found:
return max(found, key=depot_build)
+63 -12
View File
@@ -1,8 +1,6 @@
/*
* Opt-in observations for file allocation and writes to the selected Steam library.
*
* Steam's allocation failure and any later fallback are left entirely to the caller. These
* wrappers only forward the same arguments, record the result and restore errno after logging.
* The session enables them only when storage diagnostics are turned on. Counters are local to
* each process, allocation details are capped, writes are sampled, and storage.log has a global
* size limit because every Steam child shares it.
@@ -154,19 +152,56 @@ static void allocation_line(const char *api, int result, int error, int flags,
if (n > 0 && (size_t)n < sizeof(line)) append_line(line, (size_t)n);
}
static int steam_client(void) {
static int cached = -1;
if (cached < 0)
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
strstr(program_invocation_name, "steamrtarm64") != NULL;
return cached;
}
static int staging_file(int fd) {
char link[32], path[PATH_MAX];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
long n = syscall(SYS_readlinkat, (long)AT_FDCWD, (long)link, (long)path, (long)sizeof(path) - 1);
if (n <= 0) return 0;
path[n] = '\0';
return strstr(path, "/steamapps/downloading/") != NULL;
}
static long long unreserved[16];
static unsigned int unreserved_count;
static int cannot_reserve(long long device) {
unsigned int n = __atomic_load_n(&unreserved_count, __ATOMIC_ACQUIRE);
for (unsigned int i = 0; i < n && i < 16; i++)
if (__atomic_load_n(&unreserved[i], __ATOMIC_RELAXED) == device) return 1;
return 0;
}
static void note_cannot_reserve(int fd) {
struct stat st;
if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat");
if (next_fstat == NULL || next_fstat(fd, &st) != 0 || cannot_reserve((long long)st.st_dev)) return;
unsigned int slot = __atomic_load_n(&unreserved_count, __ATOMIC_RELAXED);
while (slot < 16 && !__atomic_compare_exchange_n(&unreserved_count, &slot, slot + 1, 0, __ATOMIC_ACQ_REL,
__ATOMIC_RELAXED)) {
}
if (slot < 16) __atomic_store_n(&unreserved[slot], (long long)st.st_dev, __ATOMIC_RELEASE);
}
int fallocate(int fd, int mode, off_t offset, off_t length) {
int entry_errno = errno;
if (next_fallocate == NULL) next_fallocate = dlsym(RTLD_NEXT, "fallocate");
if (next_fallocate == NULL) { errno = ENOSYS; return -1; }
int selected = on_selected_device(fd, NULL);
errno = entry_errno;
if (!selected) return next_fallocate(fd, mode, offset, length);
uint64_t start = now_ns();
uint64_t start = selected ? now_ns() : 0;
errno = entry_errno;
int result = next_fallocate(fd, mode, offset, length);
int call_errno = errno;
uint64_t elapsed = now_ns() - start;
allocation_line("fallocate", result, call_errno, mode, offset, length, elapsed);
if (result != 0 && (call_errno == EOPNOTSUPP || call_errno == ENOSYS) && steam_client()) note_cannot_reserve(fd);
if (selected) allocation_line("fallocate", result, call_errno, mode, offset, length, now_ns() - start);
errno = call_errno;
return result;
}
@@ -222,12 +257,28 @@ int ftruncate(int fd, off_t length) {
if (next_ftruncate == NULL) next_ftruncate = dlsym(RTLD_NEXT, "ftruncate");
if (next_ftruncate == NULL) { errno = ENOSYS; return -1; }
int selected = on_selected_device(fd, NULL);
errno = entry_errno;
if (!selected) return next_ftruncate(fd, length);
uint64_t start = now_ns();
errno = entry_errno;
int result = next_ftruncate(fd, length);
int call_errno = errno;
uint64_t start = selected ? now_ns() : 0;
struct stat st;
int known = 0;
if (steam_client()) {
if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat");
known = next_fstat != NULL && next_fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && staging_file(fd);
}
int result = 0, call_errno = entry_errno;
if (!known || length <= st.st_size || !cannot_reserve((long long)st.st_dev)) {
errno = entry_errno;
result = next_ftruncate(fd, length);
call_errno = errno;
if (result != 0 && known && length >= st.st_size && (call_errno == EPERM || call_errno == EOPNOTSUPP)) {
if (length > st.st_size) note_cannot_reserve(fd);
result = 0;
call_errno = entry_errno;
}
}
if (!selected) {
errno = call_errno;
return result;
}
uint64_t elapsed = now_ns() - start;
add(&stats.ftruncate_calls, 1);
if (result != 0) add(&stats.ftruncate_failures, 1);
+40
View File
@@ -1,6 +1,7 @@
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
@@ -231,6 +232,45 @@ class ImportsTest(unittest.TestCase):
self.folder.rmdir()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
def test_absent_folder_sets_its_install_aside_until_it_returns(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.write_text(manifest.read_text().replace('}', ' "buildid" "99"\n}'))
state = manifest.read_text()
away = self.root / 'unplugged'
self.folder.rename(away)
with patch('builtins.print'):
self.assertEqual(([], {}), imports.route(self.steam, self.acct, []))
self.assertFalse(manifest.exists())
self.assertEqual(state, (self.steam / 'steamapps' / imports.PARKED / manifest.name).read_text())
away.rename(self.folder)
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertEqual(state, manifest.read_text())
self.assertFalse((self.steam / 'steamapps' / imports.PARKED / manifest.name).exists())
def test_steam_installs_are_never_set_aside(self):
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.parent.mkdir(parents=True)
manifest.write_text('"AppState"\n{\n "appid" "42"\n "installdir" "Example"\n}\n')
link = self.steam / 'steamapps/common/DroidDeck-42'
link.parent.mkdir(parents=True)
link.symlink_to(self.root / 'missing', target_is_directory=True)
imports.route(self.steam, self.acct, [])
self.assertTrue(manifest.is_file())
def test_install_in_another_library_is_used_as_is(self):
other = self.root / 'Games/PC'
(other / 'steamapps').mkdir(parents=True)
(other / 'steamapps/appmanifest_42.acf').write_text('Steam installed it here')
vdf = self.steam / 'steamapps/libraryfolders.vdf'
vdf.parent.mkdir(parents=True)
vdf.write_text('"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n}\n' % other)
self.snapshot()
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
def test_replaced_game_does_not_inherit_cached_ownership(self):
self.snapshot()
self.exe.write_bytes(b'a different game')
+152
View File
@@ -0,0 +1,152 @@
import importlib.machinery
import importlib.util
import json
import os
import re
import runpy
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
def load(name):
loader = importlib.machinery.SourceFileLoader(name.replace("-", "_"), str(BIN / name))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
library = load("droiddeck-steam-library")
desktop = load("droiddeck-desktop-games")
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
def entry(index, path, label=""):
return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"label"\t\t"%s"\n\t}\n' % (index, path, label)
def manifest(steamapps, appid, name="Game", installdir="Game", flags="4"):
steamapps.mkdir(parents=True, exist_ok=True)
(steamapps / ("appmanifest_%s.acf" % appid)).write_text(
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n}\n'
% (appid, name, flags, installdir))
class LibraryListTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
self.steam = self.root / "Steam"
self.vdf = self.steam / "steamapps/libraryfolders.vdf"
self.vdf.parent.mkdir(parents=True)
self.games = self.root / "Games"
self.card = self.root / "card"
self.pc = self.games / "PC"
self.old = self.games / "Old"
self.user = self.root / "user-library"
for folder in (self.pc, self.old, self.user, self.card):
(folder / "steamapps").mkdir(parents=True)
manifest(self.pc / "steamapps", 10)
self.vdf.write_text('"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.old, "Old")
+ entry(2, self.user, "Mine") + entry(3, self.card, "SD Card") + "}\n")
self.listing = self.root / "steam-libraries.json"
for target in (patch.object(library, "GAMES_DIR", str(self.games) + "/"),
patch.object(library, "LIBRARIES", ((str(self.card), "SD Card"),)),
patch("builtins.print")):
target.start()
self.addCleanup(target.stop)
def run_main(self, listing=True):
with patch.dict(os.environ), patch("sys.argv", ["droiddeck-steam-library", str(self.steam)]):
os.environ.pop("BL_STEAM_LIBRARIES", None)
if listing:
os.environ["BL_STEAM_LIBRARIES"] = str(self.listing)
library.main()
return self.vdf.read_text()
def paths(self, text):
return [Path(p) for p in re.findall(r'"path"\s+"([^"]*)"', text)]
def test_listed_games_folder_is_registered_and_the_rest_are_forgotten(self):
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC (PNY USB drive)"}]))
text = self.run_main()
self.assertEqual([self.steam, self.user, self.pc], self.paths(text))
self.assertIn('"label"\t\t"PC (PNY USB drive)"', text)
self.assertIn('"10"\t\t"0"', text)
self.assertIn('"label"\t\t"PC (PNY USB drive)"', (self.pc / "libraryfolder.vdf").read_text())
def test_absent_listed_folder_is_forgotten_and_comes_back(self):
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC"}, {"path": str(self.card), "label": "SD Card"}]))
self.run_main()
moved = self.root / "unplugged"
self.pc.rename(moved)
self.assertNotIn(self.pc, self.paths(self.run_main()))
moved.rename(self.pc)
self.assertIn(self.pc, self.paths(self.run_main()))
self.assertIn(self.card, self.paths(self.vdf.read_text()))
def test_without_a_list_only_the_card_is_managed(self):
text = self.run_main(listing=False)
self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text))
def test_unreadable_list_keeps_games_folder_entries(self):
self.listing.write_text("{not json")
text = self.run_main()
self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text))
def test_labels_cannot_break_the_vdf(self):
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": 'P"C\\'}]))
text = self.run_main()
self.assertIn('"label"\t\t"P C "', text)
class CompatLibrariesTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
self.steam = self.root / "Steam"
self.pc = self.root / "Games/My PC"
(self.steam / "steamapps").mkdir(parents=True)
(self.steam / "steamapps/libraryfolders.vdf").write_text(
'"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.pc, "PC") + "}\n")
def test_titles_and_tools_in_a_registered_library_are_found(self):
manifest(self.steam / "steamapps", 20)
manifest(self.pc / "steamapps", 30)
manifest(self.pc / "steamapps", 3127680, installdir="FEX")
self.assertEqual(["20", "30"], COMPAT["installed_apps"](str(self.steam)))
self.assertEqual(["1628350"], COMPAT["fex_missing"](str(self.steam)))
def test_newest_proton_across_libraries_wins(self):
for root, build in ((self.steam, "100"), (self.pc, "200")):
depot = root / "steamapps/common/Proton Experimental (ARM64)"
(depot / "files/bin-arm64").mkdir(parents=True)
(depot / "version").write_text(build + " x\n")
self.assertEqual(str(self.pc / "steamapps/common/Proton Experimental (ARM64)"), COMPAT["find_source"](str(self.steam)))
def test_missing_list_still_reads_the_client_library(self):
(self.steam / "steamapps/libraryfolders.vdf").unlink()
self.assertEqual(str(self.steam), COMPAT["libraries"](str(self.steam))[0])
class DesktopGamesTest(unittest.TestCase):
def test_games_in_registered_libraries_get_entries(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
steam, pc = root / "Steam", root / "Games/PC"
manifest(steam / "steamapps", 40, name="Internal")
manifest(pc / "steamapps", 50, name="Outside")
(steam / "steamapps/libraryfolders.vdf").write_text('"libraryfolders"\n{\n' + entry(0, steam) + entry(1, pc) + "}\n")
with patch.object(desktop, "STEAM_ROOT", str(steam)), patch.object(desktop, "APPS_DIR", str(root / "apps")), \
patch.object(desktop, "LIBRARIES", ((str(steam), "Steam Games"), (str(root / "card"), "SD Games"))):
self.assertEqual({40: "Internal", 50: "Outside"}, desktop.write_entries())
if __name__ == "__main__":
unittest.main()
+149
View File
@@ -0,0 +1,149 @@
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
FAKE = r"""
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
static void path_of(int fd, char *out, size_t size) {
char link[32];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
long n = syscall(SYS_readlinkat, AT_FDCWD, link, out, size - 1);
out[n > 0 ? n : 0] = '\0';
}
int fallocate(int fd, int mode, off_t offset, off_t length) {
(void)fd; (void)mode; (void)offset; (void)length;
errno = EOPNOTSUPP;
return -1;
}
int ftruncate(int fd, off_t length) {
char path[4096];
path_of(fd, path, sizeof(path));
FILE *log = fopen(getenv("FAKE_LOG"), "a");
if (log) { fprintf(log, "%s %lld\n", strrchr(path, '/') + 1, (long long)length); fclose(log); }
if (strstr(path, "ntfs")) { errno = EPERM; return -1; }
return (int)syscall(SYS_ftruncate, fd, length);
}
"""
PROGRAM = r"""
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char **argv) {
for (int i = 1; i + 2 < argc; i += 3) {
int fd = open(argv[i + 1], O_RDWR | O_CREAT, 0644);
if (fd < 0) return 2;
long long length = atoll(argv[i + 2]);
int result;
errno = 0;
if (strcmp(argv[i], "fallocate") == 0) result = fallocate(fd, 0, 0, length);
else result = ftruncate(fd, length);
int error = result == 0 ? 0 : errno;
struct stat st;
fstat(fd, &st);
printf("%s %d %s %lld\n", argv[i], result, error ? strerrorname_np(error) : "-", (long long)st.st_size);
close(fd);
}
return 0;
}
"""
@unittest.skipUnless(shutil.which("cc"), "needs a C compiler")
class StoragePreloadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.dir = Path(tempfile.mkdtemp())
build = cls.dir / "build"
build.mkdir()
(build / "fake.c").write_text(FAKE)
(build / "program.c").write_text(PROGRAM)
cls.storage = build / "storage.so"
cls.fake = build / "fake.so"
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.storage), str(PRELOAD / "storage.c"), "-ldl"], check=True)
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.fake), str(build / "fake.c")], check=True)
cls.steam = cls.dir / "steamrtarm64/steam"
cls.steam.parent.mkdir()
subprocess.run(["cc", "-O2", "-o", str(cls.steam), str(build / "program.c")], check=True)
cls.other = build / "steamwebhelper"
shutil.copy(cls.steam, cls.other)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.dir, True)
def setUp(self):
self.lib = Path(tempfile.mkdtemp(dir=self.dir))
self.staging = self.lib / "steamapps/downloading/252410"
self.staging.mkdir(parents=True)
self.log = self.lib / "ftruncate.log"
def run_program(self, *steps, program=None):
env = dict(os.environ, LD_PRELOAD="%s:%s" % (self.storage, self.fake), FAKE_LOG=str(self.log))
env.pop("BL_STORAGE_DIAGNOSTICS", None)
args = [str(program or self.steam)]
for call, path, length in steps:
args += [call, str(path), str(length)]
result = subprocess.run(args, env=env, capture_output=True, text=True, check=True)
return result.stdout.splitlines()
def reached(self):
return self.log.read_text().splitlines() if self.log.exists() else []
def test_reservation_after_failed_fallocate_is_not_zero_filled(self):
data = self.staging / "data.pak"
out = self.run_program(("fallocate", data, 1 << 20), ("ftruncate", data, 1 << 20), ("ftruncate", data, 0))
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate 0 - 0", "ftruncate 0 - 0"], out)
self.assertEqual(["data.pak 0"], self.reached())
def test_reservation_is_real_until_the_device_has_refused_one(self):
data = self.staging / "data.pak"
self.assertEqual(["ftruncate 0 - 4096"], self.run_program(("ftruncate", data, 4096)))
self.assertEqual(["data.pak 4096"], self.reached())
def test_refused_extension_is_reported_as_done_and_remembered(self):
first, second = self.staging / "ntfs-one", self.staging / "two"
out = self.run_program(("ftruncate", first, 8192), ("ftruncate", second, 8192))
self.assertEqual(["ftruncate 0 - 0", "ftruncate 0 - 0"], out)
self.assertEqual(["ntfs-one 8192"], self.reached())
def test_refused_shrink_keeps_its_error(self):
data = self.staging / "ntfs-data"
data.write_bytes(b"x" * 100)
self.assertEqual(["ftruncate -1 EPERM 100"], self.run_program(("ftruncate", data, 10)))
def test_files_outside_staging_are_untouched(self):
manifest = self.lib / "steamapps/ntfs-appmanifest.acf"
out = self.run_program(("fallocate", self.staging / "a", 4096), ("ftruncate", manifest, 4096))
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out)
self.assertEqual(["ntfs-appmanifest.acf 4096"], self.reached())
def test_other_programs_are_untouched(self):
data = self.staging / "ntfs-data"
out = self.run_program(("fallocate", data, 4096), ("ftruncate", data, 4096), program=self.other)
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out)
if __name__ == "__main__":
unittest.main()