From 2503d6929332de2a4a22c8a31c1b3ce84e5a8831 Mon Sep 17 00:00:00 2001 From: Jagger15 Date: Mon, 12 Oct 2026 17:13:07 -0400 Subject: [PATCH] Add Quest 3/3S compatibility to the standard APK (#642) --- .github/workflows/build.yml | 3 +- app/src/main/cpp/fakeinput_steam.cpp | 13 ++- .../main/java/com/droiddeck/launcher/App.kt | 1 + .../droiddeck/launcher/gpu/TurnipDriver.java | 3 +- .../launcher/runtime/BwrapSpawner.kt | 7 +- .../launcher/runtime/GuestCommand.kt | 1 + .../launcher/session/CrashHandler.kt | 6 ++ .../launcher/session/QuestExperimental.kt | 60 +++++++++++++ .../launcher/session/SessionService.kt | 2 + .../launcher/session/QuestExperimentalTest.kt | 73 ++++++++++++++++ docs/quest.md | 40 +++++++++ tools/build_local.sh | 1 + .../overlay/usr/local/bin/droiddeck-bwrap | 2 +- tools/linuxfs/preload/netif.c | 3 +- tools/linuxfs/preload/quest_kgsl.c | 3 + tools/quest/android_wrapper.c | 26 ++++++ tools/quest/build-driver.py | 60 +++++++++++++ tools/quest/kgsl_compat.c | 78 +++++++++++++++++ tools/quest/kgsl_compat.h | 15 ++++ tools/quest/test_kgsl_compat.c | 77 +++++++++++++++++ tools/release/runtime_inputs.py | 9 +- tools/tests/test_fakeinput_ioctl.py | 84 +++++++++++++++++++ tools/tests/test_quest_kgsl.py | 23 +++++ tools/tests/test_runtime_inputs.py | 14 ++++ tools/tests/test_session_guest_command.py | 67 +++++++++++++++ 25 files changed, 659 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/QuestExperimental.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/QuestExperimentalTest.kt create mode 100644 docs/quest.md create mode 100644 tools/linuxfs/preload/quest_kgsl.c create mode 100644 tools/quest/android_wrapper.c create mode 100644 tools/quest/build-driver.py create mode 100644 tools/quest/kgsl_compat.c create mode 100644 tools/quest/kgsl_compat.h create mode 100644 tools/quest/test_kgsl_compat.c create mode 100644 tools/tests/test_fakeinput_ioctl.py create mode 100644 tools/tests/test_quest_kgsl.py create mode 100644 tools/tests/test_session_guest_command.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2fc4823e..b645f663 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -186,7 +186,7 @@ jobs: uses: actions/cache@v4 with: path: app/src/main/assets/linuxfs - key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env') }} + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' @@ -464,6 +464,7 @@ jobs: SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }} run: | set -o pipefail + python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION" python3 tools/release/runtime_inputs.py record ./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index ad5e0337..984650c5 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -3600,6 +3600,13 @@ static bool wait_snapshot(std::shared_ptr fake, } } +static int forward_ioctl(int fd, ioctl_request_t op, void *argp) { + using ioctl_fn = int (*)(int, ioctl_request_t, ...); + // Preserve later preload hooks, including GPU allocation adapters. + static const auto next = reinterpret_cast(dlsym(RTLD_NEXT, "ioctl")); + return next ? next(fd, op, argp) : static_cast(syscall(SYS_ioctl, fd, op, argp)); +} + EXPORT int ioctl(int fd, ioctl_request_t op, ...) { va_list va; void *argp; @@ -3611,7 +3618,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { // The lock must not be held across the passthrough: binder's transport is a // blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own // controller_mutex() for as long as it waits and deadlock every other ioctl. - if (!watched(fd)) return syscall(SYS_ioctl, fd, op, argp); + if (!watched(fd)) return forward_ioctl(fd, op, argp); std::unique_lock guard(controller_mutex()); auto maker = uinput_map().find(fd); if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp); @@ -3629,7 +3636,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { auto controller = controller_map().find(fd); if (controller == controller_map().end()) { guard.unlock(); - return syscall(SYS_ioctl, fd, op, argp); + return forward_ioctl(fd, op, argp); } int type = (op >> 8 & 0xFF); @@ -3825,7 +3832,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { } else { Logger::log("Unhandled evdev ioctl, type %d number %d\n", type, number); guard.unlock(); - return syscall(SYS_ioctl, fd, op, argp); + return forward_ioctl(fd, op, argp); } } diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index e12aefb3..1b1a2fd8 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -22,6 +22,7 @@ class App : Application() { override fun onCreate() { super.onCreate() CrashHandler.install(this) + com.droiddeck.launcher.session.QuestExperimental.initialize(this) com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) com.droiddeck.launcher.update.AppUpdates.init(this) GpuClockPin.clearLeftover(this) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java index 5cd41467..27083a54 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java @@ -50,7 +50,8 @@ public final class TurnipDriver { /** Adreno 8xx (8 Elite, e.g. a Fold 8's A830). */ private static final String DRIVER_A8XX = "turnip-sdk36"; /** The two the apk carries; never removable, unpacked on demand. */ - public static final List BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX)); + public static final List BUNDLED = Collections.unmodifiableList(Arrays.asList( + DRIVER_A7XX, DRIVER_A8XX, com.droiddeck.launcher.session.QuestExperimental.DRIVER)); /** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */ private static final String OVERRIDE_FILE = "Download/droiddeck-driver"; /** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */ diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt index a5d31529..c733c66d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -33,7 +33,7 @@ import java.util.concurrent.atomic.AtomicInteger */ object BwrapSpawner { private const val TAG = "BwrapSpawner" - const val SOCKET = "com.droiddeck.launcher.bwrap" + fun socket(context: Context): String = "${context.packageName}.bwrap" private val seq = AtomicInteger() @Volatile private var server: LocalServerSocket? = null @@ -42,10 +42,11 @@ object BwrapSpawner { fun start(context: Context) { if (server != null) return val app = context.applicationContext + val name = socket(app) val socket = try { - LocalServerSocket(SOCKET) + LocalServerSocket(name) } catch (e: IOException) { - Log.e(TAG, "cannot listen on $SOCKET", e) + Log.e(TAG, "cannot listen on $name", e) return } server = socket diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 28605958..ad63fc5b 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -51,6 +51,7 @@ object GuestCommand { "/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8", "PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}", "XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}", + "DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", ) cmd += argv diff --git a/app/src/main/java/com/droiddeck/launcher/session/CrashHandler.kt b/app/src/main/java/com/droiddeck/launcher/session/CrashHandler.kt index 1a0a6693..50d9213e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/CrashHandler.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/CrashHandler.kt @@ -24,6 +24,12 @@ object CrashHandler { Thread.setDefaultUncaughtExceptionHandler { thread, error -> try { Log.e(TAG, "uncaught on ${thread.name}", error) + if (QuestExperimental.isSupported()) runCatching { + File(context.filesDir, "quest-last-crash.txt").writeText( + "${java.time.Instant.now()} thread=${thread.name}\n" + + StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString() + ) + } val dir = SessionPaths.current() if (dir != null && dir.isDirectory) { val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString() diff --git a/app/src/main/java/com/droiddeck/launcher/session/QuestExperimental.kt b/app/src/main/java/com/droiddeck/launcher/session/QuestExperimental.kt new file mode 100644 index 00000000..a58bb7f9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/QuestExperimental.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Build +import android.util.Log +import com.droiddeck.launcher.BuildConfig +import java.io.File + +/** Device-detected defaults and launch evidence for experimental Quest compatibility. */ +object QuestExperimental { + private const val TAG = "QuestExperimental" + const val DRIVER = "turnip25.1.0-quest1" + + // Quest 2 has an A6xx GPU and cannot use this bundled A7xx driver. + internal fun supported(model: String, device: String, manufacturer: String): Boolean = + model.equals("Quest 3", ignoreCase = true) || model.equals("Quest 3S", ignoreCase = true) || + ((manufacturer.equals("Oculus", ignoreCase = true) || manufacturer.equals("Meta", ignoreCase = true)) && + (device == "eureka" || device == "panther")) + + fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER) + + fun initialize(context: Context) { + if (!isSupported()) return + val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE) + if (!prefs.getBoolean("questDefaultsV1", false)) { + // Auto would download an unwrapped display driver and lose the compatibility path. + // Apply once so deliberate changes made during headset testing remain possible. + prefs.edit() + .putString("gpuDriverMode", SessionPrefs.GPU_DRIVERS_MANUAL) + .putString("androidDriver", DRIVER) + .putString("linuxDriver", "") + // The existing HDR option uses SurfaceControl presentation. This is a + // corruption workaround from upstream issue #245, not a claim of HDR accuracy. + .putBoolean("hdr.steam", true) + .putBoolean("questDefaultsV1", true) + .apply() + } + val report = buildString { + appendLine("DroidDeck Quest Experimental ${BuildConfig.VERSION_NAME}") + appendLine("package=${context.packageName}") + appendLine("manufacturer=${Build.MANUFACTURER} model=${Build.MODEL}") + appendLine("device=${Build.DEVICE} board=${Build.BOARD} hardware=${Build.HARDWARE}") + appendLine("android=${Build.VERSION.RELEASE} sdk=${Build.VERSION.SDK_INT}") + appendLine("abis=${Build.SUPPORTED_ABIS.joinToString()}") + for (name in listOf("gpu_model", "gpu_chipid")) { + val value = runCatching { File("/sys/class/kgsl/kgsl-3d0/$name").readText().trim() } + .getOrDefault("unavailable") + appendLine("$name=$value") + } + appendLine("driverMode=${SessionPrefs.gpuDriverMode(context)}") + appendLine("androidDriver=${SessionPrefs.androidDriver(context)}") + appendLine("steamSurfaceControlWorkaround=${SessionPrefs.hdr(context, "steam")}") + appendLine("GPUOBJ fallback installed in Android driver and ARM64 libblsession") + appendLine("Experimental Quest 3/3S compatibility; 2D panel application") + } + Log.i(TAG, report) + runCatching { File(context.filesDir, "quest-startup.txt").writeText(report) } + .onFailure { Log.w(TAG, "Could not write startup report", it) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 5802b563..01f9112e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -585,6 +585,8 @@ class SessionService : Service() { private fun addClientEnvironment(guest: MutableList, steamHere: Boolean) { guest.add("/usr/bin/env") guest.add("-i") + // PRoot executes guest[0] directly; assignments belong after env -i. + guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this)) guest.add("HOME=/root") guest.add("USER=root") guest.add("PATH=/usr/local/bin:/usr/bin:/bin") diff --git a/app/src/test/java/com/droiddeck/launcher/session/QuestExperimentalTest.kt b/app/src/test/java/com/droiddeck/launcher/session/QuestExperimentalTest.kt new file mode 100644 index 00000000..5677b151 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/QuestExperimentalTest.kt @@ -0,0 +1,73 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.content.ContextWrapper +import com.droiddeck.launcher.runtime.BwrapSpawner +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBuild + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class QuestExperimentalTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + + @Before fun reset() { + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + ShadowBuild.setModel("Phone") + ShadowBuild.setDevice("phone") + } + + @Test fun otherDevicesAndQuest2KeepTheirSettings() { + for (model in listOf("Phone", "Quest 2")) { + ShadowBuild.setModel(model) + QuestExperimental.initialize(context) + assertEquals(SessionPrefs.GPU_DRIVERS_AUTO, SessionPrefs.gpuDriverMode(context)) + assertEquals("", SessionPrefs.androidDriver(context)) + assertFalse(SessionPrefs.hdr(context, "steam")) + } + assertFalse(QuestExperimental.supported("Quest 2", "hollywood", "Oculus")) + } + + @Test fun questDefaultsSelectBothCompatibilityPathsAndAreAppliedOnlyOnce() { + ShadowBuild.setModel("Quest 3") + val standard = object : ContextWrapper(context) { + override fun getPackageName() = "com.droiddeck.launcher" + } + QuestExperimental.initialize(standard) + assertEquals(SessionPrefs.GPU_DRIVERS_MANUAL, SessionPrefs.gpuDriverMode(context)) + assertEquals(QuestExperimental.DRIVER, SessionPrefs.androidDriver(context)) + assertEquals("", SessionPrefs.linuxDriver(context)) + assertTrue(SessionPrefs.hdr(context, "steam")) + assertTrue(context.filesDir.resolve("quest-startup.txt").readText().contains("model=Quest 3")) + SessionPrefs.setAndroidDriver(context, "chosen-driver") + SessionPrefs.setHdr(context, "steam", false) + QuestExperimental.initialize(context) + assertEquals("chosen-driver", SessionPrefs.androidDriver(context)) + assertFalse(SessionPrefs.hdr(context, "steam")) + } + + @Test fun quest3sIsRecognizedByModelOrDevice() { + assertTrue(QuestExperimental.supported("Quest 3S", "unknown", "Oculus")) + assertTrue(QuestExperimental.supported("unknown", "panther", "Oculus")) + assertTrue(QuestExperimental.supported("unknown", "eureka", "Meta")) + assertFalse(QuestExperimental.supported("Pixel 7", "panther", "Google")) + } + + @Test fun installedPackagesUseSeparateSandboxSockets() { + val packages = listOf("com.droiddeck.launcher", "com.tencent.ig", + "com.antutu.benchmark.full", "com.ludashi.benchmark") + val sockets = packages.map { name -> + val variant = object : ContextWrapper(context) { + override fun getPackageName() = name + } + BwrapSpawner.socket(variant).also { assertEquals("$name.bwrap", it) } + } + assertEquals(packages.size, sockets.toSet().size) + } +} diff --git a/docs/quest.md b/docs/quest.md new file mode 100644 index 00000000..af5871d0 --- /dev/null +++ b/docs/quest.md @@ -0,0 +1,40 @@ +# Experimental Quest 3/3S support + +Quest 3/3S runs DroidDeck as a 2D panel. This does not add immersive VR or +OpenXR support. Quest 2 is outside the bundled Adreno 7xx driver path. + +Install the standard DroidDeck APK on Quest 3/3S. Device detection on the +first launch selects manual drivers, the bundled +`turnip25.1.0-quest1` Android driver and the default Linux driver. It enables +`hdr.steam` to use the existing SurfaceControl presentation workaround from +[the Quest report](https://github.com/Droid-Deck/DroidDeck/issues/245). +These defaults apply once; subsequent user changes are retained. Colour +accuracy and headset performance still need testing. + +Both the Android driver wrapper and the Linux session preload use +`tools/quest/kgsl_compat.c`. Successful legacy allocation/free calls are +unchanged. Only `EACCES`/`EPERM` failures retry through `GPUOBJ_ALLOC`, +`GPUOBJ_INFO` and `GPUOBJ_FREE`. The Android wrapper depends on the original +bundled Turnip binary. Unhandled controller ioctls forward to the next +preload hook rather than skipping it with a raw syscall. + +`tools/build_local.sh` and CI build the wrapper from source using the pinned +Android NDK; the normal preload builds include the shared Linux adapter. +Runtime input verification checks the resulting driver archive and invalidates +stale prepared assets after compatibility sources change. To rebuild only the +Android driver archive: + +```sh +python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/27.3.13750724" +``` + +Quest compatibility is included in the standard APK; no separate Quest +package is needed. Other devices retain their existing defaults. The remaining +performance-name APK variants continue to use separate app data and bwrap sockets. + +Quest 3 testers have successfully launched games. + +On a headset, check the Steam login panel, sign-in, game installation and +then an owned game's rendering/input. Capture the device/OS, session logs, +`files/quest-startup.txt` and any `files/quest-last-crash.txt` failure. +Host allocation and preload-chain tests do not establish headset operation. diff --git a/tools/build_local.sh b/tools/build_local.sh index d5d45dbc..224e9b83 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -320,6 +320,7 @@ else fi cd "${repo_root}" +python3 tools/quest/build-driver.py "${NDK}" bash tools/comet/build-local.sh python3 tools/release/runtime_inputs.py record ./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}" ${gradle_rust_arg} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap index 5a3a7395..0070565c 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap @@ -20,7 +20,7 @@ import socket import struct import sys -SOCKET = "\0com.droiddeck.launcher.bwrap" +SOCKET = "\0" + os.environ.get("DROIDDECK_BWRAP_SOCKET", "com.droiddeck.launcher.bwrap") VERSION = "bubblewrap 0.11.0 (droiddeck)" # Options and how many arguments each takes; bwrap(1). diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 0e394a7e..acdc6e66 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -421,6 +421,7 @@ static int is_link_query(unsigned long request) { } void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc); /* kgslid.c */ +#include "../../quest/kgsl_compat.h" int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden"))); int bl_ntsync_ioctl(int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden"))); @@ -440,7 +441,7 @@ int ioctl(int fd, unsigned long request, ...) { if (bl_ntsync_ioctl(fd, request, arg, &rc)) return rc; if (bl_drm_gem_close(fd, request, arg, &rc)) return rc; if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); - if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = real(fd, request, arg); + if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = quest_kgsl_ioctl(real, fd, request, arg); bl_kgsl_chip_id_fixup(request, arg, rc); if (arg == NULL || !is_link_query(request)) return rc; int saved = errno; diff --git a/tools/linuxfs/preload/quest_kgsl.c b/tools/linuxfs/preload/quest_kgsl.c new file mode 100644 index 00000000..09037faf --- /dev/null +++ b/tools/linuxfs/preload/quest_kgsl.c @@ -0,0 +1,3 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Shared with the Android driver wrapper so the two translation paths cannot drift. */ +#include "../../quest/kgsl_compat.c" diff --git a/tools/quest/android_wrapper.c b/tools/quest/android_wrapper.c new file mode 100644 index 00000000..241ad784 --- /dev/null +++ b/tools/quest/android_wrapper.c @@ -0,0 +1,26 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Loaded as the AdrenoTools driver; DT_NEEDED points to the original Turnip. + * Vulkan/HAL symbols are resolved from that dependency. Its ioctl calls resolve here. + */ +#define _GNU_SOURCE +#include "kgsl_compat.h" +#include +#include +#include +#include +#include + +static int (*next_ioctl)(int, int, ...); +static pthread_once_t once = PTHREAD_ONCE_INIT; +static void resolve_ioctl(void) { next_ioctl = dlsym(RTLD_NEXT, "ioctl"); } +static int forward(int fd, unsigned long request, void *arg) { + pthread_once(&once, resolve_ioctl); + return next_ioctl ? next_ioctl(fd, (int)request, arg) : (int)syscall(SYS_ioctl, fd, request, arg); +} +__attribute__((visibility("default"))) int ioctl(int fd, int request, ...) { + va_list ap; + va_start(ap, request); + void *arg = va_arg(ap, void *); + va_end(ap); + return quest_kgsl_ioctl(forward, fd, (unsigned int)request, arg); +} diff --git a/tools/quest/build-driver.py b/tools/quest/build-driver.py new file mode 100644 index 00000000..b421f997 --- /dev/null +++ b/tools/quest/build-driver.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Build the Quest allocation adapter around the unchanged bundled Android Turnip.""" +import io +import json +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = ROOT / 'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst' + + +def build(ndk, output=OUTPUT): + hosts = list((Path(ndk) / 'toolchains/llvm/prebuilt').glob('*')) + if len(hosts) != 1: + raise ValueError('Expected one NDK host toolchain') + llvm = hosts[0] / 'bin' + original = ROOT / 'app/src/main/assets/graphics_driver/adrenotools-turnip25.1.0.tzst' + data = subprocess.run(['zstd', '-dc', str(original)], capture_output=True, check=True).stdout + with tempfile.TemporaryDirectory(prefix='quest-driver-') as folder: + work = Path(folder) + with tarfile.open(fileobj=io.BytesIO(data)) as archive: + # Only read the known driver entry; no archive paths are extracted. + driver = archive.extractfile('vulkan.ad07xx.so').read() + (work / 'vulkan.ad07xx.so').write_bytes(driver) + wrapper = work / 'libquest_turnip.so' + subprocess.run([ + str(llvm / 'aarch64-linux-android26-clang'), '-shared', '-fPIC', '-O2', + '-Wall', '-Wextra', '-Werror', '-fvisibility=hidden', + '-Wl,-z,max-page-size=16384', '-Wl,-soname,libquest_turnip.so', + '-Wl,--no-as-needed', str(work / 'vulkan.ad07xx.so'), '-Wl,--as-needed', + str(ROOT / 'tools/quest/android_wrapper.c'), str(ROOT / 'tools/quest/kgsl_compat.c'), + '-ldl', '-llog', '-o', str(wrapper), + ], check=True) + subprocess.run([str(llvm / 'llvm-strip'), '--strip-unneeded', str(wrapper)], check=True) + metadata = json.dumps({ + 'libraryName': 'libquest_turnip.so', + 'name': 'Turnip Adreno 7xx - Quest GPUOBJ experimental', + 'description': 'Bundled A7xx driver with the experimental Quest 3/3S allocation adapter.', + 'driverVersion': 'Bundled Mesa + Quest GPUOBJ v1', + }, indent=2).encode() + b'\n' + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w', format=tarfile.USTAR_FORMAT) as archive: + for name, content in [('meta.json', metadata), ('libquest_turnip.so', wrapper.read_bytes()), + ('vulkan.ad07xx.so', driver)]: + entry = tarfile.TarInfo(name) + entry.size = len(content) + entry.mode = 0o644 + archive.addfile(entry, io.BytesIO(content)) + compressed = subprocess.run(['zstd', '-q', '-19', '-c'], input=stream.getvalue(), + capture_output=True, check=True).stdout + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(compressed) + print(output) + + +if __name__ == '__main__': + build(sys.argv[1]) diff --git a/tools/quest/kgsl_compat.c b/tools/quest/kgsl_compat.c new file mode 100644 index 00000000..4b1ab668 --- /dev/null +++ b/tools/quest/kgsl_compat.c @@ -0,0 +1,78 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * Quest experimental build, 2026-10-11. + * Horizon permits GPUOBJ allocation, but rejects the legacy GPUMEM alloc/free calls. + * Retry only those two calls after EACCES/EPERM. No change to device permissions. + */ +#include "kgsl_compat.h" +#include +#include +#include +#include +#ifdef __ANDROID__ +#include +#endif + +#if __SIZEOF_POINTER__ == 8 +_Static_assert(sizeof(struct kgsl_gpumem_alloc_id) == 48, "KGSL alloc ABI"); +_Static_assert(sizeof(struct kgsl_gpuobj_alloc) == 48, "GPUOBJ alloc ABI"); +_Static_assert(sizeof(struct kgsl_gpuobj_free) == 32, "GPUOBJ free ABI"); +_Static_assert(sizeof(struct kgsl_gpuobj_info) == 48, "GPUOBJ info ABI"); +#endif + +static void report(const char *operation, int error) { + static unsigned successes, failures; + unsigned *counter = error ? &failures : &successes; + /* Log the first success and at most eight failures per process. */ + if (__atomic_fetch_add(counter, 1, __ATOMIC_RELAXED) >= (error ? 8u : 1u)) return; + int saved = errno; +#ifdef __ANDROID__ + __android_log_print(error ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO, "QuestKGSL", + "%s: GPUOBJ compatibility path, errno=%d", operation, error); +#else + fprintf(stderr, "[QuestKGSL] %s: GPUOBJ compatibility path, errno=%d\n", operation, error); +#endif + errno = saved; +} + +int quest_kgsl_ioctl(quest_ioctl_fn real, int fd, unsigned long request, void *arg) { + int rc = real(fd, request, arg); + if (rc != -1 || (errno != EACCES && errno != EPERM) || arg == NULL) return rc; + + if (request == IOCTL_KGSL_GPUMEM_ALLOC_ID) { + struct kgsl_gpumem_alloc_id *old = arg; + struct kgsl_gpuobj_alloc alloc = {.size = old->size, .flags = old->flags}; + rc = real(fd, IOCTL_KGSL_GPUOBJ_ALLOC, &alloc); + if (rc != 0) { report("alloc failed", errno); return rc; } + struct kgsl_gpuobj_info info = {.id = alloc.id}; + rc = real(fd, IOCTL_KGSL_GPUOBJ_INFO, &info); + int error = errno; + if (rc == 0 && (info.size > SIZE_MAX || alloc.mmapsize > SIZE_MAX || + info.gpuaddr > ULONG_MAX || info.flags > UINT_MAX)) { + rc = -1; + error = EOVERFLOW; + } + if (rc != 0) { + /* The caller never receives this allocation, so clean it up on an INFO failure. */ + struct kgsl_gpuobj_free release = {.id = alloc.id}; + if (real(fd, IOCTL_KGSL_GPUOBJ_FREE, &release) != 0) + report("cleanup failed", errno); + errno = error; + report("info failed", error); + return rc; + } + old->id = alloc.id; + old->flags = (unsigned int)info.flags; + old->size = info.size; + old->mmapsize = alloc.mmapsize; + old->gpuaddr = info.gpuaddr; + report("allocation succeeded", 0); + return 0; + } + if (request == IOCTL_KGSL_GPUMEM_FREE_ID) { + const struct kgsl_gpumem_free_id *old = arg; + struct kgsl_gpuobj_free release = {.id = old->id}; + rc = real(fd, IOCTL_KGSL_GPUOBJ_FREE, &release); + if (rc != 0) report("free failed", errno); + } + return rc; +} diff --git a/tools/quest/kgsl_compat.h b/tools/quest/kgsl_compat.h new file mode 100644 index 00000000..f54e334e --- /dev/null +++ b/tools/quest/kgsl_compat.h @@ -0,0 +1,15 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#ifndef DROIDDECK_QUEST_KGSL_COMPAT_H +#define DROIDDECK_QUEST_KGSL_COMPAT_H + +/* Use the vendored Qualcomm UAPI, including its architecture-dependent ioctl sizes. */ +#ifndef __user +#define __user +#endif +#include "../../app/src/main/cpp/adrenotools/src/hook/kgsl.h" + +typedef int (*quest_ioctl_fn)(int, unsigned long, void *); +__attribute__((visibility("hidden"))) +int quest_kgsl_ioctl(quest_ioctl_fn real, int fd, unsigned long request, void *arg); + +#endif diff --git a/tools/quest/test_kgsl_compat.c b/tools/quest/test_kgsl_compat.c new file mode 100644 index 00000000..30ae397a --- /dev/null +++ b/tools/quest/test_kgsl_compat.c @@ -0,0 +1,77 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +#include "kgsl_compat.h" +#include +#include +#include +#include + +static unsigned long calls[8]; +static int count, old_error, alloc_error, info_error, free_error; +static unsigned long address; +static int mock(int fd, unsigned long request, void *arg) { + assert(fd == 42 && count < 8); + calls[count++] = request; + if (request == IOCTL_KGSL_GPUMEM_ALLOC_ID || request == IOCTL_KGSL_GPUMEM_FREE_ID) { + if (old_error) { errno = old_error; return -1; } + return 0; + } + if (request == IOCTL_KGSL_GPUOBJ_ALLOC) { + if (alloc_error) { errno = alloc_error; return -1; } + struct kgsl_gpuobj_alloc *p = arg; + assert(p->size == 123 && p->flags == 0x10000 && !p->metadata && !p->metadata_len); + p->id = 77; p->size = 4096; p->mmapsize = 8192; + return 0; + } + if (request == IOCTL_KGSL_GPUOBJ_INFO) { + struct kgsl_gpuobj_info *p = arg; + assert(p->id == 77); + if (info_error) { errno = info_error; return -1; } + p->gpuaddr = address; p->flags = 0x20000; p->size = 4096; + return 0; + } + if (request == IOCTL_KGSL_GPUOBJ_FREE) { + struct kgsl_gpuobj_free *p = arg; + assert(p->id == 77 && !p->flags && !p->priv && !p->type && !p->len); + if (free_error) { errno = free_error; return -1; } + return 0; + } + errno = EACCES; + return -1; +} +static struct kgsl_gpumem_alloc_id reset(void) { + count = old_error = alloc_error = info_error = free_error = 0; + address = 0x100000; + return (struct kgsl_gpumem_alloc_id){.size = 123, .flags = 0x10000}; +} +int main(void) { + struct kgsl_gpumem_alloc_id p = reset(); + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == 0 && count == 1); + p = reset(); old_error = EACCES; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == 0); + assert(count == 3 && p.id == 77 && p.size == 4096 && p.mmapsize == 8192); + assert(p.flags == 0x20000 && p.gpuaddr == address); + p = reset(); old_error = EPERM; address = 0; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == 0 && p.gpuaddr == 0); + p = reset(); old_error = ENOMEM; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == ENOMEM && count == 1); + p = reset(); old_error = EACCES; alloc_error = ENOMEM; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == ENOMEM && count == 2); + assert(p.id == 0 && p.size == 123); + p = reset(); old_error = EACCES; info_error = EIO; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == EIO); + assert(count == 4 && calls[3] == IOCTL_KGSL_GPUOBJ_FREE && p.id == 0); + p = reset(); old_error = EACCES; info_error = EIO; free_error = ENOSPC; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, &p) == -1 && errno == EIO && count == 4); + p = reset(); old_error = EACCES; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_ALLOC_ID, NULL) == -1 && count == 1); + p = reset(); old_error = EACCES; + assert(quest_kgsl_ioctl(mock, 42, 0x1234, &p) == -1 && errno == EACCES && count == 1); + struct kgsl_gpumem_free_id release = {.id = 77}; + reset(); old_error = EACCES; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_FREE_ID, &release) == 0 && count == 2); + reset(); old_error = EPERM; free_error = EIO; + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_FREE_ID, &release) == -1 && errno == EIO && count == 2); + reset(); + assert(quest_kgsl_ioctl(mock, 42, IOCTL_KGSL_GPUMEM_FREE_ID, &release) == 0 && count == 1); + puts("PASS: 12 GPUOBJ translation, passthrough, failure and cleanup scenarios"); +} diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index b4108dbc..eaccde51 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -16,7 +16,9 @@ SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env', 'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/build_local.sh', - 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal'] + 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', + 'tools/quest', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', + 'app/src/main/assets/graphics_driver/adrenotools-turnip25.1.0.tzst'] def digest(path): @@ -48,11 +50,14 @@ def outputs(root): for library in ['libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so']: result['lib/arm64-v8a/' + library] = 'app/src/main/jniLibs/arm64-v8a/' + library result['assets/pulseaudio.tzst'] = 'app/build/prepared-assets/pulseaudio.tzst' + result['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'] = \ + 'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst' return result def required(): - paths = ['assets/linuxfs/' + name for name in ['libfakeinput.so', 'libblsession.so', + paths = ['assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst'] + paths += ['assets/linuxfs/' + name for name in ['libfakeinput.so', 'libblsession.so', 'libblfastpath.so', 'libssbs.so', 'usr/local/bin/droiddeck-clipboard', 'usr/local/bin/gamescope', 'usr/local/lib/droiddeck/uruntime']] for arch in ['x86_64', 'i386']: diff --git a/tools/tests/test_fakeinput_ioctl.py b/tools/tests/test_fakeinput_ioctl.py new file mode 100644 index 00000000..0eab1916 --- /dev/null +++ b/tools/tests/test_fakeinput_ioctl.py @@ -0,0 +1,84 @@ +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[2] / "app/src/main/cpp/fakeinput_steam.cpp" + +PROGRAM = r""" +#include +#include +#include +#include +#include +#include + +#ifdef BACKEND +int ioctl(int fd, unsigned long request, ...) { + va_list ap; + va_start(ap, request); + int *value = va_arg(ap, int *); + va_end(ap); + assert(fd == -1 && request == 0x12345678 && value); + *value = 0x51; + errno = EACCES; + return -1; +} +#else +int main(int argc, char **argv) { + if (argc > 1) { + int value = 0; + assert(ioctl(-1, 0x12345678, &value) == -1); + assert(value == 0x51 && errno == EACCES); + } else { + int fds[2]; + assert(pipe(fds) == 0); + struct termios term; + assert(ioctl(fds[0], TCGETS, &term) == -1 && errno == ENOTTY); + close(fds[0]); + close(fds[1]); + } + return 0; +} +#endif +""" + + +@unittest.skipUnless(sys.platform == "linux" and shutil.which("gcc") and shutil.which("g++"), + "needs Linux, gcc and g++") +class FakeinputIoctlTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.tmp.cleanup) + cls.path = Path(cls.tmp.name) + source = cls.path / "program.c" + source.write_text(PROGRAM) + subprocess.run(["gcc", "-o", str(cls.path / "program"), str(source)], check=True) + subprocess.run(["gcc", "-shared", "-fPIC", "-DBACKEND", "-o", + str(cls.path / "backend.so"), str(source)], check=True) + subprocess.run(["g++", "-shared", "-fPIC", "-O2", "-Wno-attributes", "-pthread", + "-std=c++17", "-static-libstdc++", "-static-libgcc", + "-Wl,--exclude-libs,ALL", "-o", str(cls.path / "libfakeinput.so"), + str(SOURCE), "-ldl"], check=True) + + def run_program(self, backend=False): + preload = str(self.path / "libfakeinput.so") + args = [str(self.path / "program")] + if backend: + preload += ":" + str(self.path / "backend.so") + args.append("backend") + subprocess.run(args, env=dict(os.environ, LD_PRELOAD=preload), check=True, timeout=10) + + def test_later_interposer_receives_arguments_and_returns_errno(self): + self.run_program(backend=True) + + def test_libc_passthrough_retains_kernel_error(self): + self.run_program() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_quest_kgsl.py b/tools/tests/test_quest_kgsl.py new file mode 100644 index 00000000..4ce7ae0f --- /dev/null +++ b/tools/tests/test_quest_kgsl.py @@ -0,0 +1,23 @@ +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +QUEST = Path(__file__).resolve().parents[1] / 'quest' + + +@unittest.skipUnless(sys.platform == 'linux' and shutil.which('gcc'), 'needs Linux and gcc') +class QuestKgslTest(unittest.TestCase): + def test_allocation_passthrough_translation_and_cleanup(self): + with tempfile.TemporaryDirectory() as folder: + binary = str(Path(folder) / 'test-kgsl') + subprocess.run(['gcc', '-Wall', '-Wextra', '-Werror', '-O2', '-o', binary, + str(QUEST / 'test_kgsl_compat.c'), str(QUEST / 'kgsl_compat.c')], check=True) + result = subprocess.run([binary], capture_output=True, text=True, check=True, timeout=10) + self.assertIn('PASS: 12 GPUOBJ', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_runtime_inputs.py b/tools/tests/test_runtime_inputs.py index b2f2f02a..92c04531 100644 --- a/tools/tests/test_runtime_inputs.py +++ b/tools/tests/test_runtime_inputs.py @@ -55,6 +55,20 @@ class RuntimeInputsTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, 'sources changed'): runtime.verify(self.root) + def test_changed_quest_source_rejects_old_outputs(self): + source = self.root / 'tools/quest/kgsl_compat.c' + source.parent.mkdir(parents=True) + source.write_text('old compatibility code') + runtime.record(self.root) + source.write_text('new compatibility code') + with self.assertRaisesRegex(ValueError, 'sources changed'): + runtime.verify(self.root) + + def test_missing_quest_driver_cannot_be_attested(self): + (self.root / 'app/build/prepared-assets/graphics_driver/adrenotools-turnip25.1.0-quest1.tzst').unlink() + with self.assertRaisesRegex(ValueError, 'quest1.tzst'): + runtime.record(self.root) + def test_modified_prebuilt_binary_is_rejected(self): runtime.record(self.root) (self.root / 'app/src/main/jniLibs/arm64-v8a/libproot.so').write_bytes(b'old APK binary') diff --git a/tools/tests/test_session_guest_command.py b/tools/tests/test_session_guest_command.py new file mode 100644 index 00000000..53645082 --- /dev/null +++ b/tools/tests/test_session_guest_command.py @@ -0,0 +1,67 @@ +"""Exercise the literal launch prefix from SessionService without an Android runtime. + +This is a focused source/exec regression check, not a Kotlin or PRoot integration +test. PRoot executes the first guest argument directly; KEY=VALUE is meaningful +only after /usr/bin/env. Read the production prefix rather than duplicate it. +""" +import json +from pathlib import Path +import re +import subprocess +import unittest + + +SERVICE = Path(__file__).resolve().parents[2] / ( + "app/src/main/java/com/droiddeck/launcher/session/SessionService.kt") + +VARIANTS = Path(__file__).resolve().parents[1] / "release/variants.txt" +PACKAGES = [line.split()[1] for line in VARIANTS.read_text().splitlines() + if line.strip() and not line.startswith("#")] + + +def guest_prefix(package): + source = SERVICE.read_text() + before_environment = source.split("val guest = ArrayList()", 1)[1].split( + "addClientEnvironment(guest, steamHere)", 1)[0] + environment = source.split("private fun addClientEnvironment(", 1)[1].split( + 'guest.add("HOME=/root")', 1)[0] + prefix = [] + for line in (before_environment + environment).splitlines(): + line = line.strip() + if not line.startswith("guest.add("): + continue + expression = line[len("guest.add("):-1] + match = re.fullmatch(r'("[^"\\]*")(?: \+ (.+))?', expression) + if match is None: + raise AssertionError("Launch prefix changed; update its regression harness: " + line) + value = json.loads(match[1]) + if match[2]: + if match[2] != "com.droiddeck.launcher.runtime.BwrapSpawner.socket(this)": + raise AssertionError("Unexpected dynamic launch-prefix argument: " + line) + value += package + ".bwrap" + prefix.append(value) + return prefix + + +class SessionGuestCommandTest(unittest.TestCase): + def test_env_is_the_executable_for_every_package(self): + for package in PACKAGES: + with self.subTest(package=package): + self.assertEqual(["/usr/bin/env", "-i"], guest_prefix(package)[:2]) + + @unittest.skipUnless(Path("/usr/bin/env").is_file(), "needs /usr/bin/env") + def test_child_receives_its_package_socket_after_environment_is_cleared(self): + for package in PACKAGES: + with self.subTest(package=package): + # env with no command prints the environment it would pass to the + # session. A misplaced assignment fails at exec, before it runs. + result = subprocess.run( + guest_prefix(package), capture_output=True, text=True, check=True, + timeout=5, env={"DROIDDECK_BWRAP_SOCKET": "wrong.bwrap", "STALE_HOST": "1"}) + self.assertEqual( + ["DROIDDECK_BWRAP_SOCKET=" + package + ".bwrap"], + result.stdout.splitlines()) + + +if __name__ == "__main__": + unittest.main()