mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ca58f6fbe | ||
|
|
e7cbba37d1 | ||
|
|
d9bb5e15b0 | ||
|
|
46cdc32cf1 | ||
|
|
387572e733 | ||
|
|
9f46c7a50b | ||
|
|
0607bb0b3a |
No files matched your search
@@ -343,7 +343,7 @@ jobs:
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap; do
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle drmIoctl; do
|
||||
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
||||
done
|
||||
test -f "$D/usr/local/bin/droiddeck-session"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -71,6 +71,10 @@
|
||||
<action android:name="android.intent.action.CREATE_SHORTCUT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
|
||||
@@ -123,6 +127,14 @@
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".SessionActivity"
|
||||
android:exported="false"
|
||||
@@ -173,5 +185,11 @@
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -65,6 +65,9 @@ import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
import com.droiddeck.launcher.frontend.CoverArt
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.frontend.GameFiles
|
||||
import com.droiddeck.launcher.frontend.GameFileSync
|
||||
import com.droiddeck.launcher.frontend.GameLaunchIntent
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.RomsDialog
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
@@ -152,7 +155,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var forceFullscreen by mutableStateOf(true)
|
||||
private var forceFullscreen by mutableStateOf(false)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
@@ -399,10 +402,15 @@ class MainActivity : ComponentActivity() {
|
||||
private fun pickGameExport(game: Library.SteamGame?) {
|
||||
onSavePicked = { folder ->
|
||||
saveAction(getString(R.string.game_frontend_files)) {
|
||||
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
|
||||
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
|
||||
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
|
||||
getString(R.string.game_file_exported, folder.path)
|
||||
val saved = if (game != null) GameFiles.export(folder, game)
|
||||
else {
|
||||
GameFileSync.enable(this, folder)
|
||||
folder
|
||||
}
|
||||
ui.post {
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
}
|
||||
getString(R.string.game_file_exported, saved.path)
|
||||
}
|
||||
}
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
|
||||
@@ -416,10 +424,10 @@ class MainActivity : ComponentActivity() {
|
||||
shortcutLibraryScanning = true
|
||||
} else shortcutLibraryScanning = false
|
||||
pendingGameLink = null
|
||||
if (request.action == Intent.ACTION_VIEW) {
|
||||
if (GameLaunchIntent.accepts(request.action)) {
|
||||
val copy = Intent(request)
|
||||
Thread({
|
||||
val id = com.droiddeck.launcher.frontend.GameFiles.readIntent(this, copy)
|
||||
val id = GameLaunchIntent.read(this, copy)
|
||||
ui.post {
|
||||
if (intent === request && !isDestroyed) {
|
||||
pendingGameLink = id
|
||||
@@ -462,10 +470,14 @@ class MainActivity : ComponentActivity() {
|
||||
if (game == null) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else if (launchGame(game)) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +489,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
readGameIntent(intent)
|
||||
gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this)
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
@@ -556,7 +568,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
onStopGameFileSync = {
|
||||
Thread({
|
||||
com.droiddeck.launcher.frontend.GameFileSync.disable(this)
|
||||
GameFileSync.disable(this)
|
||||
ui.post { gameSyncFolder = null }
|
||||
}, "game-file-stop-sync").start()
|
||||
},
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -12,7 +12,7 @@ import java.io.InputStream
|
||||
|
||||
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
|
||||
object GameFiles {
|
||||
const val EXTENSION = "droiddeck"
|
||||
const val EXTENSION = "steam"
|
||||
private const val MAX_BYTES = 128
|
||||
private const val INVENTORY = ".droiddeck-sync.json"
|
||||
private const val FORMAT = "droiddeck-game-files-v1"
|
||||
@@ -46,35 +46,49 @@ object GameFiles {
|
||||
}
|
||||
if (count > MAX_BYTES) return null
|
||||
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
|
||||
return GameLaunchLink.parse(text) ?: text.takeIf(GameLaunchLink::validId)
|
||||
return text.takeIf(GameLaunchLink::validId)
|
||||
}
|
||||
|
||||
fun filename(game: Library.SteamGame): String {
|
||||
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
|
||||
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
|
||||
.trim().trim('.').ifBlank { "Game" }
|
||||
val points = sanitized.codePoints().limit(40).toArray()
|
||||
val title = String(points, 0, points.size)
|
||||
return "$title (${game.gameIdString}).$EXTENSION"
|
||||
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
|
||||
return "$title$suffix.$EXTENSION"
|
||||
}
|
||||
|
||||
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
|
||||
private fun contents(id: String): String {
|
||||
require(GameLaunchLink.validId(id))
|
||||
return id
|
||||
}
|
||||
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(folder, game, filename(game))
|
||||
|
||||
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val target = child(folder, filename(game))
|
||||
val text = contents(game.gameIdString)
|
||||
val target = exportTarget(folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
|
||||
if (!target.exists()) write(target, text)
|
||||
exportArt(folder, game)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(folder, name)
|
||||
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
|
||||
child(folder, filename(game, disambiguate = true))
|
||||
} else target
|
||||
}
|
||||
|
||||
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
|
||||
private fun exportArt(folder: File, game: Library.SteamGame) {
|
||||
val art = listOfNotNull(game.icon, game.art).firstOrNull {
|
||||
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
|
||||
} ?: return
|
||||
val title = filename(game).substringBeforeLast(" (${game.gameIdString})")
|
||||
val title = filename(game).substringBeforeLast(".")
|
||||
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
|
||||
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
|
||||
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
|
||||
@@ -95,12 +109,20 @@ object GameFiles {
|
||||
record.getJSONObject("games")
|
||||
} else JSONObject()
|
||||
val next = JSONObject()
|
||||
val exports = games.distinctBy { it.gameId }.map { game ->
|
||||
val target = child(folder, filename(game))
|
||||
val unique = games.distinctBy { it.gameId }
|
||||
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
|
||||
val exports = unique.map { game ->
|
||||
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
|
||||
val preferred = child(folder, name)
|
||||
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(folder, game, name)
|
||||
val text = contents(game.gameIdString)
|
||||
val existed = target.exists()
|
||||
// Keep an edited export without letting it block the rest of the library sync.
|
||||
val file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
|
||||
!matches(target, contents(game.gameIdString))) target else export(folder, game)
|
||||
next.put(file.name, game.gameIdString)
|
||||
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(folder, game, target.name)
|
||||
if (!existed || owned) next.put(file.name, game.gameIdString)
|
||||
file
|
||||
}
|
||||
// Write new exports before pruning; a failed write must not remove old launch files.
|
||||
@@ -119,6 +141,16 @@ object GameFiles {
|
||||
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
|
||||
}
|
||||
|
||||
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
if (record.optString(file.name) == id) return true
|
||||
if (!file.exists()) return false
|
||||
return record.keys().asSequence().any { name ->
|
||||
record.optString(name) == id && child(folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun child(folder: File, name: String): File {
|
||||
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
|
||||
val file = File(folder, name)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
object GameLaunchIntent {
|
||||
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
|
||||
const val EXTRA_APP_ID = "app_id"
|
||||
const val EXTRA_GAME_SOURCE = "game_source"
|
||||
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
|
||||
|
||||
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
|
||||
|
||||
fun read(context: Context, intent: Intent): String? = when (intent.action) {
|
||||
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
|
||||
ACTION, GAMENATIVE_ACTION -> readId(intent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun readId(intent: Intent): String? = runCatching {
|
||||
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
|
||||
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
|
||||
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
|
||||
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
|
||||
is Int -> value.takeIf { it > 0 }?.toString()
|
||||
is String -> value.takeIf(GameLaunchLink::validId)
|
||||
else -> null
|
||||
}
|
||||
id
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -300,7 +300,10 @@ object BwrapSpawner {
|
||||
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
|
||||
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
|
||||
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
|
||||
// Android 16 refuses the size seal Firefox puts on a tab's shared
|
||||
// memory, and the tab then dies on every page (droiddeck-desktop).
|
||||
"MOZ_SHM_NO_SEALS")) {
|
||||
env.putIfAbsent(name, "1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,9 +168,12 @@ object SessionPrefs {
|
||||
* Steam only: gamescope makes every game window the size of the screen. A game that resizes
|
||||
* its own window when it loses focus (FlatOut) otherwise comes back smaller, drawn in a
|
||||
* corner; a game that sets its own resolution and never looks at its window again (Quake 3)
|
||||
* instead draws small in the bottom-left of the stretched one. On unless turned off.
|
||||
* instead draws small in the bottom-left of the stretched one. A game whose resolution differs
|
||||
* from the screen's (DiRT 3 at 1280x720) fights it: it rebuilds its swapchain on every forced
|
||||
* resize and the picture flickers between its own size and the screen's, often from the first
|
||||
* menu, before the player can reach its resolution setting. Off unless turned on.
|
||||
*/
|
||||
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", true)
|
||||
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", false)
|
||||
|
||||
fun setForceFullscreen(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("forceFullscreen", on).apply()
|
||||
|
||||
@@ -1039,8 +1039,8 @@
|
||||
<string name="display_frame_rate">Frame rate</string>
|
||||
<string name="display_window_compatibility">Game window compatibility</string>
|
||||
<string name="display_force_fullscreen">Force game windows fullscreen</string>
|
||||
<string name="display_force_fullscreen_hint">Keeps games fullscreen after focus changes. Turn off if a game draws small. Applies live during a session.</string>
|
||||
<string name="display_force_fullscreen_live">Keeps games fullscreen after focus changes. Applies live.</string>
|
||||
<string name="display_force_fullscreen_hint">Makes every game window the size of the screen. Turn on if a game comes back small or in a corner after switching apps; leave off if a game flickers between sizes. Applies live during a session.</string>
|
||||
<string name="display_force_fullscreen_live">Turn on if a game comes back small after switching; off if it flickers. Applies live.</string>
|
||||
<string name="display_look_filter">%1$s Sets scaling to %2$s.</string>
|
||||
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
|
||||
@@ -27,6 +27,7 @@ class GameFileSyncTest {
|
||||
}
|
||||
val folder = File(context.filesDir, "exports").apply { mkdirs() }
|
||||
GameFileSync.enable(context, folder)
|
||||
assertEquals("8400", File(folder, "Geometry Wars.steam").readText())
|
||||
assertEquals(folder.path, GameFileSync.folder(context))
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
// An unavailable source must leave the existing export intact.
|
||||
@@ -35,6 +36,8 @@ class GameFileSyncTest {
|
||||
assertThrows(IllegalStateException::class.java) { GameFileSync.syncConfigured(context) }
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
assertTrue(unavailable.renameTo(steamapps))
|
||||
GameFileSync.syncConfigured(context)
|
||||
assertTrue(File(folder, "Geometry Wars.steam").exists())
|
||||
// A genuine uninstall removes the owned export at the next tick.
|
||||
assertTrue(manifest.delete())
|
||||
GameFileSync.syncConfigured(context)
|
||||
@@ -44,6 +47,7 @@ class GameFileSyncTest {
|
||||
manifest.writeText("\"name\" \"Geometry Wars\"\n\"StateFlags\" \"4\"")
|
||||
GameFileSync.syncConfigured(context)
|
||||
assertNull(GameFileSync.folder(context))
|
||||
assertFalse(File(folder, "Geometry Wars.steam").exists())
|
||||
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class GameFilesTest {
|
||||
@Test fun exportsPortableFilesAndAcceptsFileUriRawPathAndContentUri() {
|
||||
val folder = tmp.newFolder()
|
||||
val file = GameFiles.export(folder, game("18446744073709551615", "A/B: C"))
|
||||
assertEquals("A_B_ C (18446744073709551615).droiddeck", file.name)
|
||||
assertEquals("droiddeck://game/18446744073709551615\n", file.readText())
|
||||
assertEquals("A_B_ C.steam", file.name)
|
||||
assertEquals("18446744073709551615", file.readText())
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
listOf(Uri.fromFile(file), Uri.parse(file.path)).forEach { uri ->
|
||||
assertEquals("18446744073709551615", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, uri)))
|
||||
@@ -44,15 +44,15 @@ class GameFilesTest {
|
||||
|
||||
@Test fun rejectsCommandsMalformedIdsLargeFilesAndUnsupportedIntents() {
|
||||
listOf("", "0", "01", "-1", "18446744073709551616", "steam://rungameid/8400",
|
||||
"8400\n8401", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
|
||||
"8400\n8401", "droiddeck://game/8400", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
|
||||
assertNull(value, GameFiles.read(ByteArrayInputStream(value.toByteArray())))
|
||||
}
|
||||
assertEquals("8400", GameFiles.read(ByteArrayInputStream("8400\n".toByteArray())))
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_SEND, Uri.parse("droiddeck://game/8400"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.droiddeck"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.droiddeck"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.droiddeck")))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.steam"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.steam"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.steam")))))
|
||||
assertEquals("8400", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("droiddeck://game/8400"))))
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ class GameFilesTest {
|
||||
assertEquals("custom artwork", image.readText())
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertEquals("custom artwork", image.readText())
|
||||
assertEquals(0, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
|
||||
assertEquals("8400", File(folder, GameFiles.filename(game)).readText())
|
||||
assertEquals(1, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
|
||||
icon.delete()
|
||||
val fallback = tmp.newFolder()
|
||||
GameFiles.export(fallback, game)
|
||||
@@ -80,7 +81,7 @@ class GameFilesTest {
|
||||
|
||||
@Test fun syncAddsRenamesAndRemovesOwnedFilesAndPreservesUnrelatedAndEditedFiles() {
|
||||
val folder = tmp.newFolder()
|
||||
val unrelated = File(folder, "manual.droiddeck").apply { writeText("droiddeck://game/1\n") }
|
||||
val unrelated = File(folder, "manual.steam").apply { writeText("1") }
|
||||
val note = File(folder, "notes.txt").apply { writeText("Keep me") }
|
||||
val first = game()
|
||||
val second = game("620", "Portal 2")
|
||||
@@ -110,10 +111,12 @@ class GameFilesTest {
|
||||
GameFiles.sync(folder, listOf(first))
|
||||
val second = game("620", "Portal 2")
|
||||
val collision = File(folder, GameFiles.filename(second)).apply { writeText("personal file") }
|
||||
val fallback = File(folder, GameFiles.filename(second, disambiguate = true)).apply { writeText("another personal file") }
|
||||
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(folder, listOf(second)) }
|
||||
assertEquals("personal file", collision.readText())
|
||||
assertTrue(File(folder, GameFiles.filename(first)).isFile)
|
||||
collision.delete()
|
||||
fallback.delete()
|
||||
GameFiles.sync(folder, listOf(second))
|
||||
assertFalse(File(folder, GameFiles.filename(first)).exists())
|
||||
assertTrue(collision.isFile)
|
||||
@@ -132,10 +135,10 @@ class GameFilesTest {
|
||||
@Test fun refusesSymlinksLeavingTheExportFolder() {
|
||||
val folder = tmp.newFolder()
|
||||
val outside = tmp.newFile()
|
||||
outside.writeText("droiddeck://game/8400\n")
|
||||
outside.writeText("8400")
|
||||
java.nio.file.Files.createSymbolicLink(File(folder, GameFiles.filename(game())).toPath(), outside.toPath())
|
||||
assertThrows(IllegalArgumentException::class.java) { GameFiles.export(folder, game()) }
|
||||
assertEquals("droiddeck://game/8400\n", outside.readText())
|
||||
assertEquals("8400", outside.readText())
|
||||
}
|
||||
|
||||
@Test fun caseOnlyRenameKeepsTheLiveExportOnCaseInsensitiveStorage() {
|
||||
@@ -148,6 +151,8 @@ class GameFilesTest {
|
||||
assertTrue(file.isFile)
|
||||
assertEquals("8400", file.inputStream().use(GameFiles::read))
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertFalse(file.exists())
|
||||
}
|
||||
|
||||
@Test fun unrelatedInventoryIsPreserved() {
|
||||
@@ -158,6 +163,52 @@ class GameFilesTest {
|
||||
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
}
|
||||
|
||||
@Test fun steamExportContainsOnlyIdForSteamAndAddedGames() {
|
||||
val folder = tmp.newFolder()
|
||||
val file = GameFiles.export(folder, game("620", "Portal 2"))
|
||||
assertEquals("Portal 2.steam", file.name)
|
||||
assertEquals("620", file.readText())
|
||||
assertEquals("620", GameFiles.readIntent(RuntimeEnvironment.getApplication(), Intent(Intent.ACTION_VIEW, Uri.fromFile(file))))
|
||||
val added = Library.SteamGame(1, "Added Game", null, Library.ADDED, -1L)
|
||||
val addedFile = GameFiles.export(folder, added)
|
||||
assertEquals("Added Game.steam", addedFile.name)
|
||||
assertEquals("18446744073709551615", addedFile.readText())
|
||||
}
|
||||
|
||||
@Test fun steamSyncDisambiguatesTitlesAndPreservesEditedAndForeignFiles() {
|
||||
val folder = tmp.newFolder()
|
||||
val first = game("620", "A/B")
|
||||
val second = game("8400", "A:B")
|
||||
val foreign = File(folder, "Existing.steam").apply { writeText("1") }
|
||||
val existing = game("1", "Existing")
|
||||
GameFiles.sync(folder, listOf(first, second, existing))
|
||||
val owned = File(folder, "A_B (620).steam")
|
||||
assertEquals("620", owned.readText())
|
||||
assertEquals("8400", File(folder, "A_B (8400).steam").readText())
|
||||
owned.writeText("user edit")
|
||||
GameFiles.sync(folder, listOf(first, second, existing))
|
||||
assertEquals("user edit", owned.readText())
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertEquals("user edit", owned.readText())
|
||||
assertEquals("1", foreign.readText())
|
||||
assertFalse(File(folder, "A_B (8400).steam").exists())
|
||||
}
|
||||
|
||||
@Test fun steamCollisionFallbackDoesNotClaimAnExistingFile() {
|
||||
val folder = tmp.newFolder()
|
||||
val collision = File(folder, "Geometry Wars.steam").apply { writeText("other file") }
|
||||
val existing = File(folder, "Geometry Wars (8400).steam").apply { writeText("8400") }
|
||||
GameFiles.sync(folder, listOf(game()))
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertEquals("other file", collision.readText())
|
||||
assertEquals("8400", existing.readText())
|
||||
existing.writeText("another file")
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GameFiles.sync(folder, listOf(game()))
|
||||
}
|
||||
assertEquals("another file", existing.readText())
|
||||
}
|
||||
|
||||
private class LaunchFileProvider(private val file: File) : ContentProvider() {
|
||||
override fun onCreate() = true
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
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 GameLaunchIntentTest {
|
||||
@Test fun acceptsNativeAndExplicitlyRetargetedGameNativeProfiles() {
|
||||
listOf(GameLaunchIntent.ACTION, "app.gamenative.LAUNCH_GAME").forEach { action ->
|
||||
val request = Intent(action).putExtra("app_id", 620).putExtra("game_source", "STEAM")
|
||||
assertEquals("620", GameLaunchIntent.read(RuntimeEnvironment.getApplication(), request))
|
||||
assertEquals("8400", GameLaunchIntent.readId(Intent(action).putExtra("app_id", "8400")))
|
||||
assertEquals("620", GameLaunchIntent.readId(request.putExtra("game_source", "steam")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun stringIdsPreserveUnsignedShortcutIds() {
|
||||
listOf("2147483648", "18446744073709551615").forEach { id ->
|
||||
assertEquals(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun rejectsUnsupportedSourcesTypesAndIdsWithoutFallingBackToData() {
|
||||
listOf("", "0", "01", "+1", "-1", "18446744073709551616", "620\n", "steam://rungameid/620").forEach { id ->
|
||||
assertNull(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
|
||||
}
|
||||
listOf("EPIC", "GOG", "CUSTOM_GAME", "", "STEAM ").forEach { source ->
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", source)))
|
||||
}
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620L)))
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", -1)))
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", 1)))
|
||||
assertNull(GameLaunchIntent.readId(Intent(Intent.ACTION_MAIN).putExtra("app_id", 620)))
|
||||
assertNull(GameLaunchIntent.read(RuntimeEnvironment.getApplication(), Intent(GameLaunchIntent.ACTION,
|
||||
android.net.Uri.parse("droiddeck://game/620"))))
|
||||
}
|
||||
|
||||
@Test fun deepLinksStillAcceptUnsignedShortcutIds() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertEquals("18446744073709551615", GameLaunchIntent.read(context,
|
||||
Intent(Intent.ACTION_VIEW, android.net.Uri.parse("droiddeck://game/18446744073709551615"))))
|
||||
assertFalse(GameLaunchIntent.accepts(Intent.ACTION_SEND))
|
||||
assertTrue(GameLaunchIntent.accepts(GameLaunchIntent.ACTION))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export QT_QPA_PLATFORMTHEME=lxqt
|
||||
# The prefs for this are only advisory and a profile can override them; the environment is not.
|
||||
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
|
||||
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
|
||||
# Firefox seals the size of the shared memory it hands a tab; on Android 16 (Galaxy S25/S26,
|
||||
# RedMagic) adding the seal fails, Firefox only warns, and the tab refuses the unsealed memory
|
||||
# ("Shared memory PlatformHandle is not safe to map") and dies on signal 11 - every page. Firefox's
|
||||
# own switch skips the seals.
|
||||
export MOZ_SHM_NO_SEALS=1
|
||||
# pixman composites in software and gives programs shm buffers only, so a Vulkan program on it
|
||||
# fails at its swapchain. vulkan (or gles2) composite on the GPU and give programs DMA-BUFs, but
|
||||
# stock wlroots allocates through gbm on a real DRM node and the KGSL stand-in is not one ("unable
|
||||
|
||||
@@ -24,6 +24,8 @@ fi
|
||||
# X11, which it picks from the session type.
|
||||
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
|
||||
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
|
||||
# Android 16 refuses Firefox the size seal on a tab's shared memory; see droiddeck-desktop.
|
||||
export MOZ_SHM_NO_SEALS=1
|
||||
# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and
|
||||
# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads.
|
||||
if [ -s /etc/ld.so.preload ]; then
|
||||
|
||||
@@ -30,6 +30,11 @@ FEX_APPID = "3127680"
|
||||
SNIPER_APPID = "1628350"
|
||||
INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper"}
|
||||
MARKER = ".droiddeck-fex"
|
||||
# Without Steam's FEX and runtime: DroidDeck's own FEX build in /usr/local (the x86-64 client's,
|
||||
# with GL/Vulkan thunks) and a plain x86 root - FEX's Ubuntu image unpacked - marked complete here.
|
||||
OWN_ROOTFS = "/opt/droiddeck-fex/rootfs"
|
||||
OWN_ROOTFS_DONE = ".droiddeck-complete"
|
||||
SYSTEM_PREFIXES = ("/usr/local", "/usr")
|
||||
LAYOUT = 1
|
||||
GUEST_LIBDIRS = (("x86_64", "lib/x86_64-linux-gnu"), ("i386", "lib/i386-linux-gnu"))
|
||||
PRELOAD_NAMES = ("libblsession.so", "libfakeinput.so")
|
||||
@@ -142,7 +147,7 @@ def fex_install():
|
||||
tool = app_dir(FEX_APPID)
|
||||
if tool:
|
||||
prefixes += [(os.path.join(tool, sub), True) for sub in ("usr", "", "files/usr", "files")]
|
||||
prefixes.append(("/usr", False))
|
||||
prefixes += [(prefix, False) for prefix in SYSTEM_PREFIXES]
|
||||
for prefix, portable in prefixes:
|
||||
binary = next((os.path.join(prefix, "bin", name) for name in ("FEX", "FEXInterpreter")
|
||||
if os.access(os.path.join(prefix, "bin", name), os.X_OK)), None)
|
||||
@@ -179,6 +184,13 @@ def runtime_platform():
|
||||
return max(platforms, key=platform_version, default=None)
|
||||
|
||||
|
||||
def own_rootfs():
|
||||
if os.path.isfile(os.path.join(OWN_ROOTFS, OWN_ROOTFS_DONE)) and \
|
||||
os.path.isfile(os.path.join(OWN_ROOTFS, "lib/x86_64-linux-gnu/libc.so.6")):
|
||||
return OWN_ROOTFS
|
||||
return None
|
||||
|
||||
|
||||
def unescape(raw):
|
||||
return ESCAPE.sub(lambda match: bytes([int(match.group(1), 8)]), raw)
|
||||
|
||||
@@ -308,6 +320,7 @@ def request_install():
|
||||
|
||||
|
||||
def describe(fex, platform):
|
||||
# platform: Steam's sniper platform folder, or the own root when Steam's runtime is absent.
|
||||
return {
|
||||
"fex": fex["bin"] if fex else None,
|
||||
"server": fex["server"] if fex else None,
|
||||
@@ -322,7 +335,7 @@ def describe(fex, platform):
|
||||
def prepare():
|
||||
with locked():
|
||||
fex = fex_install()
|
||||
platform = runtime_platform()
|
||||
platform = runtime_platform() or own_rootfs()
|
||||
status = describe(fex, platform)
|
||||
if not status["ready"]:
|
||||
missing = []
|
||||
@@ -341,9 +354,12 @@ def prepare():
|
||||
"the Steam client was asked to install it" if requested else "open Steam once and it is installed for you"))
|
||||
write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n")
|
||||
return None
|
||||
root = os.path.join(platform, "files")
|
||||
if materialize(platform):
|
||||
say("prepared %s as the x86 runtime" % os.path.basename(platform))
|
||||
if platform == OWN_ROOTFS:
|
||||
root = OWN_ROOTFS
|
||||
else:
|
||||
root = os.path.join(platform, "files")
|
||||
if materialize(platform):
|
||||
say("prepared %s as the x86 runtime" % os.path.basename(platform))
|
||||
install_preloads(root)
|
||||
write_config(fex, root)
|
||||
fex["rootfs"] = root
|
||||
|
||||
@@ -179,9 +179,10 @@ if [ -z "${BL_INSIDE:-}" ]; then
|
||||
[ "$mode" = steam ] && args+=(--default-touch-mode 4)
|
||||
# A game that resizes its own window when it loses focus (the Steam menu opening over it) comes
|
||||
# back smaller, and gamescope draws a window that is not flagged fullscreen at its own size in
|
||||
# the top-left corner. Forcing every window to the output's size keeps it filling the screen.
|
||||
# BL_GAMESCOPE_FORCE_FULLSCREEN=0 turns it off.
|
||||
[ "$mode" = steam ] && [ "${BL_GAMESCOPE_FORCE_FULLSCREEN:-1}" = 1 ] && args+=(--force-windows-fullscreen)
|
||||
# the top-left corner. Forcing every window to the output's size keeps it filling the screen,
|
||||
# but a game running at a resolution other than the screen's fights it and flickers, so it is
|
||||
# off unless the app asks for it: BL_GAMESCOPE_FORCE_FULLSCREEN=1 turns it on.
|
||||
[ "$mode" = steam ] && [ "${BL_GAMESCOPE_FORCE_FULLSCREEN:-0}" = 1 ] && args+=(--force-windows-fullscreen)
|
||||
# HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
|
||||
# gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
|
||||
[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
|
||||
@@ -445,7 +446,7 @@ case $mode in
|
||||
# the root window, which it watches. What gamescope was started with is where this begins.
|
||||
# Not on the desktop (BL_DESKTOP): no gamescope there, and the loop would outlive the client.
|
||||
[ -z "${BL_DESKTOP:-}" ] && (
|
||||
last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-1}
|
||||
last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-0}
|
||||
while :; do
|
||||
nap 1
|
||||
want=
|
||||
|
||||
@@ -12,11 +12,19 @@
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAX_HANDLES 4096
|
||||
|
||||
/* DRM_IOCTL_GEM_CLOSE from drm.h: _IOW('d', 0x09, struct drm_gem_close { u32 handle, pad; }). */
|
||||
struct gem_close {
|
||||
uint32_t handle;
|
||||
uint32_t pad;
|
||||
};
|
||||
#define GEM_CLOSE _IOW('d', 0x09, struct gem_close)
|
||||
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int handle_fds[MAX_HANDLES];
|
||||
static dev_t kgsl_dev;
|
||||
@@ -82,20 +90,47 @@ int drmPrimeHandleToFD(int fd, uint32_t handle, uint32_t flags, int *prime_fd) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Drops a handle's descriptor; false when the handle was never given out. */
|
||||
static int release_handle(uint32_t handle) {
|
||||
int found = 0;
|
||||
pthread_mutex_lock(&lock);
|
||||
if (handle > 0 && handle < MAX_HANDLES && handle_fds[handle] > 0) {
|
||||
close(handle_fds[handle]);
|
||||
handle_fds[handle] = 0;
|
||||
found = 1;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return found;
|
||||
}
|
||||
|
||||
int drmCloseBufferHandle(int fd, uint32_t handle) {
|
||||
if (!is_kgsl(fd)) {
|
||||
int (*fn)(int, uint32_t) = (int (*)(int, uint32_t)) real("drmCloseBufferHandle");
|
||||
return fn ? fn(fd, handle) : -ENOSYS;
|
||||
}
|
||||
int ret = -EINVAL;
|
||||
pthread_mutex_lock(&lock);
|
||||
if (handle > 0 && handle < MAX_HANDLES && handle_fds[handle] > 0) {
|
||||
close(handle_fds[handle]);
|
||||
handle_fds[handle] = 0;
|
||||
ret = 0;
|
||||
return release_handle(handle) ? 0 : -EINVAL;
|
||||
}
|
||||
|
||||
/*
|
||||
* Zink gives its handles back with the GEM_CLOSE ioctl itself, not drmCloseBufferHandle
|
||||
* (zink_bo.c, bo_destroy). KGSL refuses the ioctl, so the duplicated descriptor stayed open and
|
||||
* kept the whole buffer alive: Xwayland asks for a handle for every buffer it imports, and a game
|
||||
* whose swapchain was recreated on each resize piled up gigabytes of old swapchain images until
|
||||
* Android killed the session.
|
||||
*/
|
||||
int drmIoctl(int fd, unsigned long request, void *arg) {
|
||||
if (request == GEM_CLOSE && arg && is_kgsl(fd)) {
|
||||
if (release_handle(((struct gem_close *) arg)->handle)) {
|
||||
return 0;
|
||||
}
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return ret;
|
||||
int (*fn)(int, unsigned long, void *) = (int (*)(int, unsigned long, void *)) real("drmIoctl");
|
||||
if (!fn) {
|
||||
return -1;
|
||||
}
|
||||
return fn(fd, request, arg);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -44,7 +44,7 @@ if [ "$pkg" = "$P" ]; then
|
||||
else
|
||||
python3 "$here/axml_rename.py" "$in" "$work/renamed.apk" \
|
||||
"$P=$pkg" "$P.androidx-startup=$pkg.androidx-startup" "$P.logs=$pkg.logs" \
|
||||
"$P.documents=$pkg.documents" "$P.home=$pkg.home" \
|
||||
"$P.documents=$pkg.documents" "$P.home=$pkg.home" "$P.agent=$pkg.agent" \
|
||||
"$P.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION=$pkg.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
|
||||
fi
|
||||
got=$("$BUILD_TOOLS/aapt" dump badging "$work/renamed.apk" | sed -n "s/^package: name='\([^']*\)'.*/\1/p")
|
||||
|
||||
@@ -245,6 +245,28 @@ class PrepareTest(FexTestCase):
|
||||
self.assertFalse((self.home / ".config/droiddeck/fex-wanted").exists())
|
||||
self.assertEqual(self.quiet(FEX["prepare"])[1], "")
|
||||
|
||||
def test_the_own_build_and_root_stand_in_for_steams(self):
|
||||
local = self.tmp / "usr-local"
|
||||
self.write(local / "bin/FEX", elf(183), 0o755)
|
||||
self.write(local / "bin/FEXServer", elf(183), 0o755)
|
||||
(local / "lib/fex-emu/HostThunks").mkdir(parents=True)
|
||||
(local / "share/fex-emu/GuestThunks").mkdir(parents=True)
|
||||
self.write(local / "share/fex-emu/ThunksDB.json", "{}")
|
||||
root = self.tmp / "own-rootfs"
|
||||
self.write(root / "lib/x86_64-linux-gnu/libc.so.6", elf(62))
|
||||
with mock.patch.dict(G, {"SYSTEM_PREFIXES": (str(local),), "OWN_ROOTFS": str(root),
|
||||
"steam_running": lambda: False}):
|
||||
# An unpack that did not finish is not a root.
|
||||
self.assertIsNone(self.quiet(FEX["prepare"])[0])
|
||||
self.write(root / ".droiddeck-complete")
|
||||
fex, said = self.quiet(FEX["prepare"])
|
||||
self.assertEqual((fex["bin"], fex["rootfs"], fex["portable"]), (str(local / "bin/FEX"), str(root), False))
|
||||
self.assertNotIn("prepared", said)
|
||||
status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text())
|
||||
self.assertEqual((status["ready"], status["runtime"], status["thunks"]), (True, str(root), True))
|
||||
config = json.loads((self.home / ".local/share/droiddeck-fex/config/Config.json").read_text())
|
||||
self.assertEqual(config["Config"]["RootFS"], str(root))
|
||||
|
||||
def test_status_refreshes_what_the_app_reads_once_steam_installed_fex(self):
|
||||
with mock.patch.dict(G, {"steam_running": lambda: False}):
|
||||
self.quiet(FEX["prepare"])
|
||||
|
||||
Reference in new issue
Block a user