Restrict Xbox credential IPC and address launch review findings (#1164)

* Restrict Xbox credentials to host backends and lease game launches

* Verify mapped PRoot backend images and retry lease heartbeat timeouts

* Bound Xbox backend shutdown and isolate service socket lifetimes

* Keep unique Xbox service sockets within Unix address limits

* Own licensed launch directories and recover abandoned views safely

* Serialize Xbox account revocation with sign-in commits and launch authorization
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-19 20:10:54 -04:00
1 parent f5bba17f53
commit 6bbcf3ad8b
27 files changed
+834 -85

No files matched your search

+34 -2
View File
@@ -34,10 +34,28 @@ 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 "ci@users.noreply.github.com"
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:
@@ -80,6 +98,7 @@ 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'
@@ -125,14 +144,17 @@ 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 "ci@users.noreply.github.com"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -179,6 +201,11 @@ 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
@@ -553,6 +580,11 @@ 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'
@@ -83,6 +83,10 @@ object StoreLaunch {
}
fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) {
if (store == Store.XBOX && !com.droiddeck.launcher.stores.xbox.XboxRuntime.authorize(context, id)) {
StoresState.problems[Store.XBOX] = "Xbox account is unavailable. Sign in again and retry."
return
}
if (store != Store.EPIC) { onReady(); return }
val app = context.applicationContext
Thread({
@@ -37,7 +37,7 @@ object StoreLaunchRequests {
}
fun stop() {
Thread({ com.droiddeck.launcher.stores.xbox.XboxRuntime.stop() }, "xbox-service-stop").start()
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -52,9 +52,10 @@ object StoreLaunchRequests {
}
val reply = when (request.optString("op")) {
"xbox-released" -> {
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("id"))
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease"))
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,10 +168,12 @@ object StoresState {
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
background("stores-signout") {
backends[store]?.signOut(app)
val backend = backends[store]
backend?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else StoreAccounts.clear(app, store)
else if (store != Store.XBOX || backend == null) 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,12 +19,15 @@ object XboxBackend : StoreBackend {
context.startActivity(Intent(context, XboxLoginActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@Synchronized override fun signOut(context: Context) {
generation++
XboxCredentials.invalidate(context)
XboxLibraryCache.clear(context)
XboxRuntime.stop()
access = emptyMap()
override fun signOut(context: Context) {
XboxCredentials.invalidate(context) {
synchronized(this) {
generation++
access = emptyMap()
}
XboxLibraryCache.clear(context)
XboxRuntime.stop()
}
StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
}
@@ -28,15 +28,16 @@ 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 (row.getString("status") != "Active" || row.getBoolean("isTrial") || row.getBoolean("special") ||
row.getString("sharingSource") != "None" || !timestamp(row.getString("endDate")).isAfter(now)) return null
if (!active(row)) 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 "gamepass"
if (parent in plans) return if (rows[parent]?.let(::active) == true) "gamepass" else null
return kind(parent, visited + id)
}
return rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
@@ -10,11 +10,68 @@ import java.io.Closeable
import java.io.InputStream
import java.util.UUID
internal class XboxCredentials(private val app: Context) : Closeable {
internal class XboxCredentials(private val app: Context, expectedGeneration: String? = null) : Closeable {
val name = "droiddeck-xbox-${UUID.randomUUID()}"
private val generation = storage(app) { epoch(app) }
private val generation = lifecycle(app, expectedGeneration) { it }
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({
@@ -23,12 +80,17 @@ internal class XboxCredentials(private val app: Context) : Closeable {
socket.use {
it.soTimeout = 10_000
val reply = runCatching {
check(it.peerCredentials.uid == android.os.Process.myUid())
val peer = it.peerCredentials
check(peer.uid == android.os.Process.myUid() && authorized(peer.pid))
val request = JSONObject(readLine(it.inputStream.buffered(), 2_097_152))
storage(app) {
check(epoch(app) == generation)
answer(JSONObject(readLine(it.inputStream.buffered(), 2_097_152)))
check(!closed && epoch(app) == generation && authorized(peer.pid))
answer(request)
}
}.getOrElse { JSONObject().put("ok", false) }
}.getOrElse {
android.util.Log.w("XboxCredentials", "Credential request rejected: ${it.javaClass.simpleName}")
JSONObject().put("ok", false)
}
runCatching { it.outputStream.write((reply.toString() + "\n").toByteArray()) }
}
}
@@ -58,14 +120,25 @@ internal class XboxCredentials(private val app: Context) : Closeable {
return result
}
override fun close() { closed = true; runCatching { server.close() } }
override fun close() { closed = true; bound.countDown(); 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 invalidate(app: Context) = storage(app) {
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) {
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 ->
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.stores.xbox
import java.io.Closeable
import java.util.UUID
internal class XboxLaunchLeases(private val now: () -> Long) {
private data class Permit(val account: String, val expires: Long)
private data class Lease(val id: String, val account: String, val session: String, var expires: Long, var child: Closeable? = null)
private val permits = HashMap<String, Permit>()
private val leases = HashMap<String, Lease>()
@Synchronized fun authorize(id: String, account: String) { permits[id] = Permit(account, now() + 180_000) }
@Synchronized fun active(id: String) = leases.values.any { it.id == id }
@Synchronized fun begin(id: String, account: String, session: String): String {
val permit = permits.remove(id)
check(permit != null && permit.account == account && permit.expires > now()) { "Start this Xbox game from DroidDeck." }
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> {
permits.clear()
return leases.values.mapNotNull { it.child }.also { leases.clear() }
}
}
@@ -0,0 +1,40 @@
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,7 +48,8 @@ class XboxLoginActivity : ComponentActivity() {
Thread({
if (closed) return@Thread
try {
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login")).use { process ->
val accountGeneration = XboxCredentials.lifecycle(applicationContext) { it }
Xodus.start(applicationContext, listOf("--external-webview-stdio", "login"), credentialGeneration = accountGeneration).use { process ->
child = process
if (closed) return@use
Xodus.diagnostics(process)
@@ -99,9 +100,9 @@ class XboxLoginActivity : ComponentActivity() {
}
child = null
if (closed) return@Thread
val account = Xodus.run(applicationContext, listOf("account", "--json"))
val account = Xodus.run(applicationContext, listOf("account", "--json"), credentialGeneration = accountGeneration)
check(account.optBoolean("stored"))
XboxCredentials.storage(this) {
XboxCredentials.lifecycle(this, accountGeneration) {
val record = StoreAccounts.read(this, Store.XBOX) ?: error("Xbox credentials missing")
record.put("username", account.getString("username"))
StoreAccounts.write(this, Store.XBOX, record)
@@ -7,40 +7,117 @@ 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 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 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 authorize(app: Context, id: String): Boolean = runCatching {
XboxCredentials.lifecycle(app) {
val account = StoreAccounts.signedInAs(app, Store.XBOX) ?: return@lifecycle false
launches.authorize(id, account)
true
}
}.getOrDefault(false)
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 })
@Synchronized fun prepare(app: Context, id: String): JSONObject {
private data class Authorization(val account: String, val token: String, val runtime: Long, val credentials: String)
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" }
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)
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.")
}
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) }
}
activeGames[id] = activeGames.getOrDefault(id, 0) + 1
return JSONObject().put("ok", true).put("socket", socket.path).put("credentials", child!!.credentialSocket)
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 }
}
@Synchronized fun stop() {
val process = child; child = null; owner = null; activeGames.clear()
runCatching { process?.close() }
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()
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
}
}
socket
}
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()
}
}
@@ -13,16 +13,32 @@ 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) : Closeable {
val credentialSocket: String get() = credentials.name
class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable {
@Synchronized fun send(frame: JSONObject) {
process.outputStream.write((frame.toString() + "\n").toByteArray())
process.outputStream.flush()
}
override fun close() {
process.destroy()
if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
credentials.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)
}
}
}
@@ -45,19 +61,20 @@ internal object Xodus {
File(root, "version").writeText(packaged)
}
fun start(app: Context, args: List<String>, service: Boolean = false): Child {
fun start(app: Context, args: List<String>, service: Boolean = false, runtimeDirectory: File? = null, launchDirectory: File? = null, credentialGeneration: String? = null): 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)
val credentials = XboxCredentials(app, credentialGeneration)
try {
val runtime = File(app.filesDir, "xbox-runtime").apply { mkdirs() }
val runtime = (runtimeDirectory ?: 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)
@@ -65,12 +82,19 @@ internal object Xodus {
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
return Child(builder.start(), credentials)
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)
} catch (e: Exception) { credentials.close(); throw e }
}
fun run(app: Context, args: List<String>, timeout: Long = 90): JSONObject {
start(app, args).use { child ->
fun run(app: Context, args: List<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
start(app, args, credentialGeneration = credentialGeneration).use { child ->
val output = java.io.ByteArrayOutputStream()
var readError: Throwable? = null
val reader = Thread({
@@ -125,7 +125,8 @@ 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)) {
com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed)
runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) }
.onFailure { StoresState.problems[Store.XBOX] = it.message ?: "Close this Xbox game before updating it." }
}
SecondaryButton("Manage content", enabled = download == null) {
com.droiddeck.launcher.stores.xbox.XboxContent.manage(ctx, installed)
@@ -0,0 +1,60 @@
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)
}
}
@@ -0,0 +1,23 @@
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,10 +21,19 @@ class XboxCatalogTest {
private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now)
@Test fun purchasedAndSubscriptionAccessRemainDistinct() {
assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan)))
assertEquals(mapOf(game to "owned", parent to "gamepass"), access(row(game), row(parent, plan), row(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"))
@@ -0,0 +1,92 @@
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 {
leases.authorize("game", account)
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 guestPreparationRequiresAnUnexpiredAccountPermit() {
assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") }
leases.authorize("game", "account")
assertThrows(IllegalStateException::class.java) { leases.begin("game", "other", "session") }
leases.authorize("game", "account")
time = 180_000
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 lateBackendCannotAttachAfterStop() {
leases.authorize("game", "account")
val token = leases.begin("game", "account", "session")
leases.clear().forEach { it.close() }
assertFalse(leases.attach(token, Closeable { closed++ }))
assertFalse(leases.active("game"))
assertThrows(IllegalStateException::class.java) { leases.begin("game", "account", "session") }
}
@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) }
}
}
@@ -0,0 +1,53 @@
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() }
}
}
@@ -0,0 +1,21 @@
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)
}
}
}
+15 -3
View File
@@ -1,6 +1,6 @@
# Xbox / PC Game Pass on Thor
Branch: `feat/xbox-store`. This integrates PC Microsoft Store MSIXVC packages,
This integrates PC Microsoft Store MSIXVC packages,
not Xbox console emulation or UWP/APPX packages.
## Testing on Thor
@@ -32,7 +32,12 @@ 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.
Steam/session startup uses the selected ARM64 Proton. Launch through
DroidDeck's Play action: it authorizes one account-bound preparation. Direct
Steam launches without that authorization fail with a retry-in-DroidDeck error.
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.
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.
@@ -48,7 +53,14 @@ 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. Sign out from Xbox. Installed files should remain, account library data should
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
clear, and Play/download licensing should require sign-in again.
Do not uninstall DroidDeck or clear application data to troubleshoot this build.
@@ -316,7 +316,11 @@ def main():
if mic_enabled == "0":
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
env = force_ssbs(command, env)
xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox")))
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}
try:
xbox_result = xbox["launch"](command, env)
except (OSError, ValueError, KeyError, RuntimeError) as error:
@@ -102,15 +102,16 @@ def bridge(upstream):
return server.getsockname()[1], key.decode('ascii'), close
def wine(argv):
with open(os.environ['DROIDDECK_XBOX_COMMAND_FILE'], encoding='utf-8') as file:
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:
command = json.load(file)
if len(command) < 3 or command[1] != 'waitforexitandrun' or len(argv) != 1:
raise ValueError('Invalid Xbox Proton invocation')
env = dict(os.environ)
env = dict(source_env)
env.pop('DROIDDECK_XBOX_CREDENTIAL_SOCKET', None)
env.pop('DROIDDECK_XBOX_COMMAND_FILE', None)
prefix = Path(os.environ['STEAM_COMPAT_DATA_PATH']) / 'pfx'
prefix = Path(source_env['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)
@@ -128,10 +129,21 @@ def wine(argv):
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
store = runpy.run_path(str(BIN / 'droiddeck-store-launch'))
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
found = store['find_store_game'](command[2:])
if found is None or found[1].get('store') != 'xbox':
return None
@@ -142,19 +154,26 @@ 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=95)
reply = store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-prepare', 'id': sidecar['id']}, timeout=190)
if not reply or not reply.get('ok'):
raise RuntimeError('Xbox account service is unavailable; sign in again and retry')
port, key, close = bridge(reply['socket'])
stopped = threading.Event()
close = lambda: None
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():
@@ -167,12 +186,11 @@ def launch(command, env):
pass
if roots:
owned_env['XODUS_CONTENT_ROOTS'] = ';'.join(roots)
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)
return wine([reply['exe']], owned_env)
finally:
stopped.set()
close()
store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'id': sidecar['id']})
store['ask'](Path(launch_dir) / 'stores', {'op': 'xbox-released', 'lease': reply['lease']})
if __name__ == '__main__':
+11
View File
@@ -43,6 +43,17 @@ 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
@@ -0,0 +1,48 @@
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='test@example.test', GIT_COMMITTER_EMAIL='test@example.test',
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,6 +19,64 @@ 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')
+20 -3
View File
@@ -24,16 +24,33 @@ 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,
UID-checked Android abstract socket. The Android host encrypts every persistent
Android abstract socket restricted to the backend process started by the host. 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 process gets its own unguessable socket name.
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. An in-memory, single-use Play authorization is
required before the guest can request preparation. 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. Launch Xbox games through DroidDeck's Play action; an unsolicited
launch from inside Steam has no Play authorization.
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; this is not a sandbox against malicious games.
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.
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,3 +84,23 @@
}
#[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);