From 6bbcf3ad8be8ef1b80a97bf5f3444e4e001749b9 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:10:54 -0400 Subject: [PATCH] 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 --- .github/workflows/build.yml | 36 ++++- .../droiddeck/launcher/stores/StoreLaunch.kt | 4 + .../launcher/stores/StoreLaunchRequests.kt | 5 +- .../droiddeck/launcher/stores/StoresState.kt | 6 +- .../launcher/stores/xbox/XboxBackend.kt | 15 +- .../launcher/stores/xbox/XboxCatalog.kt | 7 +- .../launcher/stores/xbox/XboxCredentials.kt | 89 ++++++++++-- .../launcher/stores/xbox/XboxLaunchLeases.kt | 44 ++++++ .../launcher/stores/xbox/XboxLaunchViews.kt | 40 ++++++ .../launcher/stores/xbox/XboxLoginActivity.kt | 7 +- .../launcher/stores/xbox/XboxRuntime.kt | 131 ++++++++++++++---- .../droiddeck/launcher/stores/xbox/Xodus.kt | 46 ++++-- .../com/droiddeck/launcher/ui/StoresDetail.kt | 3 +- .../stores/xbox/XboxAccountLifecycleTest.kt | 60 ++++++++ .../stores/xbox/XboxBackendMappingTest.kt | 23 +++ .../launcher/stores/xbox/XboxCatalogTest.kt | 11 +- .../stores/xbox/XboxLaunchLeasesTest.kt | 92 ++++++++++++ .../stores/xbox/XboxLaunchViewsTest.kt | 53 +++++++ .../stores/xbox/XboxServiceSocketTest.kt | 21 +++ docs/development/xbox-store.md | 18 ++- .../overlay/usr/local/bin/droiddeck-game-env | 6 +- .../overlay/usr/local/bin/droiddeck-xbox | 42 ++++-- tools/tests/test_game_environment.py | 11 ++ tools/tests/test_xbox_build_source.py | 48 +++++++ tools/tests/test_xbox_launch.py | 58 ++++++++ tools/xbox/README.md | 23 ++- tools/xbox/droiddeck-launch-view.patch | 20 +++ 27 files changed, 834 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt create mode 100644 tools/tests/test_xbox_build_source.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46fca6d7c..306e459b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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' diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index e17aac578..68560621f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -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({ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt index 909c0b7fc..b4b9f7669 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunchRequests.kt @@ -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")) } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index 4aa57757c..dbeb94e23 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -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") } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index 2eaa59080..c8f1d27d1 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -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) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index bb3fab4a2..276919ec3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -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 = 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() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt index 14a2a8a89..40cd28225 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCredentials.kt @@ -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() + + 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 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 storage(app: Context, block: () -> T): T = synchronized(lock) { java.io.RandomAccessFile(java.io.File(StoreAccounts.dir(app, Store.XBOX), "account.lock"), "rw").use { file -> diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt new file mode 100644 index 000000000..67f0c5b84 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeases.kt @@ -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() + private val leases = HashMap() + + @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 { + 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 { + permits.clear() + return leases.values.mapNotNull { it.child }.also { leases.clear() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt new file mode 100644 index 000000000..9316ab27e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViews.kt @@ -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() + + @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() { + 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 + } + }) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt index 2192767fc..01e19158f 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLoginActivity.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt index be4d0611b..b75d8fd54 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxRuntime.kt @@ -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() - @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() } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt index 668ed9b3d..1029140b3 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -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, service: Boolean = false): Child { + fun start(app: Context, args: List, 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, timeout: Long = 90): JSONObject { - start(app, args).use { child -> + fun run(app: Context, args: List, 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({ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index 8208cad30..3baa0054a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -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) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt new file mode 100644 index 000000000..ea8f45877 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxAccountLifecycleTest.kt @@ -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() + + @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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt new file mode 100644 index 000000000..ca0a3fe62 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxBackendMappingTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt index 5eb1f2e3c..683696f14 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -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")) diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt new file mode 100644 index 000000000..51bc4f321 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchLeasesTest.kt @@ -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) } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt new file mode 100644 index 000000000..f14d294c0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLaunchViewsTest.kt @@ -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() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt new file mode 100644 index 000000000..f2e7efbef --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxServiceSocketTest.kt @@ -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) + } + } +} diff --git a/docs/development/xbox-store.md b/docs/development/xbox-store.md index e65ede543..6a7565559 100644 --- a/docs/development/xbox-store.md +++ b/docs/development/xbox-store.md @@ -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. diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index f83e93ae3..a7fa3a083 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -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: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox index beec0a65b..c07a6d833 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-xbox @@ -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__': diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index eaef4c6d0..5b2914419 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -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) diff --git a/tools/tests/test_xbox_build_source.py b/tools/tests/test_xbox_build_source.py new file mode 100644 index 000000000..8177a1cbc --- /dev/null +++ b/tools/tests/test_xbox_build_source.py @@ -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) diff --git a/tools/tests/test_xbox_launch.py b/tools/tests/test_xbox_launch.py index 7419dd62c..fb7bfdc0d 100644 --- a/tools/tests/test_xbox_launch.py +++ b/tools/tests/test_xbox_launch.py @@ -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') diff --git a/tools/xbox/README.md b/tools/xbox/README.md index bb4e8ec3f..7f700fd2a 100644 --- a/tools/xbox/README.md +++ b/tools/xbox/README.md @@ -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//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 diff --git a/tools/xbox/droiddeck-launch-view.patch b/tools/xbox/droiddeck-launch-view.patch index ce6fa4600..4f8836a18 100644 --- a/tools/xbox/droiddeck-launch-view.patch +++ b/tools/xbox/droiddeck-launch-view.patch @@ -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);