diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 07548b4ec..98ab23849 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 949602649..1b85909e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 68ac5a7f0..82f7065c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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//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 = 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() 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 = (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 { + 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): File? { diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 8c072d44d..824e6b565 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -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 { + 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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt index 74b7448ff..10692167d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt @@ -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 { + fun binds(files: File, library: File, guests: List = CARD_GUESTS): List { 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" } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 8fa227685..96d02cee6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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, + sessionRoot: File, + runtimeDir: File, ): ArrayList { val binds = ArrayList() 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>() + 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 } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 71f2926b2..797d3e358 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -41,7 +41,7 @@ %1$s: falló el motor %1$s: requiere Lossless Scaling %1$s %2$s · iniciando - 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. + 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. 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. Olvidar quita los accesos directos en la próxima sesión. Los archivos se conservan. Entorno del juego @@ -353,8 +353,8 @@ ¿Activar la detección de Wi-Fi? 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. Almacenamiento de juegos - Automático - la tarjeta SD si está presente - Automático - la tarjeta SD si está presente (ninguna ahora) + Automático - la tarjeta SD si está presente, y tus carpetas de juegos + Automático - tus carpetas de juegos, y la tarjeta SD si está presente (ninguna ahora) Solo interno Carpeta: %1$s Segunda biblioteca diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1c464ee0d..61e76e8f7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -41,7 +41,7 @@ %1$s : échec du moteur %1$s : nécessite Lossless Scaling %1$s %2$s · Démarrage en cours - 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. + 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. 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. « Oublier » supprimera ces raccourcis lors de la prochaine session. Mais les fichiers resteront sur le disque. Environnement de jeu @@ -300,8 +300,8 @@ Activer la découverte Wi-Fi ? 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. Stockage des jeux - Auto - Utiliser la carte SD si disponible - Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement) + Auto - Utiliser la carte SD si disponible, ainsi que vos dossiers de jeux + Auto - Utiliser vos dossiers de jeux, ainsi que la carte SD si disponible (aucune carte SD actuellement) Stockage interne uniquement Dossier : %1$s Bibliothèque secondaire diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b47291cde..12f3435ab 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -41,7 +41,7 @@ %1$s:エンジンエラー %1$s:Lossless Scalingが必要です %1$s %2$s · 起動中 - 複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。 + 複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。第2ライブラリが自動の場合、Steamはこのフォルダにもゲームをインストールできます。 Steamライブラリを追加し、その中のWindowsゲームをスキャンします。所有しているゲームはSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。 「忘れる」を選択すると、次回のセッションでこれらのショートカットが削除されます。ただし、ファイルはディスク上に残ります。 ゲーム環境 @@ -353,8 +353,8 @@ Wi-Fi検出を有効にしますか? AndroidがWi-Fi名を読み取り、付近のネットワークを検出するには位置情報の権限が必要です。DroidDeckはこの権限を使用してSteamにこれらのネットワークを表示します。位置情報を追跡することはありません。この権限がなくてもインターネットにアクセスできます。 ゲームストレージ - 自動 - SDカードがあればSDカード - 自動 - SDカードがあればSDカード(現在なし) + 自動 - SDカードがあればSDカード、およびゲームフォルダ + 自動 - ゲームフォルダ、およびSDカードがあればSDカード(現在なし) 内部ストレージのみ フォルダ:%1$s 第2ライブラリ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b1630b7b0..ee6c278dc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -41,7 +41,7 @@ %1$s: 엔진 실패 %1$s: Lossless Scaling 필요 %1$s %2$s · 시작 중 - 여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. + 여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. 보조 라이브러리가 자동이면 Steam이 이 폴더에도 게임을 설치할 수 있습니다. Steam 라이브러리를 추가하고 그 안의 Windows 게임을 검사합니다. 소유한 게임은 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. \"잊기\"를 누르면 다음 세션에서 이 바로 가기가 제거됩니다. 하지만 파일은 디스크에 그대로 남습니다. 게임 환경 @@ -353,8 +353,8 @@ Wi-Fi 검색을 활성화하시겠습니까? Android는 Wi-Fi 이름을 읽고 주변 네트워크를 검색하려면 위치 권한이 필요합니다. DroidDeck은 이 권한을 사용하여 Steam에 이러한 네트워크를 표시합니다. 위치를 추적하지 않습니다. 이 권한 없이도 인터넷에 액세스할 수 있습니다. 게임 저장 공간 - 자동 - SD 카드가 있으면 SD 카드 사용 - 자동 - SD 카드가 있으면 SD 카드 사용(현재 SD 카드 없음) + 자동 - SD 카드가 있으면 SD 카드 사용, 게임 폴더도 사용 + 자동 - 게임 폴더 사용, SD 카드가 있으면 SD 카드도 사용(현재 SD 카드 없음) 내부 저장 공간만 폴더: %1$s 보조 라이브러리 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b4234bcd2..e0a1a5b04 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -41,7 +41,7 @@ %1$s: сбой движка %1$s: требуется Lossless Scaling %1$s %2$s · Запуск - Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. + Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. Если для параметра «Вторая библиотека» выбрано «Авто», Steam также может устанавливать игры в эту папку. Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam. «Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске. Игровая среда @@ -300,8 +300,8 @@ Включить обнаружение Wi-Fi? Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения. Хранилище игр - Авто - Использовать SD-карту при наличии - Авто - Использовать SD-карту при наличии (сейчас нет SD-карты) + Авто - Использовать SD-карту при наличии, а также ваши папки с играми + Авто - Использовать ваши папки с играми, а также SD-карту при наличии (сейчас нет SD-карты) Только внутреннее хранилище Папка: %1$s Вторая библиотека diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d1505c0e4..6139fecb8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -41,7 +41,7 @@ %1$s:引擎失败 %1$s:需要 Lossless Scaling %1$s %2$s · 启动中 - 一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。 + 一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。第二个库设为自动时,Steam 也可以把游戏安装到此文件夹。 添加一个 Steam 库并扫描其中的 Windows 游戏。已拥有的游戏使用其 Steam 条目;其余变成非 Steam 快捷方式。 “移除”会在下次会话删除这些快捷方式。文件仍保留在磁盘上。 游戏环境 @@ -353,8 +353,8 @@ 启用 Wi-Fi 网络发现? Android 需要位置权限才能读取 Wi-Fi 名称并发现附近网络。DroidDeck 用此权限在 Steam 中显示这些网络。我们不会跟踪你的位置。没有此权限互联网访问也能正常工作。 游戏存储 - 自动 — 有 SD 卡时使用 SD 卡 - 自动 — 有 SD 卡时使用 SD 卡(当前没有) + 自动 — 有 SD 卡时使用 SD 卡,以及你的游戏文件夹 + 自动 — 使用你的游戏文件夹,有 SD 卡时也使用 SD 卡(当前没有) 仅内部存储 文件夹:%1$s 第二个库 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b7a9c3e09..6fda24b77 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -41,7 +41,7 @@ %1$s:引擎故障 %1$s:需要 Lossless Scaling %1$s %2$s · 正在啟動 - 一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。 + 一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。第二個收藏庫設為自動時,Steam 亦可以將遊戲安裝到此資料夾。 新增一個 Steam 收藏庫並掃描其中的 Windows 遊戲。已擁有的遊戲會使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。 「忘記」會在下次會話移除這些捷徑,檔案仍會保留在磁碟上。 遊戲環境 @@ -353,8 +353,8 @@ 啟用 Wi-Fi 探索? Android 需要位置權限才能讀取 Wi-Fi 名稱和尋找附近網絡。DroidDeck 只會用此權限在 Steam 中顯示這些網絡,不會追蹤你的位置。沒有此權限亦可正常上網。 遊戲儲存位置 - 自動 - 有 SD 卡時使用 SD 卡 - 自動 - 有 SD 卡時使用 SD 卡(目前沒有) + 自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾 + 自動 - 使用你的遊戲資料夾,有 SD 卡時亦使用 SD 卡(目前沒有) 只限內部儲存空間 資料夾:%1$s 第二個收藏庫 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c95ceccfc..6af7eb6cf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -41,7 +41,7 @@ %1$s:引擎失敗 %1$s:需要 Lossless Scaling %1$s %2$s · 正在啟動 - 一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。 + 一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。第二收藏庫設為自動時,Steam 也可以將遊戲安裝到此資料夾。 新增一個 Steam 收藏庫,並掃描其中的 Windows 遊戲。已擁有的遊戲會使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。 「忘記」會在下次工作階段中移除這些捷徑。但檔案仍保留在磁碟上。 遊戲環境 @@ -353,8 +353,8 @@ 啟用 Wi-Fi 探索? Android 需要定位權限才能讀取 Wi-Fi 名稱並探索附近網路。DroidDeck 使用此權限在 Steam 中顯示這些網路。我們不會追蹤你的位置。沒有此權限也可以存取網際網路。 遊戲儲存空間 - 自動 - 有 SD 卡時使用 SD 卡 - 自動 - 有 SD 卡時使用 SD 卡(目前無 SD 卡) + 自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾 + 自動 - 使用你的遊戲資料夾,有 SD 卡時也使用 SD 卡(目前無 SD 卡) 僅內部儲存空間 資料夾:%1$s 第二收藏庫 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05416d2f5..71799ae61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,7 +43,7 @@ %1$s: engine failed %1$s: needs Lossless Scaling %1$s %2$s · starting - 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. + 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. Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts. Forget removes the shortcuts next session. Files stay on disk. Game environment @@ -357,8 +357,8 @@ Enable Wi-Fi discovery? 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. Game storage - Automatic - the SD card when present - Automatic - the SD card when present (none now) + Automatic - the SD card when present, plus your Games folders + Automatic - your Games folders, plus the SD card when present (none now) Internal only Folder: %1$s Second library @@ -1851,6 +1851,7 @@ SD card Folder + %1$s (%2$s) Share session logs diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt new file mode 100644 index 000000000..5c6d70442 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt @@ -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 }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt new file mode 100644 index 000000000..fbda5f5d5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt index 0f65cecc3..131ad64e5 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt @@ -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") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games index 2d3c32ff4..21cd87d16 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games @@ -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) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync b/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync index 2f0273ae0..410a9c442 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync @@ -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)) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index 62202230b..ef0e5d341 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 5bdabd997..a2c751a17 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index 50802187c..5fe62eb1e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library index f5d2b59c5..0a961c87a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library @@ -5,6 +5,7 @@ # droiddeck-steam-library # # 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__": diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index ee1f1eea1..e8bebdb96 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -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) diff --git a/tools/linuxfs/preload/storage.c b/tools/linuxfs/preload/storage.c index d31ee0d0f..fb85d1b6f 100644 --- a/tools/linuxfs/preload/storage.c +++ b/tools/linuxfs/preload/storage.c @@ -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); diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index 1639c6ada..e7878d554 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -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') diff --git a/tools/tests/test_steam_libraries.py b/tools/tests/test_steam_libraries.py new file mode 100644 index 000000000..757e459a7 --- /dev/null +++ b/tools/tests/test_steam_libraries.py @@ -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() diff --git a/tools/tests/test_storage_preload.py b/tools/tests/test_storage_preload.py new file mode 100644 index 000000000..2bf01eb01 --- /dev/null +++ b/tools/tests/test_storage_preload.py @@ -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 +#include +#include +#include +#include +#include +#include + +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 +#include +#include +#include +#include +#include +#include + +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()