mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
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:
1 parent
a63f3b1fbc
commit
7eae6ca3e7
27 files changed
+834
-85
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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__':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user