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