Compare commits

..
Author SHA1 Message Date
maxjivi05 0aee12298a fix: user apps open at the Desktop's resolution
A user app (script, AppImage or Flatpak) started from its shortcut runs in
a Run session, which took the Steam resolution (720p by default). It
ignored the resolution chosen under Open Desktop and was letterboxed at
1280x720 on screens that are not 16:9.

User apps now use the Desktop's resolution once one has been chosen. Until
then, and for every other Run program, nothing changes.
2026-10-19 21:04:48 -04:00
36 changed files with 98 additions and 1115 deletions

No files matched your search

+2 -34
View File
@@ -34,28 +34,10 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "[email protected]"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: xbox-cache
with:
@@ -98,7 +80,6 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Check co-authors
if: github.event_name == 'pull_request'
@@ -144,17 +125,14 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "[email protected]"
git fetch --no-tags origin main
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
BASE=$(git rev-parse --short origin/main)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -201,11 +179,6 @@ jobs:
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -580,11 +553,6 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
+1 -1
View File
@@ -47,7 +47,7 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
@@ -38,7 +38,6 @@ class TouchpadGestures(
private var moverLastX = 0f
private var moverLastY = 0f
private var holdDrag = false
private var scrollLocked = false
private var clicked = 0
private val longPress = Runnable {
held = true
@@ -68,7 +67,6 @@ class TouchpadGestures(
dragging = false
held = false
holdDrag = false
scrollLocked = false
anchorId = -1
moverId = -1
lastTapAt = 0L
@@ -86,7 +84,7 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
@@ -119,12 +117,10 @@ class TouchpadGestures(
val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId)
if (ai >= 0 && mi >= 0) {
val mx = event.getX(mi); val my = event.getY(mi)
if (!holdDrag && !scrolled && !scrollLocked) {
if (!holdDrag && !scrolled) {
val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY)
val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY)
if (anchorMoved >= slopPx) {
scrollLocked = true
} else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) {
if (moverMoved > slopPx && anchorMoved < slopPx) {
holdDrag = true
secondWasTap = false
if (!dragging) {
@@ -190,9 +186,9 @@ class TouchpadGestures(
if (gone == moverId) moverId = -1
if (gone == anchorId) {
anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0)
}
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
lastX = event.getX(it); lastY = event.getY(it)
event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
}
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
@@ -216,7 +212,7 @@ class TouchpadGestures(
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
holdDrag = false; anchorId = -1; moverId = -1
return true
}
}
@@ -229,7 +225,5 @@ class TouchpadGestures(
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
}
@@ -37,7 +37,7 @@ object StoreLaunchRequests {
}
fun stop() {
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -52,10 +52,9 @@ object StoreLaunchRequests {
}
val reply = when (request.optString("op")) {
"xbox-released" -> {
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease"))
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id"))
JSONObject().put("ok", true)
}
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
@@ -168,12 +168,10 @@ object StoresState {
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
background("stores-signout") {
val backend = backends[store]
backend?.signOut(app)
backends[store]?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
else StoreAccounts.clear(app, store)
main.post {
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
@@ -19,15 +19,12 @@ object XboxBackend : StoreBackend {
context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
XboxCredentials.invalidate(context) {
synchronized(this) {
generation++
access = emptyMap()
}
XboxLibraryCache.clear(context)
XboxRuntime.stop()
}
@Synchronized override fun signOut(context: Context) {
generation++
XboxCredentials.invalidate(context)
XboxLibraryCache.clear(context)
XboxRuntime.stop()
access = emptyMap()
StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
}
@@ -28,16 +28,15 @@ internal object XboxCatalog {
(row.isNull("satisfiedByProductId") || productId.matches(row.optString("satisfiedByProductId")))
if (!valid || id in rows || id in blocked) { blocked.add(id); rows.remove(id) } else rows[id] = row
}
fun active(row: JSONObject) = row.getString("status") == "Active" && !row.getBoolean("isTrial") && !row.getBoolean("special") &&
row.getString("sharingSource") == "None" && timestamp(row.getString("endDate")).isAfter(now)
fun kind(id: String, visited: Set<String> = emptySet()): String? {
if (id in blocked || id in visited || visited.size >= 16) return null
val row = rows[id] ?: return null
if (!active(row)) return null
if (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") ||
row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
if (parent == null) return if (id in plans) null else "owned"
if (parent in blocked) return null
if (parent in plans) return if (rows[parent]?.let(::active) == true) "gamepass" else null
if (parent in plans) return "gamepass"
return kind(parent, visited + id)
}
return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
@@ -10,68 +10,11 @@ import java.io.Closeable
import java.io.InputStream
import java.util.UUID
internal class XboxCredentials(private val app: Context, expectedGeneration: String? = null) : Closeable {
internal class XboxCredentials(private val app: Context) : Closeable {
val name = "droiddeck-xbox-${UUID.randomUUID()}"
private val generation = lifecycle(app, expectedGeneration) { it }
private val generation = storage(app) { epoch(app) }
private val server = LocalServerSocket(name)
@Volatile private var closed = false
private val bound = java.util.concurrent.CountDownLatch(1)
private var backend: Process? = null
private var binaryHash = byteArrayOf()
private lateinit var binary: java.io.File
private val peers = java.util.concurrent.ConcurrentHashMap<Int, String>()
fun bind(process: Process, file: java.io.File, digest: ByteArray) {
backend = process
binary = file.canonicalFile
binaryHash = digest
bound.countDown()
}
private fun rejected(reason: String): Boolean {
android.util.Log.w("XboxCredentials", "Rejected credential peer: $reason")
return false
}
private fun authorized(pid: Int): Boolean {
if (!bound.await(5, java.util.concurrent.TimeUnit.SECONDS) || closed) return rejected("unbound")
val process = backend ?: return rejected("missing-child")
if (!process.isAlive) return rejected("exited-child")
val root = com.droiddeck.launcher.core.HostProcess.pidOf(process)
if (root <= 0 || pid <= 0) return rejected("missing-process-id")
val identity = identity(pid, root) ?: return rejected("foreign-backend")
if (peers[pid] == identity) return true
val digest = java.security.MessageDigest.getInstance("SHA-256")
binary.inputStream().use { input ->
val buffer = ByteArray(65536)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
if (!java.security.MessageDigest.isEqual(binaryHash, digest.digest())) return rejected("different-executable")
peers[pid] = identity
return true
}
private fun identity(pid: Int, root: Int): String? {
if (root <= 0 || pid <= 0) return null
val status = java.io.File("/proc/$pid/status").readLines()
val tracer = status.first { it.startsWith("TracerPid:") }.substringAfter(':').trim().toInt()
val tracerGroup = java.io.File("/proc/$tracer/status").readLines()
.first { it.startsWith("Tgid:") }.substringAfter(':').trim().toInt()
if (tracerGroup != root) return null
val executable = android.system.Os.stat(binary.path)
if (!mapsBackend(java.io.File("/proc/$pid/maps").readText(), binary.path, executable.st_ino))
return null
return java.io.File("/proc/$pid/stat").readText().substringAfterLast(") ").split(' ')[19] + ":${executable.st_dev}:${executable.st_ino}"
}
fun signalBackend(signal: Int) {
val process = backend ?: return
if (!process.isAlive) return
val root = com.droiddeck.launcher.core.HostProcess.pidOf(process)
peers.forEach { (pid, expected) -> runCatching {
if (identity(pid, root) == expected) android.system.Os.kill(pid, signal)
} }
}
init {
Thread({
@@ -80,17 +23,12 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str
socket.use {
it.soTimeout = 10_000
val reply = runCatching {
val peer = it.peerCredentials
check(peer.uid == android.os.Process.myUid() && authorized(peer.pid))
val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152))
check(it.peerCredentials.uid == android.os.Process.myUid())
storage(app) {
check(!closed && epoch(app) == generation && authorized(peer.pid))
answer(request)
check(epoch(app) == generation)
answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152)))
}
}.getOrElse {
android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}")
JSONObject().put("ok", false)
}
}.getOrElse { JSONObject().put("ok", false) }
runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) }
}
}
@@ -120,25 +58,14 @@ internal class XboxCredentials(private val app: Context, expectedGeneration: Str
return result
}
override fun close() { closed = true; bound.countDown(); runCatching { server.close() } }
override fun close() { closed = true; runCatching { server.close() } }
companion object {
val lock = Any()
internal fun mapsBackend(maps: String, path: String, inode: Long): Boolean = maps.lineSequence().any { line ->
val fields = line.trim().split(Regex("\\s+"), limit = 6)
fields.size == 6 && 'x' in fields[1] && fields[4].toLongOrNull() == inode &&
runCatching { java.io.File(fields[5]).canonicalPath == path }.getOrDefault(false)
}
private fun epoch(app: Context): String = runCatching { java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").readText() }.getOrDefault("")
fun <T> lifecycle(app: Context, expected: String? = null, block: (String) -> T): T = storage(app) {
val current = epoch(app)
check(expected == null || expected == current) { "Xbox account changed during the request" }
block(current)
}
fun invalidate(app: Context, revoked: () -> Unit = {}) = storage(app) {
fun invalidate(app: Context) = storage(app) {
java.io.File(StoreAccounts.dir(app, Store.XBOX), "generation").writeText(UUID.randomUUID().toString())
StoreAccounts.clear(app, Store.XBOX)
revoked()
}
fun <T> storage(app: Context, block: () -> T): T = synchronized(lock) {
java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file ->
@@ -1,38 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import java.io.Closeable
import java.util.UUID
internal class XboxLaunchLeases(private val now: () -> Long) {
private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null)
private val leases = HashMap<String, Lease>()
@Synchronized fun active(id: String) = leases.values.any { it.id == id }
@Synchronized fun begin(id: String, account: String, session: String): String {
check(!active(id)) { "This Xbox game is already starting or running." }
val token = UUID.randomUUID().toString()
leases[token] = Lease(id, account, session, now() + 180_000)
return token
}
@Synchronized fun attach(token: String, child: Closeable): Boolean {
val lease = leases[token] ?: return false
lease.child = child
return true
}
@Synchronized fun renew(token: String, id: String, account: String?, session: String?): Boolean {
val lease = leases[token] ?: return false
if (lease.id != id || lease.account != account || lease.session != session || lease.expires <= now()) return false
lease.expires = now() + 60_000
return true
}
@Synchronized fun release(token: String): Closeable? = leases.remove(token)?.child
@Synchronized fun stale(account: String?, session: String?, suspended: Boolean): List<Closeable> {
if (suspended) leases.values.filter { it.account == account && it.session == session }
.forEach { it.expires = maxOf(it.expires, now() + 60_000) }
val expired = leases.filterValues { it.account != account || it.session != session || (!suspended && it.expires <= now()) }.keys
return expired.mapNotNull { leases.remove(it)?.child }
}
@Synchronized fun clear(): List<Closeable> {
return leases.values.mapNotNull { it.child }.also { leases.clear() }
}
}
@@ -1,40 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import java.io.File
import java.nio.file.Files
import java.nio.file.FileVisitResult
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
internal class XboxLaunchViews {
private val recovered = HashSet<String>()
@Synchronized fun create(files: File): File {
val parent = File(files, "xbox-runtime/views").canonicalFile
check(parent == File(files.canonicalFile, "xbox-runtime/views")) { "Xbox launch storage contains a redirected path" }
check(parent.mkdirs() || parent.isDirectory)
if (parent.path !in recovered) {
parent.listFiles()?.filter { it.name.startsWith("launch-") }?.forEach(::remove)
recovered.add(parent.path)
}
return Files.createTempDirectory(parent.toPath(), "launch-").toFile()
}
companion object {
fun remove(directory: File) {
if (!Files.exists(directory.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walkFileTree(directory.toPath(), object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.deleteIfExists(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, error: java.io.IOException?): FileVisitResult {
if (error != null) throw error
Files.deleteIfExists(dir)
return FileVisitResult.CONTINUE
}
})
}
}
}
@@ -48,8 +48,7 @@ class XboxLoginActivity : ComponentActivity() {
Thread({
if (closed) return@Thread
try {
val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it }
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process ->
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process ->
child = process
if (closed) return@use
Xodus.diagnostics(process)
@@ -100,9 +99,9 @@ class XboxLoginActivity : ComponentActivity() {
}
child = null
if (closed) return@Thread
val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration)
val account = Xodus.run(applicationContext, listOf("account", "--json"))
check(account.optBoolean("stored"))
XboxCredentials.lifecycle(this, accountGeneration) {
XboxCredentials.storage(this) {
val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing")
record.put("username", account.getString("username"))
StoreAccounts.write(this, Store.XBOX, record)
@@ -7,110 +7,40 @@ import org.json.JSONObject
import java.io.File
internal object XboxRuntime {
private val state = Any()
private val startup = Any()
private var child: Xodus.Child? = null
private var owner: String? = null
private var serviceSocket: File? = null
private var generation = 0L
private val views = XboxLaunchViews()
private val launches = XboxLaunchLeases { android.os.SystemClock.uptimeMillis() }
fun isActive(id: String): Boolean = launches.active(id)
fun released(token: String) { runCatching { launches.release(token)?.close() } }
fun renew(app: Context, id: String, token: String): Boolean = launches.renew(token, id,
StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running })
private val activeGames = HashMap<String, Int>()
@Synchronized fun isActive(id: String): Boolean = activeGames.getOrDefault(id, 0) > 0
@Synchronized fun released(id: String) {
val count = activeGames.getOrDefault(id, 0)
if (count <= 1) activeGames.remove(id) else activeGames[id] = count - 1
}
private data class Authorization(val account: String, val token: String, val runtime: Long, val credentials: String)
fun prepare(app: Context, id: String): JSONObject {
@Synchronized fun prepare(app: Context, id: String): JSONObject {
check(!XboxContent.busy(id)) { "Wait for Xbox content downloads to finish" }
check(SessionState.running) { "No running DroidDeck session" }
val session = SessionState.sessionId ?: error("No running DroidDeck session")
val game = StoreInstallRoot.gameFolders(app).mapNotNull { folder ->
StoreGameSidecar.read(folder)?.takeIf { it.store == Store.XBOX && it.id == id && it.isInstalled }?.let { folder to it }
}.firstOrNull() ?: error("Xbox game is not installed")
val (account, token, current, credentials) = XboxCredentials.lifecycle(app) { epoch ->
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
synchronized(state) { Authorization(account, launches.begin(id, account, session), generation, epoch) }
}
Thread({
while (isActive(id)) {
Thread.sleep(10_000)
launches.stale(StoreAccounts.signedInAs(app, Store.XBOX), SessionState.sessionId.takeIf { SessionState.running },
SessionState.suspended).forEach { runCatching { it.close() } }
}
}, "xbox-launch-expiry").start()
try {
val socket = service(app, account, current, credentials)
check(synchronized(state) { generation == current }) { "Xbox launch was cancelled" }
val market = game.second.extra["market"]?.takeIf { it.matches(Regex("[A-Z]{2}")) } ?: Xodus.market()
val directory = views.create(app.filesDir)
val process = try {
Xodus.start(app, listOf("run", game.first.path, "droiddeck:prepare", "--exe", game.second.exe,
"--materialize-chain", "--market", market), launchDirectory = directory, credentialGeneration = credentials)
} catch (e: Exception) { XboxLaunchViews.remove(directory); throw e }
if (!launches.attach(token, process)) { process.close(); error("Xbox launch was cancelled") }
Xodus.diagnostics(process)
val result = java.util.concurrent.FutureTask {
val input = process.process.inputStream.buffered()
var line: String
do { line = XboxCredentials.readLine(input, 131_072) } while (!line.startsWith("DROIDDECK_LAUNCH_V1 "))
JSONObject(line.removePrefix("DROIDDECK_LAUNCH_V1 "))
}
Thread(result, "xbox-launch-view").start()
val view = result.get(90, java.util.concurrent.TimeUnit.SECONDS)
check(renew(app, id, token) && synchronized(state) { generation == current }) { "Xbox launch was cancelled" }
return JSONObject().put("ok", true).put("socket", socket.path).put("lease", token)
.put("exe", view.getString("exe")).put("playfab", view.opt("playfab"))
} catch (e: Exception) { released(token); throw e }
}
internal fun newServiceSocket(files: File): File =
File(files, "xbox-runtime/s-${java.util.UUID.randomUUID().toString().replace("-", "")}/xodus.sock")
private fun service(app: Context, account: String, current: Long, credentials: String): File = synchronized(startup) {
val existing = synchronized(state) {
check(generation == current) { "Xbox launch was cancelled" }
serviceSocket?.takeIf { child?.process?.isAlive == true && owner == account }
}
val socket = existing ?: newServiceSocket(app.filesDir)
if (existing == null) {
val old = synchronized(state) { child.also { child = null; owner = null; serviceSocket = null } }
old?.close()
check(StoreGameSidecar.find(app, Store.XBOX, id)?.isInstalled == true) { "Xbox game is not installed" }
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: error("Sign in to Xbox before playing")
val socket = File(app.filesDir, "xbox-runtime/xodus.sock")
if (child?.process?.isAlive != true || owner != account) {
stop()
socket.delete()
val service = Xodus.start(app, emptyList(), service = true, runtimeDirectory = socket.parentFile!!, credentialGeneration = credentials)
val accepted = synchronized(state) {
(generation == current).also { if (it) { child = service; owner = account; serviceSocket = socket } }
}
if (!accepted) { service.close(); error("Xbox launch was cancelled") }
try {
Xodus.diagnostics(service)
Thread({ runCatching { service.process.inputStream.use { input ->
val buffer = ByteArray(8192)
while (input.read(buffer) >= 0) { Thread.yield() }
} } }, "xbox-service-output").start()
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
while (!socket.exists() && service.process.isAlive && SessionState.running &&
synchronized(state) { generation == current } && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
check(socket.exists() && service.process.isAlive && SessionState.running) {
"Xbox account service did not become ready. Check your sign-in and network connection."
}
} catch (e: Exception) {
synchronized(state) { if (child === service) { child = null; owner = null; serviceSocket = null } }
service.close()
throw e
val service = Xodus.start(app, emptyList(), service = true)
child = service; owner = account
Xodus.diagnostics(service)
Thread({ runCatching { service.process.inputStream.use { val buffer = ByteArray(8192); while (it.read(buffer) >= 0) {} } } }, "xbox-service-output").start()
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
while (!socket.exists() && service.process.isAlive && SessionState.running && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(100)
if (!socket.exists() || !service.process.isAlive || !SessionState.running) {
stop(); error("Xbox account service did not become ready. Check your sign-in and network connection.")
}
}
socket
activeGames[id] = activeGames.getOrDefault(id, 0) + 1
return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket)
}
fun stop() {
val processes = synchronized(state) {
generation++
val all = launches.clear() + listOfNotNull(child)
child = null; owner = null; serviceSocket = null
all
}
Thread({ processes.forEach { runCatching { it.close() } } }, "xbox-runtime-stop").start()
@Synchronized fun stop() {
val process = child; child = null; owner = null; activeGames.clear()
runCatching { process?.close() }
}
}
@@ -13,32 +13,16 @@ internal object Xodus {
const val DIR = "/usr/local/lib/droiddeck-xbox"
fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US"
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
class Child(val process: Process, private val credentials: XboxCredentials) : Closeable {
val credentialSocket: String get() = credentials.name
@Synchronized fun send(frame: JSONObject) {
process.outputStream.write((frame.toString() + "\n").toByteArray())
process.outputStream.flush()
}
override fun close() {
try {
credentials.close()
runCatching { process.outputStream.close() }
if (!process.waitFor(2, TimeUnit.SECONDS)) {
credentials.signalBackend(android.system.OsConstants.SIGINT)
if (!process.waitFor(2, TimeUnit.SECONDS)) {
credentials.signalBackend(android.system.OsConstants.SIGKILL)
if (!process.waitFor(1, TimeUnit.SECONDS)) {
val pid = com.droiddeck.launcher.core.HostProcess.pidOf(process)
if (pid > 0) android.os.Process.killProcess(pid)
}
}
}
} finally {
runtimeDirectory?.let { directory ->
File(directory, "xodus.sock").delete()
directory.delete()
}
launchDirectory?.let(XboxLaunchViews::remove)
}
process.destroy()
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
credentials.close()
}
}
@@ -61,20 +45,19 @@ internal object Xodus {
File(root, "version").writeText(packaged)
}
fun start(app: Context, args: List<String>, service: Boolean = false, runtimeDirectory: File? = null, launchDirectory: File? = null, credentialGeneration: String? = null): Child {
fun start(app: Context, args: List<String>, service: Boolean = false): Child {
check(LinuxRuntime.isInstalled(app)) { "Install the Linux runtime before using Xbox." }
stage(app)
val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}"
check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." }
val credentials = XboxCredentials(app, credentialGeneration)
val credentials = XboxCredentials(app)
try {
val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() }
val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() }
val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf(
"/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off",
"XDG_RUNTIME_DIR=${runtime.path}", "DROIDDECK_XBOX_CREDENTIAL_SOCKET=${credentials.name}", binary,
) + args)
val builder = ProcessBuilder(command).directory(LinuxRuntime.rootDir(app))
if (launchDirectory != null) builder.environment()["TMPDIR"] = launchDirectory.path
builder.environment().apply {
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
@@ -82,19 +65,12 @@ internal object Xodus {
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val digest = java.security.MessageDigest.getInstance("SHA-256")
app.assets.open("linuxfs$binary").use { input ->
val buffer = ByteArray(65536)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
val process = builder.start()
credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest())
return Child(process, credentials, runtimeDirectory, launchDirectory)
return Child(builder.start(), credentials)
} catch (e: Exception) { credentials.close(); throw e }
}
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
start(app, args, credentialGeneration = credentialGeneration).use { child ->
fun run(app: Context, args: List<String>, timeout: Long = 90): JSONObject {
start(app, args).use { child ->
val output = java.io.ByteArrayOutputStream()
var readError: Throwable? = null
val reader = Thread({
@@ -59,22 +59,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.util.Locale
internal val Shape14 = RoundedCornerShape(14.dp)
@Composable
internal fun headingUppercase(text: String): String =
text.uppercase(LocalConfiguration.current.locales[0] ?: Locale.ROOT)
@Composable
internal fun Eyebrow(t: String) {
val pal = LocalPalette.current
@@ -82,7 +76,7 @@ internal fun Eyebrow(t: String) {
LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal))
Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
}
}
@@ -94,7 +88,7 @@ internal fun Eyebrow(t: String) {
internal fun SectionTitle(t: String, detail: String?) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) {
Text(headingUppercase(t), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground)
Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
}
@@ -125,8 +125,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
if (store == Store.XBOX) {
SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) {
runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) }
.onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." }
com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed)
}
SecondaryButton("Manage content", enabled = download == null) {
com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed)
@@ -961,7 +961,7 @@ private suspend fun <T> CompletableFuture<T>.await(): T =
@Composable
private fun SectionLabel(title: String) {
Text(
headingUppercase(title), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp),
)
}
@@ -996,7 +996,6 @@ internal fun ModeHeader(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var searching by remember { mutableStateOf(query.isNotEmpty()) }
var focusOnOpen by remember { mutableStateOf(false) }
val open by animateFloatAsState(if (searching) 1f else 0f, Motion.sp(0.75f, 420f), label = "searchOpen")
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(vertical = 4.dp)) {
@@ -1011,11 +1010,11 @@ internal fun ModeHeader(
Box(Modifier.width(w).graphicsLayer { alpha = (open * 1.6f).coerceAtMost(1f) }) {
AdbTextField(
value = query, onValueChange = onQuery, label = "", keyboardType = KeyboardType.Text, imeAction = ImeAction.Search,
placeholder = placeholder, compact = true, focusOnShow = focusOnOpen,
onFocusChange = { focused -> if (!focused && query.isEmpty()) { searching = false; focusOnOpen = false } },
placeholder = placeholder, compact = true, focusOnShow = true,
onFocusChange = { focused -> if (!focused && query.isEmpty()) searching = false },
)
}
} else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true; focusOnOpen = true }
} else SearchIcon(stringResource(R.string.stores_search_open)) { searching = true }
}
trailing()
}
@@ -5,8 +5,6 @@ import android.os.Looper
import android.view.MotionEvent
import java.time.Duration
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -111,96 +109,6 @@ class TouchpadGesturesTest {
assertEquals(listOf("left down", "left up"), events)
}
@Test fun liftingTheSecondFingerAfterAScrollKeepsTheCursorStill() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(10)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f))
assertTrue(events.contains("wheel -8"))
advance(10)
touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 600f), 2 to (600f to 610f))
advance(10)
val sx = touchpad.x
val sy = touchpad.y
touch(MotionEvent.ACTION_MOVE, 0, 1 to (401f to 601f))
assertEquals(sx, touchpad.x, 0.01f)
assertEquals(sy, touchpad.y, 0.01f)
assertFalse(events.contains("move"))
}
@Test fun trackingResumesNormallyAfterTheScroll() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(10)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f))
advance(10)
touch(MotionEvent.ACTION_POINTER_UP, 1, 1 to (400f to 600f), 2 to (600f to 610f))
advance(10)
val sy = touchpad.y
touch(MotionEvent.ACTION_MOVE, 0, 1 to (401f to 621f))
val delta = touchpad.y - sy
assertTrue(delta in 20f..65f)
assertTrue(events.contains("move"))
}
@Test fun liftingTheAnchorFirstAfterAScrollKeepsTheCursorStill() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(10)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 310f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 600f), 2 to (600f to 610f))
advance(10)
touch(MotionEvent.ACTION_POINTER_UP, 0, 1 to (400f to 600f), 2 to (600f to 610f))
advance(10)
val sx = touchpad.x
val sy = touchpad.y
touch(MotionEvent.ACTION_MOVE, 0, 2 to (601f to 611f))
assertEquals(sx, touchpad.x, 0.01f)
assertEquals(sy, touchpad.y, 0.01f)
assertFalse(events.contains("move"))
}
@Test fun twoFingerScrollWithLeadingFingerDoesNotPressLeft() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(10)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 303f), 2 to (600f to 310f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 340f), 2 to (600f to 350f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 380f), 2 to (600f to 390f))
assertFalse(events.contains("left down"))
assertTrue(events.any { it.startsWith("wheel ") })
}
@Test fun holdDragStillWorks() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(10)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f), 2 to (620f to 300f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 300f), 2 to (640f to 300f))
assertEquals("left down", events.firstOrNull())
assertTrue(events.contains("move"))
touch(MotionEvent.ACTION_POINTER_UP, 0, 1 to (400f to 300f), 2 to (640f to 300f))
assertTrue(events.contains("left up"))
}
@Test fun anchorMovedLocksOutHoldDrag() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(10)
touch(MotionEvent.ACTION_POINTER_DOWN, 1, 1 to (400f to 300f), 2 to (600f to 300f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 309f), 2 to (600f to 302f))
advance(10)
touch(MotionEvent.ACTION_MOVE, 0, 1 to (400f to 309f), 2 to (640f to 302f))
assertFalse(events.contains("left down"))
}
private fun tap() {
touch(MotionEvent.ACTION_DOWN, 0, 1 to (400f to 300f))
advance(80)
@@ -1,60 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
class XboxAccountLifecycleTest {
private val app = ApplicationProvider.getApplicationContext<android.content.Context>()
@Test fun staleSignInCommitsAndBackendStartsCannotAcquireANewerAccount() {
val original = XboxCredentials.lifecycle(app) { it }
XboxCredentials.invalidate(app)
var committed = false
assertThrows(IllegalStateException::class.java) {
XboxCredentials.lifecycle(app, original) { committed = true }
}
assertFalse(committed)
assertThrows(IllegalStateException::class.java) { XboxCredentials(app, original) }
XboxCredentials.lifecycle(app) { current -> assertNotEquals(original, current) }
}
@Test fun accountCommitAndLaunchAuthorizationWaitUntilRevocationFinishes() {
val revoked = CountDownLatch(1)
val releaseStop = CountDownLatch(1)
val attempted = CountDownLatch(1)
val committed = AtomicBoolean(false)
val authorized = CountDownLatch(1)
val signOut = Thread {
XboxCredentials.invalidate(app) {
revoked.countDown()
check(releaseStop.await(5, TimeUnit.SECONDS))
}
}.apply { start() }
assertTrue(revoked.await(5, TimeUnit.SECONDS))
val signIn = Thread {
attempted.countDown()
XboxCredentials.lifecycle(app) {
committed.set(true)
authorized.countDown()
}
}.apply { start() }
try {
assertTrue(attempted.await(5, TimeUnit.SECONDS))
assertFalse(authorized.await(100, TimeUnit.MILLISECONDS))
assertFalse(committed.get())
} finally {
releaseStop.countDown()
signOut.join(5000)
signIn.join(5000)
}
assertTrue(committed.get())
assertFalse(signOut.isAlive)
assertFalse(signIn.isAlive)
}
}
@@ -1,23 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import org.junit.Assert.*
import org.junit.Test
class XboxBackendMappingTest {
private val path = "/runtime/xodus-cli"
private fun mapping(permissions: String = "r-xp", inode: Long = 42, name: String = path) =
"7a0000-7b0000 $permissions 00001000 00:31 $inode $name\n"
@Test fun prootLoaderDoesNotHideTheVerifiedBackendImage() {
val maps = mapping(name = "/android/libproot-loader.so", inode = 1) + mapping()
assertTrue(XboxCredentials.mapsBackend(maps, path, 42))
}
@Test fun loaderAloneOrAnotherGuestExecutableCannotAuthenticate() {
assertFalse(XboxCredentials.mapsBackend(mapping(name = "/android/libproot-loader.so"), path, 42))
assertFalse(XboxCredentials.mapsBackend(mapping(name = "/runtime/other-cli"), path, 42))
assertFalse(XboxCredentials.mapsBackend(mapping(inode = 43), path, 42))
assertFalse(XboxCredentials.mapsBackend(mapping(permissions = "r--p"), path, 42))
assertFalse(XboxCredentials.mapsBackend(mapping(name = "$path (deleted)"), path, 42))
assertFalse(XboxCredentials.mapsBackend("malformed\n", path, 42))
}
}
@@ -21,19 +21,10 @@ class XboxCatalogTest {
private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "[email protected]", "US", now)
@Test fun purchasedAndSubscriptionAccessRemainDistinct() {
assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan), row(plan)))
assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan)))
assertFalse(access(row(plan)).containsKey(plan))
}
@Test fun subscriptionRequiresAnActivePlanRow() {
assertTrue(access(row(game, plan)).isEmpty())
for ((key, value) in listOf("status" to "Expired", "isTrial" to true, "special" to true,
"sharingSource" to "Family", "endDate" to now.toString())) {
assertTrue(access(row(game, plan), row(plan).put(key, value)).isEmpty())
}
assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan)))
}
@Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() {
val checked = "2026-10-19T19:02:38.173609086+00:00"
val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00"))
@@ -1,91 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import org.junit.Assert.*
import org.junit.Test
import java.io.Closeable
class XboxLaunchLeasesTest {
private var time = 0L
private val leases = XboxLaunchLeases { time }
private var closed = 0
private fun launch(account: String = "account", session: String = "session"): String {
return leases.begin("game", account, session).also { leases.attach(it, Closeable { closed++ }) }
}
private fun reap(account: String? = "account", session: String? = "session", suspended: Boolean = false) {
leases.stale(account, session, suspended).forEach { it.close() }
}
@Test fun steamLaunchCanCreateASessionBoundLease() {
val token = leases.begin("game", "account", "session")
assertTrue(leases.active("game"))
assertFalse(leases.renew(token, "game", "account", "other"))
assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") }
}
@Test fun releaseIsIdempotentAndCannotDecrementANewLaunch() {
val old = launch()
leases.release(old)?.close()
leases.release(old)?.close()
assertEquals(1, closed)
assertFalse(leases.active("game"))
val current = launch()
leases.release(old)?.close()
assertTrue(leases.active("game"))
leases.release(current)?.close()
assertEquals(2, closed)
}
@Test fun renewalCannotCrossAccountSessionOrGameBoundaries() {
val token = launch()
assertFalse(leases.renew(token, "other", "account", "session"))
assertFalse(leases.renew(token, "game", "other", "session"))
assertFalse(leases.renew(token, "game", "account", "other"))
assertTrue(leases.renew(token, "game", "account", "session"))
time = 60_000
assertFalse(leases.renew(token, "game", "account", "session"))
reap()
assertEquals(1, closed)
assertFalse(leases.active("game"))
}
@Test fun abandonedPreparationAndSessionEndCleanUp() {
launch()
time = 180_000
reap()
assertEquals(1, closed)
launch()
reap(session = null)
assertEquals(2, closed)
}
@Test fun accountSwitchInvalidatesOldLaunchWithoutAffectingItsReplacement() {
val old = launch()
reap(account = "next")
launch("next")
leases.release(old)?.close()
assertEquals(1, closed)
assertTrue(leases.active("game"))
}
@Test fun stopRemovesLeaseAndRejectsLateAttachment() {
val token = leases.begin("game", "account", "session")
leases.clear().forEach { it.close() }
assertFalse(leases.attach(token, Closeable { closed++ }))
assertFalse(leases.active("game"))
val next = leases.begin("game", "account", "new-session")
assertTrue(leases.active("game"))
assertFalse(leases.attach(token, Closeable { closed++ }))
assertTrue(leases.attach(next, Closeable { closed++ }))
}
@Test fun suspensionKeepsLiveLeasesUntilResume() {
val token = launch()
time = 200_000
reap(suspended = true)
time += 20_000
assertTrue(leases.renew(token, "game", "account", "session"))
reap()
assertEquals(0, closed)
}
@Test fun activityQueriesDoNotWaitForBackendClose() {
val token = launch()
val detached = leases.release(token)
val gate = java.util.concurrent.CountDownLatch(1)
val worker = Thread { gate.await(); detached?.close() }.apply { start() }
try { assertFalse(leases.active("game")); launch("next") } finally { gate.countDown(); worker.join(1000) }
}
}
@@ -1,53 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import org.junit.Assert.*
import org.junit.Test
import java.nio.file.Files
class XboxLaunchViewsTest {
@Test fun cleanupRemovesLicensedCopiesWithoutFollowingGameLinks() {
val root = Files.createTempDirectory("xbox-view-test").toFile()
try {
val game = root.resolve("installed").apply { mkdirs() }
game.resolve("Game.exe").writeText("encrypted original")
val view = XboxLaunchViews().create(root)
view.resolve("Game.exe").writeText("licensed copy")
Files.createSymbolicLink(view.resolve("originals").toPath(), game.toPath())
XboxLaunchViews.remove(view)
XboxLaunchViews.remove(view)
assertFalse(view.exists())
assertEquals("encrypted original", game.resolve("Game.exe").readText())
} finally { root.deleteRecursively() }
}
@Test fun recoveryRejectsARedirectedStorageRoot() {
val root = Files.createTempDirectory("xbox-view-links").toFile()
try {
val outside = root.resolve("installed").apply { mkdirs() }
val game = outside.resolve("launch-game").apply { mkdirs() }.resolve("Game.exe")
game.writeText("preserve")
val runtime = root.resolve("xbox-runtime").apply { mkdirs() }
Files.createSymbolicLink(runtime.resolve("views").toPath(), outside.toPath())
assertThrows(IllegalStateException::class.java) { XboxLaunchViews().create(root) }
assertEquals("preserve", game.readText())
} finally { root.deleteRecursively() }
}
@Test fun recoveryRemovesAbandonedViewsAndPreservesNewLiveViews() {
val root = Files.createTempDirectory("xbox-view-recovery").toFile()
try {
val old = XboxLaunchViews().create(root)
old.resolve("Game.exe").writeText("abandoned copy")
val evidence = old.parentFile!!.resolve("diagnostics").apply { mkdirs() }.resolve("report")
evidence.writeText("preserve")
val fresh = XboxLaunchViews()
val first = fresh.create(root)
first.resolve("Game.exe").writeText("running copy")
val second = fresh.create(root)
assertFalse(old.exists())
assertEquals("running copy", first.resolve("Game.exe").readText())
assertTrue(second.isDirectory)
assertEquals("preserve", evidence.readText())
} finally { root.deleteRecursively() }
}
}
@@ -1,21 +0,0 @@
package com.droiddeck.launcher.stores.xbox
import org.junit.Assert.*
import org.junit.Test
import java.io.File
class XboxServiceSocketTest {
@Test fun serviceSocketsFitUnixAddressesForEveryPackageAndRemainDistinct() {
val variants = generateSequence(File(requireNotNull(System.getProperty("user.dir")))) { it.parentFile }
.map { File(it, "tools/release/variants.txt") }.first { it.isFile }
val packages = variants.readLines().filter { it.isNotBlank() && !it.startsWith('#') }
.map { it.trim().split(Regex("\\s+"))[1] }
for (pkg in packages) for (user in listOf(0, 99999)) {
val files = File("/data/user/$user/$pkg/files")
val first = XboxRuntime.newServiceSocket(files)
val second = XboxRuntime.newServiceSocket(files)
assertTrue(first.path, first.path.toByteArray(Charsets.UTF_8).size < 108)
assertNotEquals(first, second)
}
}
}
@@ -1,44 +0,0 @@
package com.droiddeck.launcher.ui
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.assertCountEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class HeadingLocaleTest {
@get:Rule val compose = createRobolectricComposeRule()
@Test fun headingsFollowTheAppLanguageAndRecomposeWhenItChanges() {
val appLocale = mutableStateOf(Locale.forLanguageTag("tr"))
compose.setContent {
val configuration = Configuration(LocalConfiguration.current).apply {
setLocales(LocaleList(appLocale.value))
}
CompositionLocalProvider(LocalConfiguration provides configuration) {
DroidDeckTheme {
Column {
Eyebrow("Bileşenler Iİıi")
SectionTitle("Bileşenler Iİıi", null)
}
}
}
}
compose.onAllNodesWithText("BİLEŞENLER IİIİ").assertCountEquals(2)
compose.runOnIdle { appLocale.value = Locale.ENGLISH }
compose.onAllNodesWithText("BILEŞENLER IİII").assertCountEquals(2)
compose.runOnIdle { appLocale.value = Locale.forLanguageTag("tr") }
compose.onAllNodesWithText("BİLEŞENLER IİIİ").assertCountEquals(2)
}
}
@@ -1,97 +0,0 @@
package com.droiddeck.launcher.ui
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import com.droiddeck.launcher.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
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 = [28], qualifiers = "w960dp-h540dp-land", application = android.app.Application::class)
class ModeHeaderFocusTest {
@get:Rule val compose = createRobolectricComposeRule()
@Test fun reenteringCompositionWithNonEmptyQueryDoesNotStealFocus() {
lateinit var root: View
var show by mutableStateOf(true)
var query by mutableStateOf("celeste")
compose.setContent {
DroidDeckTheme {
val view = LocalView.current
SideEffect { root = view.rootView }
if (show) {
ModeHeader(
title = "Library",
query = query,
onQuery = { query = it },
placeholder = "Search",
trailing = {},
)
}
}
}
compose.waitForIdle()
val initialField = editText(root)!!
compose.runOnIdle {
assertEquals("celeste", initialField.text.toString())
assertFalse("Pre-populated search field must not auto-focus on mount", initialField.isFocused)
show = false
}
compose.waitForIdle()
compose.runOnIdle { show = true }
compose.waitForIdle()
val remountedField = editText(root)!!
compose.runOnIdle {
assertEquals("celeste", remountedField.text.toString())
assertFalse("Remounted search field must not steal focus when scrolled back into view", remountedField.isFocused)
}
}
@Test fun clickingSearchIconFocusesField() {
lateinit var root: View
var query by mutableStateOf("")
compose.setContent {
DroidDeckTheme {
val view = LocalView.current
SideEffect { root = view.rootView }
ModeHeader(
title = "Library",
query = query,
onQuery = { query = it },
placeholder = "Search",
trailing = {},
)
}
}
val context = RuntimeEnvironment.getApplication()
compose.onNodeWithContentDescription(context.getString(R.string.stores_search_open)).performClick()
compose.waitForIdle()
val field = editText(root)!!
compose.runOnIdle {
assertTrue("Opening search via icon must focus the text field", field.isFocused)
}
}
private fun editText(view: View): EditText? {
if (view is EditText) return view
if (view is ViewGroup) for (i in 0 until view.childCount) {
editText(view.getChildAt(i))?.let { return it }
}
return null
}
}
+3 -16
View File
@@ -1,6 +1,6 @@
# Xbox / PC Game Pass on Thor
This integrates PC Microsoft Store MSIXVC packages,
Branch: `feat/xbox-store`. This integrates PC Microsoft Store MSIXVC packages,
not Xbox console emulation or UWP/APPX packages.
## Testing on Thor
@@ -32,13 +32,7 @@ not Xbox console emulation or UWP/APPX packages.
of showing the "Steam picks the game up when it next starts" toast.
A launch taking over 20 seconds should not produce a duplicate Steam launch
or a "Game already running" dialog while licensing is still preparing it.
Steam/session startup uses the selected ARM64 Proton. Launch the installed
Xbox game from either DroidDeck or Steam; both paths should prepare the same
account-bound game service and licensed view while keeping account credentials
inside the Android-owned backend.
During preparation, opening the Xbox detail/content UI must remain responsive.
Update / verify must report a problem if the game starts after its button was
enabled, rather than crashing the app.
Steam/session startup uses the selected ARM64 Proton.
First launch prepares that game's prefix. Check that a window appears, the
game reaches its menu, controller buttons/axes work, and audio is audible.
Record the selected Proton, game/version, and any startup failure.
@@ -54,14 +48,7 @@ not Xbox console emulation or UWP/APPX packages.
it, then relaunch and check that the game recognizes it. An account with no
such content should get an empty-content message. Base-game uninstall also
removes its managed content.
9. Stop a game and launch it again. A delayed release from the first launch must
not clear the second launch's activity. Suspend/resume while playing; the
launch lease should survive suspension. Stop the session during preparation
and verify both backend processes exit and the host-owned launch directory
under `files/xbox-runtime/views` is removed. A fresh app process recovers
abandoned `launch-*` directories there before preparing another game;
cleanup must preserve installed game files reached through view symlinks.
10. Sign out from Xbox. Installed files should remain, account library data should
9. Sign out from Xbox. Installed files should remain, account library data should
clear, and Play/download licensing should require sign-in again.
Do not uninstall DroidDeck or clear application data to troubleshoot this build.
@@ -1232,7 +1232,6 @@ class Session:
seed_settings()
install_shims()
install_preload(files)
fex_module()["install_fonts"](files)
clear_playing()
sync_installations()
forget_sign_in()
@@ -34,7 +34,7 @@ RUNTIMES = {SNIPER_APPID: ("sniper_platform_", "Steam Linux Runtime 3.0 (sniper"
SLR4_APPID: ("steamrt4_platform_", "Steam Linux Runtime 4.0 (steamrt4")}
MARKER = ".droiddeck-fex"
LAYOUT = 1
ROOTFS_LAYOUT = 2
ROOTFS_LAYOUT = 1
ARCH_DIRS = ("x86_64-linux-gnu", "i386-linux-gnu")
INTERPRETERS = ("ld-linux-x86-64.so.2", "ld-linux.so.2")
PROVIDED = frozenset((
@@ -42,7 +42,6 @@ PROVIDED = frozenset((
"librt.so.1", "libutil.so.1", "libresolv.so.2", "libanl.so.1", "libBrokenLocale.so.1", "libnsl.so.1",
"libnss_compat.so.2", "libnss_dns.so.2", "libnss_files.so.2", "libnss_hesiod.so.2", "libthread_db.so.1",
"libmemusage.so", "libpcprofile.so", "libc_malloc_debug.so.0", "gconv", "libstdc++.so.6", "libgcc_s.so.1",
"libssl.so.3", "libcrypto.so.3",
))
GLIBC_BUILD = re.compile(r"^(?:ld|lib[A-Za-z_]+)-2\.[0-9]+\.so$|^libSegFault\.so$")
GUEST_LIBDIRS = (("x86_64", "lib/x86_64-linux-gnu"), ("i386", "lib/i386-linux-gnu"))
@@ -62,8 +61,6 @@ SCAN_LIMIT = 4000
MODES = ("auto", "on", "off")
STEAM_VERBS = ("run", "waitforexitandrun")
ESCAPE = re.compile(rb"\\([0-7]{3})")
DEVICE_FONTS = "/usr/local/share/fonts/android"
FONT_RULES = "/etc/fonts/conf.d/66-droiddeck-device-fonts.conf"
def say(message):
@@ -355,16 +352,6 @@ def write_config(fex, root):
write_if_changed(os.path.join(CONFIG_DIR, "Config.json"), json.dumps(config, indent=2) + "\n")
def install_fonts(root):
target = os.path.join(root, FONT_RULES.lstrip("/"))
rules = read_text(FONT_RULES) or ""
if not os.path.isdir(DEVICE_FONTS) or "<fontconfig>" not in rules:
with contextlib.suppress(FileNotFoundError):
os.unlink(target)
return
write_if_changed(target, rules.replace("<fontconfig>", "<fontconfig>\n<dir>%s</dir>" % DEVICE_FONTS, 1))
def link_steam_sdk():
for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))):
link = os.path.join(HOME, ".steam", sdk)
@@ -467,7 +454,6 @@ def prepare(app=SNIPER_APPID):
if materialize(platform):
say("prepared %s as the x86 runtime" % os.path.basename(platform))
install_preloads(root)
install_fonts(root)
provider = None
if app == SNIPER_APPID:
provider = graphics_platform()
@@ -316,11 +316,7 @@ def main():
if mic_enabled == "0":
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
env = force_ssbs(command, env)
try:
xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox")))
except Exception as error:
print("droiddeck-game-env: Xbox support unavailable (%s); stock launch" % error, file=sys.stderr)
xbox = {"launch": lambda command, env: None}
xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox")))
try:
xbox_result = xbox["launch"](command, env)
except (OSError, ValueError, KeyError, RuntimeError) as error:
@@ -102,16 +102,15 @@ def bridge(upstream):
return server.getsockname()[1], key.decode('ascii'), close
def wine(argv, source_env=None):
source_env = os.environ if source_env is None else source_env
with open(source_env['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file:
def wine(argv):
with open(os.environ['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file:
command = json.load(file)
if len(command) < 3 or command[1] != 'waitforexitandrun' or len(argv) != 1:
raise ValueError('Invalid Xbox Proton invocation')
env = dict(source_env)
env = dict(os.environ)
env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None)
env.pop('DROIDDECK_XBOX_COMMAND_FILE', None)
prefix = Path(source_env['STEAM_COMPAT_DATA_PATH']) / 'pfx'
prefix = Path(os.environ['STEAM_COMPAT_DATA_PATH']) / 'pfx'
prefix.parent.mkdir(parents=True, exist_ok=True)
if not (prefix / 'system.reg').is_file():
result = subprocess.run([command[0], 'run', 'cmd', '/c', 'exit', '0'], stdout=sys.stderr, env=env)
@@ -129,21 +128,10 @@ def wine(argv, source_env=None):
return subprocess.call([*command[:2], argv[0], *command[3:]], env=env)
def renew_lease(ask, channel, product, lease, stopped):
while not stopped.wait(15):
response = ask(channel, {'op': 'xbox-renew', 'id': product, 'lease': lease}, timeout=10)
if response is not None and response.get('ok') is False:
return
def launch(command, env):
if len(command) < 3 or command[1] != 'waitforexitandrun' or env.get('DROIDDECK_XBOX_ACTIVE') == '1':
return None
try:
store = runpy.run_path(str(BIN / 'droiddeck-store-launch'))
except Exception as error:
print('droiddeck-xbox: store detection unavailable (%s); stock launch' % error, file=sys.stderr)
return None
store = runpy.run_path(str(BIN / 'droiddeck-store-launch'))
found = store['find_store_game'](command[2:])
if found is None or found[1].get('store') != 'xbox':
return None
@@ -154,26 +142,19 @@ def launch(command, env):
launch_dir = env.get('BL_LAUNCH_DIR')
if not launch_dir:
raise RuntimeError('No Xbox account channel; start the game from DroidDeck')
reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=190)
reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=95)
if not reply or not reply.get('ok'):
raise RuntimeError('Xbox account service is unavailable; sign in again and retry')
stopped = threading.Event()
close = lambda: None
port, key, close = bridge(reply['socket'])
try:
port, key, close = bridge(reply['socket'])
threading.Thread(target=renew_lease, args=(store['ask'], Path(launch_dir) / 'stores',
sidecar['id'], reply['lease'], stopped), daemon=True).start()
with tempfile.TemporaryDirectory(prefix='droiddeck-xbox-') as temporary:
context = Path(temporary) / 'command.json'
context.write_text(json.dumps(command), encoding='utf-8')
context.chmod(0o600)
owned_env = dict(env, DROIDDECK_XBOX_ACTIVE='1', DROIDDECK_XBOX_COMMAND_FILE=str(context),
DROIDDECK_XBOX_CREDENTIAL_SOCKET=reply['credentials'],
XODUS_SOCKET=reply['socket'], XODUS_BRIDGE_PORT=str(port), XODUS_BRIDGE_KEY=key,
XODUS_LOG='off', RUST_LOG='off', XODUS_SAVE_SYNC='auto' if sidecar.get('cloud', True) else 'off')
owned_env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None)
if reply.get('playfab'):
owned_env['XODUS_XSTS_PLAYFAB'] = reply['playfab']
content = folder / '.xbox-content'
roots = []
if content.is_dir():
@@ -186,11 +167,12 @@ def launch(command, env):
pass
if roots:
owned_env['XODUS_CONTENT_ROOTS'] = ';'.join(roots)
return wine([reply['exe']], owned_env)
market = sidecar.get('extra', {}).get('market', 'US')
return subprocess.call([str(RUNTIME / 'xodus-cli'), 'run', str(folder), str(BIN / 'droiddeck-xbox'),
'--exe', sidecar['exe'], '--materialize-chain', '--market', market], env=owned_env)
finally:
stopped.set()
close()
store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'lease': reply['lease']})
store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'id': sidecar['id']})
if __name__ == '__main__':
-24
View File
@@ -45,8 +45,6 @@ class FexTestCase(unittest.TestCase):
"WANTED": str(self.home / ".config/droiddeck/fex-wanted"),
"PRELOADS": str(self.tmp / "preloads"),
"HOST_PRELOAD": str(self.tmp / "ld.so.preload"),
"DEVICE_FONTS": str(self.tmp / "android-fonts"),
"FONT_RULES": str(self.tmp / "66-droiddeck-device-fonts.conf"),
}
patcher = mock.patch.dict(G, patches)
patcher.start()
@@ -129,9 +127,6 @@ class FexTestCase(unittest.TestCase):
self.write(provided / "gconv/NEW.so", b"new gconv")
self.write(platform / "files/lib/i386-linux-gnu/libc.so.6", b"GLIBC_2.41")
self.write(platform / "files/lib/i386-linux-gnu/ld-linux.so.2", b"new ld32")
for arch in ("x86_64", "i386"):
for name in ("libssl.so.3", "libcrypto.so.3"):
self.write(platform / "files/lib" / (arch + "-linux-gnu") / name, arch.encode() + name.encode())
with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out:
out.write("#mtree\n. type=dir\n")
self.write(self.steam / "steamapps/appmanifest_4183110.acf", '"AppState" { "installdir" "SteamLinuxRuntime_4" }')
@@ -266,21 +261,6 @@ class PrepareTest(FexTestCase):
self.assertEqual((root / "lib/i386-linux-gnu/droiddeck/libfaultreport.so").read_bytes(), b"report32")
self.assertEqual((root / "etc/ld.so.preload").read_text(), "/usr/$LIB/droiddeck/libfaultreport.so\n")
def test_device_fonts_reach_the_x86_runtime(self):
root = self.tmp / "root"
conf = root / str(self.tmp / "66-droiddeck-device-fonts.conf").lstrip("/")
rules = '<?xml version="1.0"?><fontconfig>\n<match target="pattern"></match>\n</fontconfig>\n'
self.write(self.tmp / "66-droiddeck-device-fonts.conf", rules)
FEX["install_fonts"](str(root))
self.assertFalse(conf.exists())
(self.tmp / "android-fonts").mkdir()
FEX["install_fonts"](str(root))
self.assertEqual(conf.read_text(), '<?xml version="1.0"?><fontconfig>\n<dir>%s</dir>\n<match target="pattern"></match>\n</fontconfig>\n'
% (self.tmp / "android-fonts"))
(self.tmp / "66-droiddeck-device-fonts.conf").unlink()
FEX["install_fonts"](str(root))
self.assertFalse(conf.exists())
def test_config_turns_thunks_on_only_when_complete(self):
FEX["write_config"]({"host_thunks": "/h", "guest_thunks": "/g", "thunks_db": "/db"}, "/root")
config = json.loads((self.home / ".local/share/droiddeck-fex/config/Config.json").read_text())
@@ -353,10 +333,6 @@ class PrepareTest(FexTestCase):
self.assertEqual((x86 / "ld-linux-x86-64.so.2").read_bytes(), b"new ld")
self.assertEqual((x86 / "gconv/NEW.so").read_bytes(), b"new gconv")
self.assertEqual((root / "lib/i386-linux-gnu/libc.so.6").read_bytes(), b"GLIBC_2.41")
self.assertEqual((x86 / "libssl.so.3").read_bytes(), b"x86_64libssl.so.3")
self.assertEqual((x86 / "libcrypto.so.3").read_bytes(), b"x86_64libcrypto.so.3")
self.assertEqual((root / "lib/i386-linux-gnu/libssl.so.3").read_bytes(), b"i386libssl.so.3")
self.assertEqual((root / "lib/i386-linux-gnu/libcrypto.so.3").read_bytes(), b"i386libcrypto.so.3")
self.assertEqual((x86 / "libSDL2-2.0.so.0").read_bytes(), b"sdl")
self.assertEqual(os.readlink(x86 / "libfoo.so.1"), "libfoo.so.1.2")
self.assertEqual((x86 / "libfoo.so.1").read_bytes(), b"foo")
-11
View File
@@ -43,17 +43,6 @@ class ReadConfigMappingTest(unittest.TestCase):
class GameEnvironmentTest(unittest.TestCase):
def test_optional_xbox_loader_failure_keeps_stock_launch(self):
from unittest.mock import patch
for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')):
calls = []
with patch.dict(os.environ, {}, clear=True), patch('sys.argv', ['droiddeck-game-env', '/stock', 'run']), \
patch('runpy.run_path', side_effect=error), patch('os.execvpe', side_effect=lambda *args: calls.append(args)), \
patch.dict(MODULE['main'].__globals__, load_sync=lambda: None, select_sync=lambda command, env, sync: (command, env)):
MODULE['main']()
self.assertEqual(calls[0][0:2], ('/stock', ['/stock', 'run']))
def test_directaudio_capture_follows_the_session_microphone_setting(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
-48
View File
@@ -1,48 +0,0 @@
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
class XboxBuildSourceTest(unittest.TestCase):
def test_main_advancing_between_jobs_does_not_change_packaged_source(self):
try:
import yaml
except ImportError:
self.fail('PyYAML is required to execute workflow steps')
workflow = yaml.safe_load((Path(__file__).resolve().parents[2] / '.github/workflows/build.yml').read_text())
jobs = workflow['jobs']
step = lambda job, name: next(s['run'] for s in jobs[job]['steps'] if s.get('name') == name)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
env = dict(os.environ, GIT_AUTHOR_NAME='test', GIT_COMMITTER_NAME='test',
GIT_AUTHOR_EMAIL='[email protected]', GIT_COMMITTER_EMAIL='[email protected]',
GITHUB_STEP_SUMMARY=str(root / 'summary'))
def git(*args, cwd=root):
return subprocess.check_output(['git', *args], cwd=cwd, env=env, stderr=subprocess.DEVNULL, text=True).strip()
git('init', '-q', '-b', 'main')
(root / 'backend').write_text('base')
git('add', '.'); git('commit', '-qm', 'base')
git('switch', '-qc', 'feature')
(root / 'app').write_text('feature')
git('add', '.'); git('commit', '-qm', 'feature')
head = git('rev-parse', 'HEAD')
git('switch', '-q', 'main')
(root / 'backend').write_text('merged backend')
git('commit', '-qam', 'backend changed after branch')
for name in ('xbox', 'apk'):
git('clone', '-q', str(root), str(root / name))
git('checkout', '-q', head, cwd=root / name)
output = root / 'output'
env['GITHUB_OUTPUT'] = str(output)
subprocess.run(['bash', '-c', step('xbox', 'Pin and merge the APK source')], cwd=root / 'xbox', env=env, check=True, capture_output=True)
base = output.read_text().strip().split('=', 1)[1]
tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'xbox')
(root / 'backend').write_text('newer main, after backend build')
git('commit', '-qam', 'main advanced again')
env.update(BUILD_BASE_SHA=base, XBOX_SOURCE_TREE=tree)
subprocess.run(['bash', '-c', step('build', 'Merge onto main, or say what conflicts')], cwd=root / 'apk', env=env, check=True, capture_output=True)
subprocess.run(['bash', '-c', step('build', 'Verify backend and APK source match')], cwd=root / 'apk', env=env, check=True, capture_output=True)
self.assertEqual((root / 'apk/backend').read_text(), 'merged backend')
self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'apk'), tree)
-58
View File
@@ -19,64 +19,6 @@ class XboxLaunchTest(unittest.TestCase):
(folder / '.droiddeck-store.json').write_text(json.dumps({'store': 'gog', 'id': '1'}))
self.assertIsNone(xbox['launch'](['/proton', 'waitforexitandrun', str(folder / 'Game.exe')], {}))
def test_renewal_retries_a_timeout_and_stops_after_rejection(self):
stopped = unittest.mock.Mock()
stopped.wait.side_effect = [False, False, False, AssertionError('renewed a rejected lease')]
ask = unittest.mock.Mock(side_effect=[None, {'ok': True}, {'ok': False}])
xbox['renew_lease'](ask, Path('/channel'), 'GAME', 'lease-1', stopped)
self.assertEqual(ask.call_count, 3)
def test_stopping_a_launch_stops_renewal_without_another_request(self):
stopped = threading.Event()
stopped.set()
ask = unittest.mock.Mock()
xbox['renew_lease'](ask, Path('/channel'), 'GAME', 'lease-1', stopped)
ask.assert_not_called()
def test_missing_store_detector_keeps_stock_launch(self):
for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')):
with self.subTest(error=error), patch('runpy.run_path', side_effect=error):
self.assertIsNone(xbox['launch'](['/proton', 'waitforexitandrun', '/Game.exe'], {}))
def test_launch_uses_prepared_image_and_never_receives_account_credentials(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root)
runtime = root / 'runtime/windows'
runtime.mkdir(parents=True)
for name in xbox['DLLS']:
(runtime / name).write_bytes(b'MZ')
store = {'find_store_game': lambda argv: (root, {'store': 'xbox', 'id': 'GAME'}),
'ask': unittest.mock.Mock(return_value={'ok': True, 'socket': '/service', 'lease': 'lease-1',
'exe': '/private/Game.exe', 'playfab': 'game-token'})}
close = unittest.mock.Mock()
with patch('runpy.run_path', return_value=store), patch.dict(xbox['launch'].__globals__, RUNTIME=runtime.parent), \
patch.dict(xbox['launch'].__globals__, bridge=lambda path: (1234, 'key', close)), \
patch.dict(xbox['launch'].__globals__, wine=unittest.mock.Mock(return_value=7)):
launch = xbox['launch']
self.assertEqual(launch(['/proton', 'waitforexitandrun', '/encrypted/Game.exe'],
{'BL_LAUNCH_DIR': '/launch', 'DROIDDECK_XBOX_CREDENTIAL_SOCKET': 'stale'}), 7)
wine = launch.__globals__['wine']
self.assertEqual(wine.call_args.args[0], ['/private/Game.exe'])
env = wine.call_args.args[1]
self.assertNotIn('DROIDDECK_XBOX_CREDENTIAL_SOCKET', env)
self.assertEqual(env['XODUS_XSTS_PLAYFAB'], 'game-token')
close.assert_called_once()
self.assertEqual(store['ask'].call_args.args[1], {'op': 'xbox-released', 'lease': 'lease-1'})
def test_bridge_start_failure_still_releases_the_lease(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root)
runtime = root / 'windows'
runtime.mkdir()
for name in xbox['DLLS']:
(runtime / name).touch()
ask = unittest.mock.Mock(return_value={'ok': True, 'socket': '/service', 'lease': 'lease-1'})
with patch('runpy.run_path', return_value={'find_store_game': lambda argv: (root, {'store': 'xbox', 'id': 'GAME'}), 'ask': ask}), \
patch.dict(xbox['launch'].__globals__, RUNTIME=root, bridge=unittest.mock.Mock(side_effect=OSError('bind failed'))):
with self.assertRaises(OSError):
xbox['launch'](['/proton', 'waitforexitandrun', '/Game.exe'], {'BL_LAUNCH_DIR': '/launch'})
self.assertEqual(ask.call_args.args[1], {'op': 'xbox-released', 'lease': 'lease-1'})
def test_bridge_authenticates_before_forwarding(self):
with tempfile.TemporaryDirectory() as root:
path = str(Path(root) / 'service.sock')
+3 -20
View File
@@ -24,33 +24,16 @@ in the build cache and publishes both in CI's `xbox-corresponding-source`
artifact; distribute corresponding sources with published binaries.
`droiddeck-credentials.patch` replaces Linux Secret Service with a bounded,
Android abstract socket restricted to the backend process started by the host. The Android host encrypts every persistent
UID-checked Android abstract socket. The Android host encrypts every persistent
write using its existing Keystore cipher, verifies backend read-backs, and
rejects writes when Keystore is unavailable. No token values enter process
arguments, external storage, application logs or a plaintext credential file.
Account-generation invalidation prevents a signed-out child's later writes from
restoring credentials. Each backend gets its own socket. The host checks the peer UID, the PRoot tracer
owned by its live child, and an executable mapping of the backend file with a matching inode and
SHA-256 against the APK asset. PRoot's loader can occupy `/proc/<pid>/exe`, so
authentication checks the mapped backend image instead. A socket name alone grants no credential operations, including to another
copy of Xodus started by the guest.
Licensed launch-view preparation runs in an Android-owned Xodus child, which
never starts Wine or a game. Steam and DroidDeck launch paths can request
preparation for an installed Xbox title during a live session with an Xbox
account. The reply contains a random launch lease, a materialized executable
path, a game-service socket and the existing PlayFab game token; it contains no
account credential endpoint.
Leases bind product, account and session, expire without heartbeats, and release
idempotently. Cancellation, session stop and account changes revoke leases;
closing the child's stdin lets it remove its private view. Suspension preserves
live leases.
restoring credentials. Each process gets its own unguessable socket name.
The Proton initialization and game processes do not inherit the raw credential
channel or command-file environment variables. The authenticated GDK bridge
remains available to the game. Like the other stores, games share DroidDeck's
Linux environment and Android UID. The process checks close the direct
credential-socket route; they do not isolate writable runtime files, prevent
same-UID process tampering, or provide a sandbox against malicious games.
Linux environment and Android UID; this is not a sandbox against malicious games.
Microsoft sign-in uses Nexus's private stdio protocol in an isolated Android
WebView process. Its page bridge is restricted to the allowed Microsoft/Xbox
-20
View File
@@ -84,23 +84,3 @@
}
#[test]
--- a/crates/xodus-cli/src/commands/run.rs
+++ b/crates/xodus-cli/src/commands/run.rs
@@ -618 +618,17 @@
+ if wine == "droiddeck:prepare" {
+ use std::io::Write;
+ if !materialize_chain { return ExitCode::FAILURE; }
+ let ready = serde_json::json!({
+ "exe": stub_path,
+ "playfab": playfab_auth_header(client, tokens).await,
+ });
+ println!("DROIDDECK_LAUNCH_V1 {}", ready);
+ let _ = std::io::stdout().flush();
+ let mut release = String::new();
+ let _ = std::io::stdin().read_line(&mut release);
+ drop(launch_mirror);
+ cleanup().await;
+ return ExitCode::SUCCESS;
+ }
+
let mut command = Command::new(wine);