Compare commits

...
Author SHA1 Message Date
The412Banner 5ca58f6fbe droiddeck-fex: use DroidDeck's own FEX and x86 root without Steam's
x86 Linux programs on the desktop (an x86_64 AppImage) needed Valve's
FEX (Steam app 3127680) and Steam Linux Runtime 3.0; without them they
exited with status 126. The x86-64 client work built our own FEX
(release fex-2609-r3: openat2 fallback for proot, GL/Vulkan/EGL/ALSA
thunks) installed under /usr/local, run against FEX's Ubuntu 24.04 x86
image.

Look for FEX in /usr/local as well as /usr, and when Steam's runtime is
absent accept a plain x86 root at /opt/droiddeck-fex/rootfs once its
unpack is marked complete. Steam's FEX and runtime still win when
installed.
2026-10-05 15:06:28 -04:00
MaxsTechReview e7cbba37d1 sign-apk: rename the agent provider's authority for the other packages (#285) 2026-10-05 14:41:38 -04:00
Kurt Himebauch d9bb5e15b0 Use plain-ID .steam files for frontend exports (#277) 2026-10-05 13:48:18 -04:00
The412BannerandThe412Banner 46cdc32cf1 Firefox: skip shared-memory seals (tabs crash on Android 16) (#280)
On Android 16 phones (Galaxy S25 SM-S931B, SM-S948B, RedMagic NP05J)
every Firefox and LibreWolf tab dies at once: Firefox seals the size of
the shared memory it hands a tab, adding the seal fails there, Firefox
only warns, and the tab refuses the unsealed memory ("IPDL protocol
error: Shared memory PlatformHandle is not safe to map") and exits on
signal 11. The window opens; no page ever loads.

MOZ_SHM_NO_SEALS=1 is Firefox's own switch to skip the seals
(ipc/glue/SharedMemoryPlatform_posix.cpp). Set it beside the sandbox
switches in all three places a browser starts from: the desktop session,
AppImages, and Flatpak sandboxes.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 13:37:08 -04:00
Lobanokivan11 387572e733 merge bridge to main (second time) (#279) 2026-10-05 17:09:24 +00:00
The412BannerandThe412Banner 9f46c7a50b Force game windows fullscreen: off by default (#276)
A game whose resolution differs from the screen's (DiRT 3 at 1280x720)
fights --force-windows-fullscreen: every forced resize makes it rebuild
its swapchain, and the picture flickers between its own size and the
screen's - often from the first menu, before the player can reach the
game's resolution setting. It also drove the Xwayland dma-buf leak fixed
separately.

The switch stays in Settings > Games and the session drawer, now off
unless turned on; its hint says when to turn it on (a game coming back
small or in a corner after switching apps, e.g. FlatOut) and when to
leave it off (flicker). Users who already set it keep their choice. The
session script's fallback when the app passes nothing is off as well.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 12:44:45 -04:00
The412BannerandThe412Banner 0607bb0b3a Fix Xwayland dma-buf leak: close Zink's emulated GEM handles on KGSL (#275)
Close Zink's emulated GEM handles on KGSL (Xwayland dma-buf leak)

The session preload hands out GEM handles on the KGSL render node by
duplicating the dma-buf descriptor, and released them only through
drmCloseBufferHandle. Zink gives handles back with
drmIoctl(DRM_IOCTL_GEM_CLOSE) instead, which KGSL refuses, so every
duplicated descriptor stayed open and kept its buffer alive.

Xwayland asks for a handle for each buffer it imports through gbm. With
"Force game windows fullscreen" fighting a game's own resolution
(DiRT 3 at 1280x720), the game recreates its swapchain many times a
second and Xwayland piled up ~2,000 old swapchain images (6+ GB) in
seconds until Android killed the session. A preload trace on the device
put 2,090 of 2,118 open dma-buf descriptors in Xwayland on
drmPrimeFDToHandle called from Zink.

Handle GEM_CLOSE on the KGSL node in drmIoctl, and check the three DRM
symbols are exported in CI.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 12:44:16 -04:00
21 changed files with 343 additions and 75 deletions

No files matched your search

+1 -1
View File
@@ -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"
-19
View File
@@ -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>
+18
View File
@@ -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()
},
@@ -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()
+2 -2
View File
@@ -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))
}
}
+5
View File
@@ -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=
+43 -8
View File
@@ -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);
}
/*
+1 -1
View File
@@ -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")
+22
View File
@@ -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"])