From 5fe5fa45c77c51d01ff17844328114efa1fd3da0 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 12:00:24 -0400 Subject: [PATCH] fix: let the Steam client find the Deck controller under an enforcing SELinux policy --- app/src/main/cpp/fakeinput_steam.cpp | 94 ++++++++++++++++++- .../launcher/session/SessionService.kt | 5 +- .../launcher/session/SteamDeckPad.kt | 14 ++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 936268e5f..128cb8d97 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1488,8 +1488,18 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { // into /sys, so those paths are said to be sysfs; nothing else is touched. static constexpr long SYSFS_FS_MAGIC = 0x62656572; +// FAKE_DECK_SYSFS_LISTING: stand-ins for the listings of /sys, /sys/class and /sys/bus +// (SteamDeckPad.listingDir), for a client that is not allowed to list the real ones. +__attribute__((visibility("hidden"))) static const char *deck_listing_dir() { + static const char *dir = getenv("FAKE_DECK_SYSFS_LISTING"); + return dir && *dir ? dir : nullptr; +} + __attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) { - return path && (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)); + if (!path) return false; + if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true; + const char *listing = deck_listing_dir(); + return listing && !strncmp(path, listing, strlen(listing)); } __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { @@ -1644,6 +1654,80 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) { } } +// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any +// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy +// denies directories a permissive device lets through. The client's /sys failures that are not +// ENOENT are logged, each path once, so pad.log names the directory that stopped the scan. +__attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) { + int saved_errno = errno; + std::string path; + if (saved_errno == ENOENT || !pathname || !process_is_steam_client()) { + errno = saved_errno; + return path; + } + if (pathname[0] == '/' || dirfd == AT_FDCWD) { + path = pathname; + } else { + char link[64], dir[PATH_MAX]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd); + ssize_t length = readlink(link, dir, sizeof(dir) - 1); + if (length <= 0) { + errno = saved_errno; + return path; + } + path.assign(dir, length); + path += '/'; + path += pathname; + } + if (path.compare(0, 5, "/sys/") != 0 && path != "/sys") { + errno = saved_errno; + return {}; + } + static std::mutex mutex; + static std::vector *seen = new std::vector(); + bool first = false; + { + std::lock_guard guard(mutex); + if (seen->size() < 64 && std::find(seen->begin(), seen->end(), path) == seen->end()) { + seen->push_back(path); + first = true; + } + } + if (first) Logger::log("sysfs: %s not readable (%s)\n", path.c_str(), strerror(saved_errno)); + errno = saved_errno; + return path; +} + +// An enforcing SELinux policy (every retail phone; the Thor only with it forced on) refuses an app +// a listing of /sys, /sys/class and /sys/bus. libudev lists /sys/class to find the subsystems to +// descend into and gives up its whole scan when it cannot, so the client never reached hidraw and +// found no Deck. The client is listed the stand-ins instead - hidraw under class - and opens +// /sys/class/hidraw itself, which is ours and readable. Only on that refusal, only for the client. +__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path) { + const char *listing = deck_listing_dir(); + if (!path || !listing || errno != EACCES || !fake_deck_enabled() || !process_is_steam_client()) return {}; + std::string p(path); + while (p.size() > 1 && p.back() == '/') p.pop_back(); + int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1; + if (which < 0) return {}; + static const char *const names[] = {"sys", "class", "bus"}; + static std::atomic logged{0}; + if (!(logged.fetch_or(1 << which) & (1 << which))) + Logger::log("sysfs: %s listed from the Deck's stand-in\n", p.c_str()); + return std::string(listing) + "/" + names[which]; +} + +EXPORT DIR *opendir(const char *name) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); + DIR *dir = real(name); + if (!dir) { + note_sys_failure(AT_FDCWD, name); + std::string listing = deck_listing_for(name); + if (!listing.empty()) dir = real(listing.c_str()); + } + return dir; +} + EXPORT int open(const char *pathname, int flags, ...) { va_list va; mode_t mode; @@ -1707,6 +1791,10 @@ EXPORT int open(const char *pathname, int flags, ...) { fd = my_open(pathname, flags, mode); else fd = my_open(pathname, flags); + if (fd < 0) { + std::string listing = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str()); + if (!listing.empty()) fd = my_open(listing.c_str(), flags); + } if (fake_path) free(fake_path); @@ -1777,6 +1865,10 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { fd = my_openat(dirfd, pathname, flags, mode); else fd = my_openat(dirfd, pathname, flags); + if (fd < 0) { + std::string listing = deck_listing_for(note_sys_failure(dirfd, pathname).c_str()); + if (!listing.empty()) fd = my_openat(AT_FDCWD, listing.c_str(), flags); + } if (fake_path) free(fake_path); 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 a7832cf88..bb4a7dd89 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -647,7 +647,10 @@ class SessionService : Service() { // /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a // game becomes a node the game reads, carrying the player's layout, as on a Deck. guest.add("FAKE_EVDEV_UINPUT=1") - if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1") + if (SessionState.deckPad) { + guest.add("FAKE_EVDEV_DECK=1") + guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path) + } } else if (SessionState.mode == MODE_STEAM) { // Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity - // for games the client starts only. Everywhere else (the desktop, a program from the diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt index 39747df8f..c4e3b06d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -23,7 +23,11 @@ import java.io.File * - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the * runtime already binds there for the GPU); * - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without - * which libudev's enumeration leaves a device node out. + * which libudev's enumeration leaves a device node out; + * - [listingDir] - stand-ins for the listings of `/sys`, `/sys/class` and `/sys/bus`. An app under + * an enforcing SELinux policy (every retail phone) may not list those directories, and libudev + * abandons its whole scan when it cannot, so the client never reached `hidraw`; libfakeinput + * lists these to the client instead when the real listing is refused (FAKE_DECK_SYSFS_LISTING). * * Symlinks are absolute guest paths; proot resolves them inside the session. */ @@ -47,6 +51,9 @@ object SteamDeckPad { 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, ).map { it.toByte() }.toByteArray() + /** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */ + fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") + /** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */ fun prepare(context: Context, sessionRoot: File): List { val base = File(sessionRoot, "sys/deck") @@ -89,6 +96,11 @@ object SteamDeckPad { hidrawClass.mkdirs() link(File(hidrawClass, NODE), HIDRAW) + + // Only the names matter: libudev reads a listing for the subsystems to descend into and + // then opens /sys/class/hidraw itself, which is the bind above. + val listing = listingDir(sessionRoot) + for (name in listOf("sys/bus", "sys/class", "sys/devices", "class/hidraw", "bus")) File(listing, name).mkdirs() // The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode). link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)