From f1fb3026e9351698d7a00f795940723394c5444e Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 2 Oct 2026 10:45:25 -0400 Subject: [PATCH 1/4] Fix Deck controller discovery across sysfs layouts --- app/src/main/cpp/fakeinput_steam.cpp | 147 ++++++++++++++---- .../launcher/session/SessionService.kt | 1 + .../launcher/session/SteamDeckPad.kt | 27 +++- 3 files changed, 136 insertions(+), 39 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 128cb8d97..9e68801fa 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1488,8 +1488,8 @@ __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. +// FAKE_DECK_SYSFS_LISTING: the merged /sys/class listing and denied-listing stand-ins for +// /sys and /sys/bus (SteamDeckPad.listingDir). __attribute__((visibility("hidden"))) static const char *deck_listing_dir() { static const char *dir = getenv("FAKE_DECK_SYSFS_LISTING"); return dir && *dir ? dir : nullptr; @@ -1497,46 +1497,58 @@ __attribute__((visibility("hidden"))) static const char *deck_listing_dir() { __attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) { if (!path) return false; - if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true; + auto under = [path](const char *root) { + if (!root || !*root) return false; + size_t n = strlen(root); + return !strncmp(path, root, n) && (path[n] == '\0' || path[n] == '/'); + }; + if (under("/sys/devices/droiddeck") || under("/sys/class/hidraw")) return true; const char *listing = deck_listing_dir(); - return listing && !strncmp(path, listing, strlen(listing)); + // /proc/self/fd can name the canonical Android backing file instead of the guest bind. + // Without recognising that alias, sd-device rejects the tree as outside sysfs. + static const char *root = getenv("FAKE_DECK_SYSFS_ROOT"); + return under(listing) || under(root); } -__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { +__attribute__((visibility("hidden"))) static std::string fd_path(int fd) { char link[64], path[PATH_MAX]; snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); ssize_t length = readlink(link, path, sizeof(path) - 1); - if (length <= 0) return false; - path[length] = '\0'; - return is_deck_sysfs_path(path); + return length > 0 ? std::string(path, length) : std::string(); } -// Once per process: proof the client's enumeration reached the Deck's sysfs at all. No line here -// and no open of /dev/hidraw16 means the client never looked; a line here and no open means it -// looked and turned the device down. -__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *how, int result) { +// Once per process: which guest or backing path sd-device checked and the filesystem it found. +__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *path, int result, long type) { static std::atomic noted{false}; if (!noted.exchange(true)) - Logger::log("deck: sysfs checked (%s%s)\n", how, result == 0 ? "" : ", failed"); + Logger::log("deck: sysfs checked %s (%s, type 0x%lx%s)\n", path, + result == 0 ? "ok" : "failed", type, result == 0 ? " -> sysfs" : ""); } EXPORT int fstatfs(int fd, struct statfs *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstatfs")); int result = real(fd, buf); - if (fake_deck_enabled() && is_deck_sysfs_fd(fd)) { - note_deck_sysfs("fstatfs", result); - if (result == 0) buf->f_type = SYSFS_FS_MAGIC; + int saved_errno = errno; + if (fake_deck_enabled()) { + std::string path = fd_path(fd); + if (is_deck_sysfs_path(path.c_str())) { + note_deck_sysfs(path.c_str(), result, result == 0 ? buf->f_type : 0); + if (result == 0) buf->f_type = SYSFS_FS_MAGIC; + } } + errno = saved_errno; return result; } EXPORT int statfs(const char *path, struct statfs *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "statfs")); int result = real(path, buf); + int saved_errno = errno; if (fake_deck_enabled() && is_deck_sysfs_path(path)) { - note_deck_sysfs(path, result); + note_deck_sysfs(path, result, result == 0 ? buf->f_type : 0); if (result == 0) buf->f_type = SYSFS_FS_MAGIC; } + errno = saved_errno; return result; } @@ -1698,31 +1710,68 @@ __attribute__((visibility("hidden"))) static std::string note_sys_failure(int di 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) { +// A bind does not add its name to a real parent's listing. Always show Steam the merged class +// listing, even on a readable kernel whose real /sys/class lacks hidraw. /sys and /sys/bus keep +// their real listings unless SELinux denies them. Other processes and Xbox mode keep real sysfs. +__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path, bool refused = false) { const char *listing = deck_listing_dir(); - if (!path || !listing || errno != EACCES || !fake_deck_enabled() || !process_is_steam_client()) return {}; + if (!path || !listing || !fake_deck_enabled() || !process_is_steam_client()) return {}; std::string p(path); while (p.size() > 1 && p.back() == '/') p.pop_back(); + // Also handle openat on the /sys stand-in's directory fd. + if (p.compare(0, strlen(listing) + 5, std::string(listing) + "/sys/") == 0) + p = "/sys/" + p.substr(strlen(listing) + 5); int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1; - if (which < 0) return {}; + if (which < 0 || (which != 1 && (!refused || errno != EACCES))) 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()); + Logger::log("sysfs: %s listed from the Deck's %s\n", p.c_str(), + which == 1 ? "merged classes" : "denied-listing stand-in"); return std::string(listing) + "/" + names[which]; } +__attribute__((visibility("hidden"))) static std::string path_at(int dirfd, const char *path) { + if (!path) return {}; + if (*path == '/') return path; + int saved_errno = errno; + std::string dir; + if (dirfd == AT_FDCWD) { + char cwd[PATH_MAX]; + if (getcwd(cwd, sizeof(cwd))) dir = cwd; + } else { + dir = fd_path(dirfd); + } + std::string resolved = dir.empty() ? std::string() : dir + "/" + path; + errno = saved_errno; + return resolved; +} + +// The listing supplies names only. A relative open on its fd must descend into real sysfs (or +// the bound hidraw class), not into an empty placeholder. In particular sd-device's chase uses +// openat(O_PATH) on each component; symlinks back to /sys/class would loop through this listing. +__attribute__((visibility("hidden"))) static std::string deck_listing_child(int dirfd, const char *path) { + const char *listing = deck_listing_dir(); + if (!listing || !fake_deck_enabled() || !process_is_steam_client()) return {}; + std::string p = path_at(dirfd, path); + for (const char *name : {"class", "sys"}) { + std::string prefix = std::string(listing) + "/" + name + "/"; + if (p.compare(0, prefix.size(), prefix) == 0) + return std::string(!strcmp(name, "class") ? "/sys/class/" : "/sys/") + p.substr(prefix.size()); + } + return {}; +} + EXPORT DIR *opendir(const char *name) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); + std::string child = deck_listing_child(AT_FDCWD, name); + if (!child.empty()) name = child.c_str(); + std::string listing = deck_listing_for(name); + if (!listing.empty()) return real(listing.c_str()); DIR *dir = real(name); if (!dir) { note_sys_failure(AT_FDCWD, name); - std::string listing = deck_listing_for(name); + listing = deck_listing_for(name, true); if (!listing.empty()) dir = real(listing.c_str()); } return dir; @@ -1787,13 +1836,21 @@ EXPORT int open(const char *pathname, int flags, ...) { } } + std::string listing; + std::string child; + if ((flags & O_ACCMODE) == O_RDONLY && !(flags & (O_CREAT | O_TRUNC))) { + child = deck_listing_child(AT_FDCWD, pathname); + if (!child.empty()) pathname = child.c_str(); + listing = deck_listing_for(pathname); + } + if (!listing.empty()) pathname = listing.c_str(); if (hasMode) 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); + std::string fallback = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str(), true); + if (!fallback.empty()) fd = my_open(fallback.c_str(), flags); } if (fake_path) @@ -1861,13 +1918,29 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { } } + std::string listing; + std::string child; + if (fake_deck_enabled() && process_is_steam_client() && + (flags & O_ACCMODE) == O_RDONLY && !(flags & (O_CREAT | O_TRUNC))) { + child = deck_listing_child(dirfd, pathname); + if (!child.empty()) { + dirfd = AT_FDCWD; + pathname = child.c_str(); + } + listing = deck_listing_for(path_at(dirfd, pathname).c_str()); + } + if (!listing.empty()) { + dirfd = AT_FDCWD; + pathname = listing.c_str(); + } if (hasMode) 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); + note_sys_failure(dirfd, pathname); + std::string fallback = deck_listing_for(path_at(dirfd, pathname).c_str(), true); + if (!fallback.empty()) fd = my_openat(AT_FDCWD, fallback.c_str(), flags); } if (fake_path) @@ -2033,7 +2106,17 @@ EXPORT int scandir(const char *dirp, struct dirent ***namelist, } } - return my_scandir(dirp, namelist, filter, compar); + std::string child = deck_listing_child(AT_FDCWD, dirp); + if (!child.empty()) dirp = child.c_str(); + std::string listing = deck_listing_for(dirp); + if (!listing.empty()) return my_scandir(listing.c_str(), namelist, filter, compar); + int result = my_scandir(dirp, namelist, filter, compar); + if (result < 0) { + note_sys_failure(AT_FDCWD, dirp); + listing = deck_listing_for(dirp, true); + if (!listing.empty()) result = my_scandir(listing.c_str(), namelist, filter, compar); + } + return result; } EXPORT int inotify_add_watch(int fd, const char *pathname, uint32_t mask) { 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 cd1ef2e9d..1ced49ec7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -672,6 +672,7 @@ class SessionService : Service() { if (SessionState.deckPad) { guest.add("FAKE_EVDEV_DECK=1") guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path) + guest.add("FAKE_DECK_SYSFS_ROOT=" + SteamDeckPad.sysfsRoot(fakeInputDir.parentFile!!.parentFile!!)) } } else if (SessionState.mode == MODE_STEAM) { // Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity - 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 c4e3b06d8..d95b27170 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -24,10 +24,11 @@ import java.io.File * 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; - * - [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). + * - [listingDir] - a merged `/sys/class` listing, with the host's class names plus `hidraw`. + * A proot bind is reachable by path but does not add its name to the parent's directory listing; + * a kernel without a native `hidraw` class therefore hid our bound class from libudev. The + * libfakeinput routes relative opens back to the real guest paths. `/sys` and `/sys/bus` also have minimal + * stand-ins for a client whose SELinux policy refuses those listings. * * Symlinks are absolute guest paths; proot resolves them inside the session. */ @@ -54,6 +55,9 @@ object SteamDeckPad { /** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */ fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") + /** The canonical backing path, also used when a guest fd resolves to its Android path. */ + fun sysfsRoot(sessionRoot: File) = File(sessionRoot, "sys/deck").canonicalPath + /** 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") @@ -97,10 +101,19 @@ 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. + // Snapshot the host's class names at session start. Relative opens on the listing's + // fd are routed back to /sys/class/, retaining their real attributes. Use dirs: + // a link back to /sys/class/ loops when sd-device chases it through this listing. val listing = listingDir(sessionRoot) - for (name in listOf("sys/bus", "sys/class", "sys/devices", "class/hidraw", "bus")) File(listing, name).mkdirs() + for (name in listOf("sys/bus", "sys/class", "sys/devices", "class", "bus")) File(listing, name).mkdirs() + val nativeClasses = File("/sys/class").list() + for (name in (nativeClasses?.toList().orEmpty() + "hidraw").distinct()) { + File(listing, "class/$name").mkdirs() + } + Log.i(TAG, "deck pad: native /sys/class " + when (nativeClasses) { + null -> "not listable; merged listing contains hidraw only" + else -> "${nativeClasses.size} classes, hidraw ${if ("hidraw" in nativeClasses) "present" else "absent"}; merged listing includes hidraw" + }) // The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode). link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW) From c6c74fb67df5e070fbfed5fc9db8bed41c56cbfd Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 2 Oct 2026 11:59:36 -0400 Subject: [PATCH 2/4] Preserve native HID discovery and trace Deck enumeration --- app/src/main/cpp/fakeinput_steam.cpp | 263 +++++++++++++++++- .../launcher/session/SteamDeckPad.kt | 7 +- 2 files changed, 254 insertions(+), 16 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 9e68801fa..8598ffeb4 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1304,11 +1304,14 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() { } // What the client starts with, so a pad log says whether it was offered a Deck at all. +__attribute__((visibility("hidden"))) static void probe_deck_discovery(); __attribute__((constructor)) static void log_client_start() { config(); - if (process_is_steam_client()) + if (process_is_steam_client()) { Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off", get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str()); + if (fake_deck_enabled() && Logger::log_enabled) probe_deck_discovery(); + } } __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { @@ -1517,6 +1520,74 @@ __attribute__((visibility("hidden"))) static std::string fd_path(int fd) { return length > 0 ? std::string(path, length) : std::string(); } +// A read-only view of the bound tree, independent of whether Steam ever tries to enumerate it. +// Use the next libc functions so these lines cannot be mistaken for Steam's discovery calls. +__attribute__((visibility("hidden"))) static void probe_deck_discovery() { + auto real_opendir = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); + auto real_readlink = reinterpret_cast(dlsym(RTLD_NEXT, "readlink")); + auto real_realpath = reinterpret_cast(dlsym(RTLD_NEXT, "realpath")); + auto real_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + auto real_fstatfs = reinterpret_cast(dlsym(RTLD_NEXT, "fstatfs")); + int saved_errno = errno; + DIR *dir = real_opendir("/sys/class/hidraw"); + if (dir) { + unsigned count = 0; + bool deck = false; + unsigned deck_type = 0; + errno = 0; + while (auto *entry = readdir(dir)) { + if (entry->d_name[0] == '.') continue; + ++count; + if (!strcmp(entry->d_name, "hidraw16")) { + deck = true; + deck_type = entry->d_type; + } + } + int error = errno; + closedir(dir); + Logger::log("deck probe: /sys/class/hidraw entries=%u hidraw16=%s d_type=%u (%s)\n", count, + deck ? "present" : "absent", deck_type, error ? strerror(error) : "ok"); + } else { + Logger::log("deck probe: /sys/class/hidraw failed (%s)\n", strerror(errno)); + } + const char *node = "/sys/class/hidraw/hidraw16"; + char resolved[PATH_MAX]; + ssize_t length = real_readlink(node, resolved, sizeof(resolved) - 1); + if (length >= 0) { + resolved[length] = '\0'; + Logger::log("deck probe: %s link=%s\n", node, resolved); + } else { + Logger::log("deck probe: %s readlink failed (%s)\n", node, strerror(errno)); + } + if (real_realpath(node, resolved)) + Logger::log("deck probe: %s resolved=%s\n", node, resolved); + else + Logger::log("deck probe: %s realpath failed (%s)\n", node, strerror(errno)); + for (const char *path : {"/sys/class/hidraw/hidraw16/uevent", "/sys/class/hidraw/hidraw16/device/uevent", + "/sys/class/hidraw/hidraw16/dev", "/run/udev/data/c240:16"}) { + int fd = real_open(path, O_RDONLY | O_CLOEXEC); + if (fd >= 0 && !strcmp(path, "/sys/class/hidraw/hidraw16/uevent")) { + struct statfs fs; + int result = real_fstatfs(fd, &fs); + int error = errno; + Logger::log("deck probe: uevent fd path=%s raw_fs_type=0x%lx (%s)\n", fd_path(fd).c_str(), + result == 0 ? static_cast(fs.f_type) : 0, result == 0 ? "ok" : strerror(error)); + } + char value[384]; + ssize_t n = fd >= 0 ? syscall(SYS_read, fd, value, sizeof(value) - 1) : -1; + int error = errno; + if (fd >= 0) syscall(SYS_close, fd); + if (n >= 0) { + value[n] = '\0'; + for (ssize_t i = 0; i < n; ++i) if (value[i] == '\n') value[i] = ';'; + Logger::log("deck probe: %s value=%s\n", path, value); + } else { + Logger::log("deck probe: %s %s failed (%s)\n", path, fd < 0 ? "open" : "read", strerror(error)); + } + } + errno = saved_errno; +} + // Once per process: which guest or backing path sd-device checked and the filesystem it found. __attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *path, int result, long type) { static std::atomic noted{false}; @@ -1710,9 +1781,34 @@ __attribute__((visibility("hidden"))) static std::string note_sys_failure(int di return path; } -// A bind does not add its name to a real parent's listing. Always show Steam the merged class -// listing, even on a readable kernel whose real /sys/class lacks hidraw. /sys and /sys/bus keep -// their real listings unless SELinux denies them. Other processes and Xbox mode keep real sysfs. +// Check the guest's native class names once. Readable listings containing hidraw stay native; +// a missing name needs the merge because a bind alone does not add it to the parent's listing. +__attribute__((visibility("hidden"))) static bool deck_class_missing_hidraw() { + static const bool missing = [] { + auto real = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); + int saved_errno = errno; + DIR *dir = real("/sys/class"); + bool found = false; + bool readable = false; + int error = errno; + if (dir) { + errno = 0; + while (auto *entry = readdir(dir)) found |= !strcmp(entry->d_name, "hidraw"); + error = errno; + readable = error == 0; + closedir(dir); + } + Logger::log("deck discovery: native /sys/class %s (%s)\n", + readable ? (found ? "contains hidraw; retained" : "missing hidraw; merge needed") : "not listable", + readable ? "ok" : strerror(error)); + errno = saved_errno; + return readable && !found; + }(); + return missing; +} + +// /sys, /sys/bus and a native /sys/class containing hidraw use the old EACCES-only fallback. +// Other processes and Xbox mode keep real sysfs. __attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path, bool refused = false) { const char *listing = deck_listing_dir(); if (!path || !listing || !fake_deck_enabled() || !process_is_steam_client()) return {}; @@ -1722,7 +1818,8 @@ __attribute__((visibility("hidden"))) static std::string deck_listing_for(const if (p.compare(0, strlen(listing) + 5, std::string(listing) + "/sys/") == 0) p = "/sys/" + p.substr(strlen(listing) + 5); int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1; - if (which < 0 || (which != 1 && (!refused || errno != EACCES))) return {}; + bool denied = refused && errno == EACCES; + if (which < 0 || (!denied && (which != 1 || !deck_class_missing_hidraw()))) return {}; static const char *const names[] = {"sys", "class", "bus"}; static std::atomic logged{0}; if (!(logged.fetch_or(1 << which) & (1 << which))) @@ -1747,13 +1844,141 @@ __attribute__((visibility("hidden"))) static std::string path_at(int dirfd, cons return resolved; } +// Log only the discovery roots and our HID tree, including ENOENT. Ordinary sysfs failures keep +// their existing log below; unrelated attribute traffic cannot consume this separate budget. +__attribute__((visibility("hidden"))) static bool deck_discovery_path(const char *path) { + if (!path) return false; + auto under = [path](const std::string &root) { + return !root.empty() && !strncmp(path, root.c_str(), root.size()) && + (path[root.size()] == '\0' || path[root.size()] == '/'); + }; + if (!strcmp(path, "/sys") || !strcmp(path, "/sys/") || + !strcmp(path, "/sys/class") || !strcmp(path, "/sys/class/") || + !strcmp(path, "/sys/bus") || !strcmp(path, "/sys/bus/") || + !strcmp(path, "/sys/dev/char/240:16") || !strcmp(path, "/run/udev/data/c240:16") || + under("/sys/class/hidraw") || under("/sys/devices/droiddeck")) return true; + const char *root = getenv("FAKE_DECK_SYSFS_ROOT"); + if (root && (under(std::string(root) + "/devices") || under(std::string(root) + "/class-hidraw"))) return true; + const char *listing = deck_listing_dir(); + return listing && (!strcmp(path, (std::string(listing) + "/class").c_str()) || + under(std::string(listing) + "/class/hidraw")); +} + +__attribute__((visibility("hidden"))) static bool deck_discovery_enabled() { + return Logger::log_enabled && fake_deck_enabled() && process_is_steam_client(); +} + +__attribute__((visibility("hidden"))) static void note_deck_discovery( + const char *operation, const char *path, long result, int flags = 0, + const char *detail = nullptr, bool descriptor = false) { + int saved_errno = errno; + if (Logger::log_enabled && fake_deck_enabled() && deck_discovery_path(path) && process_is_steam_client()) { + // Descriptor numbers change on each scan. Deduplicate by operation/path/flags/outcome instead. + int error = result < 0 ? saved_errno : 0; + std::string key = std::string(operation) + ":" + path + ":" + std::to_string(flags) + ":" + std::to_string(error); + static auto *seen = new std::vector(); + bool first = false; + bool limit = false; + { + // This mutex is already reset in forked Steam children. + std::lock_guard guard(controller_mutex()); + if (seen->size() < 96 && std::find(seen->begin(), seen->end(), key) == seen->end()) { + seen->push_back(key); + first = true; + limit = seen->size() == 96; + } + } + if (first) { + std::string extra = descriptor && result >= 0 ? fd_path(static_cast(result)) : std::string(); + if (detail && *detail) extra += (extra.empty() ? "" : " ") + std::string(detail); + Logger::log("deck discovery: %s %s flags=0x%x result=%ld (%s)%s%s\n", operation, path, flags, result, + error ? strerror(error) : "ok", extra.empty() ? "" : " target=", extra.c_str()); + if (limit) Logger::log("deck discovery: trace limit reached (96 unique operations)\n"); + } + } + errno = saved_errno; +} + +// These libc entry points can bypass the public open() hook. Keep their behavior unchanged and +// expose the symlink chase, statx/fstatat validation and stdio attribute opens that sd-device uses. +EXPORT ssize_t readlink(const char *path, char *buf, size_t size) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "readlink")); + ssize_t result = real(path, buf, size); + int saved_errno = errno; + if (deck_discovery_path(path)) { + std::string target = result >= 0 ? std::string(buf, result) : std::string(); + note_deck_discovery("readlink", path, result, 0, target.c_str()); + } + errno = saved_errno; + return result; +} + +EXPORT ssize_t readlinkat(int dirfd, const char *path, char *buf, size_t size) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "readlinkat")); + ssize_t result = real(dirfd, path, buf, size); + int saved_errno = errno; + if (deck_discovery_enabled()) { + std::string resolved = path_at(dirfd, path); + std::string target = result >= 0 ? std::string(buf, result) : std::string(); + note_deck_discovery("readlinkat", resolved.c_str(), result, 0, target.c_str()); + } + errno = saved_errno; + return result; +} + +EXPORT FILE *fopen(const char *path, const char *mode) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fopen")); + FILE *file = real(path, mode); + int saved_errno = errno; + note_deck_discovery("fopen", path, file ? fileno(file) : -1, 0, mode, true); + errno = saved_errno; + return file; +} + +#ifdef __GLIBC__ +EXPORT FILE *fopen64(const char *path, const char *mode) { + return fopen(path, mode); +} + +EXPORT int fstatat(int dirfd, const char *path, struct stat *buf, int flags) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstatat")); + int result = real(dirfd, path, buf, flags); + int saved_errno = errno; + if (deck_discovery_enabled()) { + std::string resolved = path_at(dirfd, path); + char type[32]; + snprintf(type, sizeof(type), "mode=0%o", result == 0 ? buf->st_mode : 0); + note_deck_discovery("fstatat", resolved.c_str(), result, flags, type); + } + errno = saved_errno; + return result; +} + +EXPORT int fstatat64(int dirfd, const char *path, struct stat64 *buf, int flags) { + return fstatat(dirfd, path, reinterpret_cast(buf), flags); +} + +EXPORT int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *buf) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "statx")); + int result = real(dirfd, path, flags, mask, buf); + int saved_errno = errno; + if (deck_discovery_enabled()) { + std::string resolved = path_at(dirfd, path); + char type[48]; + snprintf(type, sizeof(type), "mode=0%o mask=0x%x", result == 0 ? buf->stx_mode : 0, mask); + note_deck_discovery("statx", resolved.c_str(), result, flags, type); + } + errno = saved_errno; + return result; +} +#endif + // The listing supplies names only. A relative open on its fd must descend into real sysfs (or // the bound hidraw class), not into an empty placeholder. In particular sd-device's chase uses // openat(O_PATH) on each component; symlinks back to /sys/class would loop through this listing. -__attribute__((visibility("hidden"))) static std::string deck_listing_child(int dirfd, const char *path) { +__attribute__((visibility("hidden"))) static std::string deck_listing_child_path(const std::string &p) { const char *listing = deck_listing_dir(); if (!listing || !fake_deck_enabled() || !process_is_steam_client()) return {}; - std::string p = path_at(dirfd, path); for (const char *name : {"class", "sys"}) { std::string prefix = std::string(listing) + "/" + name + "/"; if (p.compare(0, prefix.size(), prefix) == 0) @@ -1762,18 +1987,23 @@ __attribute__((visibility("hidden"))) static std::string deck_listing_child(int return {}; } +__attribute__((visibility("hidden"))) static std::string deck_listing_child(int dirfd, const char *path) { + if (!deck_listing_dir() || !fake_deck_enabled() || !process_is_steam_client()) return {}; + return deck_listing_child_path(path_at(dirfd, path)); +} + EXPORT DIR *opendir(const char *name) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); std::string child = deck_listing_child(AT_FDCWD, name); if (!child.empty()) name = child.c_str(); std::string listing = deck_listing_for(name); - if (!listing.empty()) return real(listing.c_str()); - DIR *dir = real(name); + DIR *dir = real(listing.empty() ? name : listing.c_str()); if (!dir) { note_sys_failure(AT_FDCWD, name); listing = deck_listing_for(name, true); if (!listing.empty()) dir = real(listing.c_str()); } + note_deck_discovery("opendir", name, dir ? dirfd(dir) : -1, 0, nullptr, true); return dir; } @@ -1838,6 +2068,7 @@ EXPORT int open(const char *pathname, int flags, ...) { std::string listing; std::string child; + const char *requested = pathname; if ((flags & O_ACCMODE) == O_RDONLY && !(flags & (O_CREAT | O_TRUNC))) { child = deck_listing_child(AT_FDCWD, pathname); if (!child.empty()) pathname = child.c_str(); @@ -1853,6 +2084,8 @@ EXPORT int open(const char *pathname, int flags, ...) { if (!fallback.empty()) fd = my_open(fallback.c_str(), flags); } + note_deck_discovery("open", requested, fd, flags, nullptr, true); + if (fake_path) free(fake_path); @@ -1920,14 +2153,16 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { std::string listing; std::string child; + std::string requested; if (fake_deck_enabled() && process_is_steam_client() && (flags & O_ACCMODE) == O_RDONLY && !(flags & (O_CREAT | O_TRUNC))) { - child = deck_listing_child(dirfd, pathname); + requested = path_at(dirfd, pathname); + child = deck_listing_child_path(requested); if (!child.empty()) { dirfd = AT_FDCWD; pathname = child.c_str(); } - listing = deck_listing_for(path_at(dirfd, pathname).c_str()); + listing = deck_listing_for(child.empty() ? requested.c_str() : child.c_str()); } if (!listing.empty()) { dirfd = AT_FDCWD; @@ -1943,6 +2178,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { if (!fallback.empty()) fd = my_openat(AT_FDCWD, fallback.c_str(), flags); } + if (!requested.empty()) note_deck_discovery("openat", requested.c_str(), fd, flags, nullptr, true); + if (fake_path) free(fake_path); @@ -2109,13 +2346,13 @@ EXPORT int scandir(const char *dirp, struct dirent ***namelist, std::string child = deck_listing_child(AT_FDCWD, dirp); if (!child.empty()) dirp = child.c_str(); std::string listing = deck_listing_for(dirp); - if (!listing.empty()) return my_scandir(listing.c_str(), namelist, filter, compar); - int result = my_scandir(dirp, namelist, filter, compar); + int result = my_scandir(listing.empty() ? dirp : listing.c_str(), namelist, filter, compar); if (result < 0) { note_sys_failure(AT_FDCWD, dirp); listing = deck_listing_for(dirp, true); if (!listing.empty()) result = my_scandir(listing.c_str(), namelist, filter, compar); } + note_deck_discovery("scandir", dirp, result); return result; } 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 d95b27170..4175b7944 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -27,7 +27,8 @@ import java.io.File * - [listingDir] - a merged `/sys/class` listing, with the host's class names plus `hidraw`. * A proot bind is reachable by path but does not add its name to the parent's directory listing; * a kernel without a native `hidraw` class therefore hid our bound class from libudev. The - * libfakeinput routes relative opens back to the real guest paths. `/sys` and `/sys/bus` also have minimal + * libfakeinput uses this only when `hidraw` is missing or the native listing is denied, and routes + * relative opens back to the real guest paths. `/sys` and `/sys/bus` also have minimal * stand-ins for a client whose SELinux policy refuses those listings. * * Symlinks are absolute guest paths; proot resolves them inside the session. @@ -111,8 +112,8 @@ object SteamDeckPad { File(listing, "class/$name").mkdirs() } Log.i(TAG, "deck pad: native /sys/class " + when (nativeClasses) { - null -> "not listable; merged listing contains hidraw only" - else -> "${nativeClasses.size} classes, hidraw ${if ("hidraw" in nativeClasses) "present" else "absent"}; merged listing includes hidraw" + null -> "not listable; hidraw listing fallback prepared" + else -> "${nativeClasses.size} classes, hidraw ${if ("hidraw" in nativeClasses) "present; native listing retained" else "absent; merged listing prepared"}" }) // The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode). link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW) From 7cc3e318892ec5c670d9ee5de73a84110d316be6 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 2 Oct 2026 19:08:01 -0400 Subject: [PATCH 3/4] Recover Deck discovery mount IDs on older kernels --- app/src/main/cpp/fakeinput_steam.cpp | 70 +++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 8598ffeb4..95f074a7d 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1958,14 +1958,80 @@ EXPORT int fstatat64(int dirfd, const char *path, struct stat64 *buf, int flags) return fstatat(dirfd, path, reinterpret_cast(buf), flags); } +// sd-device's path chase now requires a mount ID, which statx only supplies from Linux 5.8. +// Older handheld kernels still publish the real ID in fdinfo. Scope this compatibility path to +// libudev/libsystemd in the Deck-reading Steam client, and never invent a unique mount ID. +__attribute__((visibility("hidden"))) static bool deck_device_library(void *caller) { + Dl_info info; + if (!dladdr(caller, &info) || !info.dli_fname) return false; + const char *name = strrchr(info.dli_fname, '/'); + name = name ? name + 1 : info.dli_fname; + return !strncmp(name, "libudev.so", 10) || !strncmp(name, "libsystemd.so", 13); +} + +__attribute__((visibility("hidden"))) static bool deck_mount_id_from_fdinfo( + int dirfd, const char *path, int flags, struct statx *buf) { + static auto real_openat = reinterpret_cast(dlsym(RTLD_NEXT, "openat")); + static auto real_fstat = reinterpret_cast(dlsym(RTLD_NEXT, "fstat")); + int target; + if (!path || !*path) { + if (!(flags & AT_EMPTY_PATH)) return false; + target = dirfd == AT_FDCWD ? real_openat(AT_FDCWD, ".", O_PATH | O_CLOEXEC) : + syscall(SYS_fcntl, dirfd, F_DUPFD_CLOEXEC, 0); + } else { + target = real_openat(dirfd, path, O_PATH | O_CLOEXEC | + ((flags & AT_SYMLINK_NOFOLLOW) ? O_NOFOLLOW : 0)); + } + if (target < 0) return false; + struct stat st; + // Pin the inode and check it still matches the successful statx before adding information. + bool same = real_fstat(target, &st) == 0 && (buf->stx_mask & STATX_INO) && + st.st_ino == buf->stx_ino && major(st.st_dev) == buf->stx_dev_major && + minor(st.st_dev) == buf->stx_dev_minor; + char name[64]; + snprintf(name, sizeof(name), "/proc/self/fdinfo/%d", target); + int info = same ? real_openat(AT_FDCWD, name, O_RDONLY | O_CLOEXEC) : -1; + char value[512]; + ssize_t length = info >= 0 ? syscall(SYS_read, info, value, sizeof(value) - 1) : -1; + if (info >= 0) syscall(SYS_close, info); + syscall(SYS_close, target); + if (length < 0) return false; + value[length] = '\0'; + for (const char *line = value; line && *line;) { + unsigned long long id; + if (sscanf(line, "mnt_id: %llu", &id) == 1 && id > 0) { + buf->stx_mnt_id = id; + buf->stx_mask |= STATX_MNT_ID; + return true; + } + const char *next = strchr(line, '\n'); + line = next ? next + 1 : nullptr; + } + return false; +} + EXPORT int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "statx")); int result = real(dirfd, path, flags, mask, buf); int saved_errno = errno; + constexpr unsigned unique_mount_id = 0x4000; // STATX_MNT_ID_UNIQUE, newer than build headers. + if (result == 0 && (mask & STATX_MNT_ID) && + !(buf->stx_mask & (STATX_MNT_ID | unique_mount_id)) && fake_deck_enabled() && + process_is_steam_client() && deck_device_library(__builtin_return_address(0))) { + bool recovered = deck_mount_id_from_fdinfo(dirfd, path, flags, buf); + static std::atomic noted_success{false}, noted_failure{false}; + if (!(recovered ? noted_success : noted_failure).exchange(true)) + Logger::log("deck discovery: statx mount id %s for %s (returned mask=0x%x id=%llu)\n", + recovered ? "recovered from fdinfo" : "unavailable in fdinfo", + path && *path ? path : "(fd)", buf->stx_mask, + static_cast(buf->stx_mnt_id)); + } if (deck_discovery_enabled()) { std::string resolved = path_at(dirfd, path); - char type[48]; - snprintf(type, sizeof(type), "mode=0%o mask=0x%x", result == 0 ? buf->stx_mode : 0, mask); + char type[96]; + snprintf(type, sizeof(type), "mode=0%o request=0x%x returned=0x%x mnt_id=%llu", + result == 0 ? buf->stx_mode : 0, mask, result == 0 ? buf->stx_mask : 0, + result == 0 ? static_cast(buf->stx_mnt_id) : 0); note_deck_discovery("statx", resolved.c_str(), result, flags, type); } errno = saved_errno; From 9645032d2dd1f06c9ef8318f5274b449b709be21 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 2 Oct 2026 20:35:28 -0400 Subject: [PATCH 4/4] Trim Deck discovery to mount-ID compatibility fix --- app/src/main/cpp/fakeinput_steam.cpp | 380 ++---------------- .../launcher/session/SessionService.kt | 1 - .../launcher/session/SteamDeckPad.kt | 28 +- 3 files changed, 41 insertions(+), 368 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 95f074a7d..3e0f18f6d 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1304,14 +1304,11 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() { } // What the client starts with, so a pad log says whether it was offered a Deck at all. -__attribute__((visibility("hidden"))) static void probe_deck_discovery(); __attribute__((constructor)) static void log_client_start() { config(); - if (process_is_steam_client()) { + if (process_is_steam_client()) Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off", get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str()); - if (fake_deck_enabled() && Logger::log_enabled) probe_deck_discovery(); - } } __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { @@ -1491,8 +1488,8 @@ __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: the merged /sys/class listing and denied-listing stand-ins for -// /sys and /sys/bus (SteamDeckPad.listingDir). +// 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; @@ -1500,126 +1497,46 @@ __attribute__((visibility("hidden"))) static const char *deck_listing_dir() { __attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) { if (!path) return false; - auto under = [path](const char *root) { - if (!root || !*root) return false; - size_t n = strlen(root); - return !strncmp(path, root, n) && (path[n] == '\0' || path[n] == '/'); - }; - if (under("/sys/devices/droiddeck") || under("/sys/class/hidraw")) return true; + if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true; const char *listing = deck_listing_dir(); - // /proc/self/fd can name the canonical Android backing file instead of the guest bind. - // Without recognising that alias, sd-device rejects the tree as outside sysfs. - static const char *root = getenv("FAKE_DECK_SYSFS_ROOT"); - return under(listing) || under(root); + return listing && !strncmp(path, listing, strlen(listing)); } -__attribute__((visibility("hidden"))) static std::string fd_path(int fd) { +__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { char link[64], path[PATH_MAX]; snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); ssize_t length = readlink(link, path, sizeof(path) - 1); - return length > 0 ? std::string(path, length) : std::string(); + if (length <= 0) return false; + path[length] = '\0'; + return is_deck_sysfs_path(path); } -// A read-only view of the bound tree, independent of whether Steam ever tries to enumerate it. -// Use the next libc functions so these lines cannot be mistaken for Steam's discovery calls. -__attribute__((visibility("hidden"))) static void probe_deck_discovery() { - auto real_opendir = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); - auto real_readlink = reinterpret_cast(dlsym(RTLD_NEXT, "readlink")); - auto real_realpath = reinterpret_cast(dlsym(RTLD_NEXT, "realpath")); - auto real_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); - auto real_fstatfs = reinterpret_cast(dlsym(RTLD_NEXT, "fstatfs")); - int saved_errno = errno; - DIR *dir = real_opendir("/sys/class/hidraw"); - if (dir) { - unsigned count = 0; - bool deck = false; - unsigned deck_type = 0; - errno = 0; - while (auto *entry = readdir(dir)) { - if (entry->d_name[0] == '.') continue; - ++count; - if (!strcmp(entry->d_name, "hidraw16")) { - deck = true; - deck_type = entry->d_type; - } - } - int error = errno; - closedir(dir); - Logger::log("deck probe: /sys/class/hidraw entries=%u hidraw16=%s d_type=%u (%s)\n", count, - deck ? "present" : "absent", deck_type, error ? strerror(error) : "ok"); - } else { - Logger::log("deck probe: /sys/class/hidraw failed (%s)\n", strerror(errno)); - } - const char *node = "/sys/class/hidraw/hidraw16"; - char resolved[PATH_MAX]; - ssize_t length = real_readlink(node, resolved, sizeof(resolved) - 1); - if (length >= 0) { - resolved[length] = '\0'; - Logger::log("deck probe: %s link=%s\n", node, resolved); - } else { - Logger::log("deck probe: %s readlink failed (%s)\n", node, strerror(errno)); - } - if (real_realpath(node, resolved)) - Logger::log("deck probe: %s resolved=%s\n", node, resolved); - else - Logger::log("deck probe: %s realpath failed (%s)\n", node, strerror(errno)); - for (const char *path : {"/sys/class/hidraw/hidraw16/uevent", "/sys/class/hidraw/hidraw16/device/uevent", - "/sys/class/hidraw/hidraw16/dev", "/run/udev/data/c240:16"}) { - int fd = real_open(path, O_RDONLY | O_CLOEXEC); - if (fd >= 0 && !strcmp(path, "/sys/class/hidraw/hidraw16/uevent")) { - struct statfs fs; - int result = real_fstatfs(fd, &fs); - int error = errno; - Logger::log("deck probe: uevent fd path=%s raw_fs_type=0x%lx (%s)\n", fd_path(fd).c_str(), - result == 0 ? static_cast(fs.f_type) : 0, result == 0 ? "ok" : strerror(error)); - } - char value[384]; - ssize_t n = fd >= 0 ? syscall(SYS_read, fd, value, sizeof(value) - 1) : -1; - int error = errno; - if (fd >= 0) syscall(SYS_close, fd); - if (n >= 0) { - value[n] = '\0'; - for (ssize_t i = 0; i < n; ++i) if (value[i] == '\n') value[i] = ';'; - Logger::log("deck probe: %s value=%s\n", path, value); - } else { - Logger::log("deck probe: %s %s failed (%s)\n", path, fd < 0 ? "open" : "read", strerror(error)); - } - } - errno = saved_errno; -} - -// Once per process: which guest or backing path sd-device checked and the filesystem it found. -__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *path, int result, long type) { +// Once per process: proof the client's enumeration reached the Deck's sysfs at all. No line here +// and no open of /dev/hidraw16 means the client never looked; a line here and no open means it +// looked and turned the device down. +__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *how, int result) { static std::atomic noted{false}; if (!noted.exchange(true)) - Logger::log("deck: sysfs checked %s (%s, type 0x%lx%s)\n", path, - result == 0 ? "ok" : "failed", type, result == 0 ? " -> sysfs" : ""); + Logger::log("deck: sysfs checked (%s%s)\n", how, result == 0 ? "" : ", failed"); } EXPORT int fstatfs(int fd, struct statfs *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstatfs")); int result = real(fd, buf); - int saved_errno = errno; - if (fake_deck_enabled()) { - std::string path = fd_path(fd); - if (is_deck_sysfs_path(path.c_str())) { - note_deck_sysfs(path.c_str(), result, result == 0 ? buf->f_type : 0); - if (result == 0) buf->f_type = SYSFS_FS_MAGIC; - } + if (fake_deck_enabled() && is_deck_sysfs_fd(fd)) { + note_deck_sysfs("fstatfs", result); + if (result == 0) buf->f_type = SYSFS_FS_MAGIC; } - errno = saved_errno; return result; } EXPORT int statfs(const char *path, struct statfs *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "statfs")); int result = real(path, buf); - int saved_errno = errno; if (fake_deck_enabled() && is_deck_sysfs_path(path)) { - note_deck_sysfs(path, result, result == 0 ? buf->f_type : 0); + note_deck_sysfs(path, result); if (result == 0) buf->f_type = SYSFS_FS_MAGIC; } - errno = saved_errno; return result; } @@ -1781,183 +1698,26 @@ __attribute__((visibility("hidden"))) static std::string note_sys_failure(int di return path; } -// Check the guest's native class names once. Readable listings containing hidraw stay native; -// a missing name needs the merge because a bind alone does not add it to the parent's listing. -__attribute__((visibility("hidden"))) static bool deck_class_missing_hidraw() { - static const bool missing = [] { - auto real = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); - int saved_errno = errno; - DIR *dir = real("/sys/class"); - bool found = false; - bool readable = false; - int error = errno; - if (dir) { - errno = 0; - while (auto *entry = readdir(dir)) found |= !strcmp(entry->d_name, "hidraw"); - error = errno; - readable = error == 0; - closedir(dir); - } - Logger::log("deck discovery: native /sys/class %s (%s)\n", - readable ? (found ? "contains hidraw; retained" : "missing hidraw; merge needed") : "not listable", - readable ? "ok" : strerror(error)); - errno = saved_errno; - return readable && !found; - }(); - return missing; -} - -// /sys, /sys/bus and a native /sys/class containing hidraw use the old EACCES-only fallback. -// Other processes and Xbox mode keep real sysfs. -__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path, bool refused = false) { +// 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 || !fake_deck_enabled() || !process_is_steam_client()) return {}; + 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(); - // Also handle openat on the /sys stand-in's directory fd. - if (p.compare(0, strlen(listing) + 5, std::string(listing) + "/sys/") == 0) - p = "/sys/" + p.substr(strlen(listing) + 5); int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1; - bool denied = refused && errno == EACCES; - if (which < 0 || (!denied && (which != 1 || !deck_class_missing_hidraw()))) return {}; + 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 %s\n", p.c_str(), - which == 1 ? "merged classes" : "denied-listing stand-in"); + Logger::log("sysfs: %s listed from the Deck's stand-in\n", p.c_str()); return std::string(listing) + "/" + names[which]; } -__attribute__((visibility("hidden"))) static std::string path_at(int dirfd, const char *path) { - if (!path) return {}; - if (*path == '/') return path; - int saved_errno = errno; - std::string dir; - if (dirfd == AT_FDCWD) { - char cwd[PATH_MAX]; - if (getcwd(cwd, sizeof(cwd))) dir = cwd; - } else { - dir = fd_path(dirfd); - } - std::string resolved = dir.empty() ? std::string() : dir + "/" + path; - errno = saved_errno; - return resolved; -} - -// Log only the discovery roots and our HID tree, including ENOENT. Ordinary sysfs failures keep -// their existing log below; unrelated attribute traffic cannot consume this separate budget. -__attribute__((visibility("hidden"))) static bool deck_discovery_path(const char *path) { - if (!path) return false; - auto under = [path](const std::string &root) { - return !root.empty() && !strncmp(path, root.c_str(), root.size()) && - (path[root.size()] == '\0' || path[root.size()] == '/'); - }; - if (!strcmp(path, "/sys") || !strcmp(path, "/sys/") || - !strcmp(path, "/sys/class") || !strcmp(path, "/sys/class/") || - !strcmp(path, "/sys/bus") || !strcmp(path, "/sys/bus/") || - !strcmp(path, "/sys/dev/char/240:16") || !strcmp(path, "/run/udev/data/c240:16") || - under("/sys/class/hidraw") || under("/sys/devices/droiddeck")) return true; - const char *root = getenv("FAKE_DECK_SYSFS_ROOT"); - if (root && (under(std::string(root) + "/devices") || under(std::string(root) + "/class-hidraw"))) return true; - const char *listing = deck_listing_dir(); - return listing && (!strcmp(path, (std::string(listing) + "/class").c_str()) || - under(std::string(listing) + "/class/hidraw")); -} - -__attribute__((visibility("hidden"))) static bool deck_discovery_enabled() { - return Logger::log_enabled && fake_deck_enabled() && process_is_steam_client(); -} - -__attribute__((visibility("hidden"))) static void note_deck_discovery( - const char *operation, const char *path, long result, int flags = 0, - const char *detail = nullptr, bool descriptor = false) { - int saved_errno = errno; - if (Logger::log_enabled && fake_deck_enabled() && deck_discovery_path(path) && process_is_steam_client()) { - // Descriptor numbers change on each scan. Deduplicate by operation/path/flags/outcome instead. - int error = result < 0 ? saved_errno : 0; - std::string key = std::string(operation) + ":" + path + ":" + std::to_string(flags) + ":" + std::to_string(error); - static auto *seen = new std::vector(); - bool first = false; - bool limit = false; - { - // This mutex is already reset in forked Steam children. - std::lock_guard guard(controller_mutex()); - if (seen->size() < 96 && std::find(seen->begin(), seen->end(), key) == seen->end()) { - seen->push_back(key); - first = true; - limit = seen->size() == 96; - } - } - if (first) { - std::string extra = descriptor && result >= 0 ? fd_path(static_cast(result)) : std::string(); - if (detail && *detail) extra += (extra.empty() ? "" : " ") + std::string(detail); - Logger::log("deck discovery: %s %s flags=0x%x result=%ld (%s)%s%s\n", operation, path, flags, result, - error ? strerror(error) : "ok", extra.empty() ? "" : " target=", extra.c_str()); - if (limit) Logger::log("deck discovery: trace limit reached (96 unique operations)\n"); - } - } - errno = saved_errno; -} - -// These libc entry points can bypass the public open() hook. Keep their behavior unchanged and -// expose the symlink chase, statx/fstatat validation and stdio attribute opens that sd-device uses. -EXPORT ssize_t readlink(const char *path, char *buf, size_t size) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "readlink")); - ssize_t result = real(path, buf, size); - int saved_errno = errno; - if (deck_discovery_path(path)) { - std::string target = result >= 0 ? std::string(buf, result) : std::string(); - note_deck_discovery("readlink", path, result, 0, target.c_str()); - } - errno = saved_errno; - return result; -} - -EXPORT ssize_t readlinkat(int dirfd, const char *path, char *buf, size_t size) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "readlinkat")); - ssize_t result = real(dirfd, path, buf, size); - int saved_errno = errno; - if (deck_discovery_enabled()) { - std::string resolved = path_at(dirfd, path); - std::string target = result >= 0 ? std::string(buf, result) : std::string(); - note_deck_discovery("readlinkat", resolved.c_str(), result, 0, target.c_str()); - } - errno = saved_errno; - return result; -} - -EXPORT FILE *fopen(const char *path, const char *mode) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fopen")); - FILE *file = real(path, mode); - int saved_errno = errno; - note_deck_discovery("fopen", path, file ? fileno(file) : -1, 0, mode, true); - errno = saved_errno; - return file; -} - #ifdef __GLIBC__ -EXPORT FILE *fopen64(const char *path, const char *mode) { - return fopen(path, mode); -} - -EXPORT int fstatat(int dirfd, const char *path, struct stat *buf, int flags) { - static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstatat")); - int result = real(dirfd, path, buf, flags); - int saved_errno = errno; - if (deck_discovery_enabled()) { - std::string resolved = path_at(dirfd, path); - char type[32]; - snprintf(type, sizeof(type), "mode=0%o", result == 0 ? buf->st_mode : 0); - note_deck_discovery("fstatat", resolved.c_str(), result, flags, type); - } - errno = saved_errno; - return result; -} - -EXPORT int fstatat64(int dirfd, const char *path, struct stat64 *buf, int flags) { - return fstatat(dirfd, path, reinterpret_cast(buf), flags); -} - // sd-device's path chase now requires a mount ID, which statx only supplies from Linux 5.8. // Older handheld kernels still publish the real ID in fdinfo. Scope this compatibility path to // libudev/libsystemd in the Deck-reading Steam client, and never invent a unique mount ID. @@ -2026,50 +1786,19 @@ EXPORT int statx(int dirfd, const char *path, int flags, unsigned mask, struct s path && *path ? path : "(fd)", buf->stx_mask, static_cast(buf->stx_mnt_id)); } - if (deck_discovery_enabled()) { - std::string resolved = path_at(dirfd, path); - char type[96]; - snprintf(type, sizeof(type), "mode=0%o request=0x%x returned=0x%x mnt_id=%llu", - result == 0 ? buf->stx_mode : 0, mask, result == 0 ? buf->stx_mask : 0, - result == 0 ? static_cast(buf->stx_mnt_id) : 0); - note_deck_discovery("statx", resolved.c_str(), result, flags, type); - } errno = saved_errno; return result; } #endif -// The listing supplies names only. A relative open on its fd must descend into real sysfs (or -// the bound hidraw class), not into an empty placeholder. In particular sd-device's chase uses -// openat(O_PATH) on each component; symlinks back to /sys/class would loop through this listing. -__attribute__((visibility("hidden"))) static std::string deck_listing_child_path(const std::string &p) { - const char *listing = deck_listing_dir(); - if (!listing || !fake_deck_enabled() || !process_is_steam_client()) return {}; - for (const char *name : {"class", "sys"}) { - std::string prefix = std::string(listing) + "/" + name + "/"; - if (p.compare(0, prefix.size(), prefix) == 0) - return std::string(!strcmp(name, "class") ? "/sys/class/" : "/sys/") + p.substr(prefix.size()); - } - return {}; -} - -__attribute__((visibility("hidden"))) static std::string deck_listing_child(int dirfd, const char *path) { - if (!deck_listing_dir() || !fake_deck_enabled() || !process_is_steam_client()) return {}; - return deck_listing_child_path(path_at(dirfd, path)); -} - EXPORT DIR *opendir(const char *name) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "opendir")); - std::string child = deck_listing_child(AT_FDCWD, name); - if (!child.empty()) name = child.c_str(); - std::string listing = deck_listing_for(name); - DIR *dir = real(listing.empty() ? name : listing.c_str()); + DIR *dir = real(name); if (!dir) { note_sys_failure(AT_FDCWD, name); - listing = deck_listing_for(name, true); + std::string listing = deck_listing_for(name); if (!listing.empty()) dir = real(listing.c_str()); } - note_deck_discovery("opendir", name, dir ? dirfd(dir) : -1, 0, nullptr, true); return dir; } @@ -2132,26 +1861,15 @@ EXPORT int open(const char *pathname, int flags, ...) { } } - std::string listing; - std::string child; - const char *requested = pathname; - if ((flags & O_ACCMODE) == O_RDONLY && !(flags & (O_CREAT | O_TRUNC))) { - child = deck_listing_child(AT_FDCWD, pathname); - if (!child.empty()) pathname = child.c_str(); - listing = deck_listing_for(pathname); - } - if (!listing.empty()) pathname = listing.c_str(); if (hasMode) fd = my_open(pathname, flags, mode); else fd = my_open(pathname, flags); if (fd < 0) { - std::string fallback = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str(), true); - if (!fallback.empty()) fd = my_open(fallback.c_str(), flags); + std::string listing = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str()); + if (!listing.empty()) fd = my_open(listing.c_str(), flags); } - note_deck_discovery("open", requested, fd, flags, nullptr, true); - if (fake_path) free(fake_path); @@ -2217,35 +1935,15 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { } } - std::string listing; - std::string child; - std::string requested; - if (fake_deck_enabled() && process_is_steam_client() && - (flags & O_ACCMODE) == O_RDONLY && !(flags & (O_CREAT | O_TRUNC))) { - requested = path_at(dirfd, pathname); - child = deck_listing_child_path(requested); - if (!child.empty()) { - dirfd = AT_FDCWD; - pathname = child.c_str(); - } - listing = deck_listing_for(child.empty() ? requested.c_str() : child.c_str()); - } - if (!listing.empty()) { - dirfd = AT_FDCWD; - pathname = listing.c_str(); - } if (hasMode) fd = my_openat(dirfd, pathname, flags, mode); else fd = my_openat(dirfd, pathname, flags); if (fd < 0) { - note_sys_failure(dirfd, pathname); - std::string fallback = deck_listing_for(path_at(dirfd, pathname).c_str(), true); - if (!fallback.empty()) fd = my_openat(AT_FDCWD, fallback.c_str(), flags); + 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 (!requested.empty()) note_deck_discovery("openat", requested.c_str(), fd, flags, nullptr, true); - if (fake_path) free(fake_path); @@ -2409,17 +2107,7 @@ EXPORT int scandir(const char *dirp, struct dirent ***namelist, } } - std::string child = deck_listing_child(AT_FDCWD, dirp); - if (!child.empty()) dirp = child.c_str(); - std::string listing = deck_listing_for(dirp); - int result = my_scandir(listing.empty() ? dirp : listing.c_str(), namelist, filter, compar); - if (result < 0) { - note_sys_failure(AT_FDCWD, dirp); - listing = deck_listing_for(dirp, true); - if (!listing.empty()) result = my_scandir(listing.c_str(), namelist, filter, compar); - } - note_deck_discovery("scandir", dirp, result); - return result; + return my_scandir(dirp, namelist, filter, compar); } EXPORT int inotify_add_watch(int fd, const char *pathname, uint32_t mask) { 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 1ced49ec7..cd1ef2e9d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -672,7 +672,6 @@ class SessionService : Service() { if (SessionState.deckPad) { guest.add("FAKE_EVDEV_DECK=1") guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path) - guest.add("FAKE_DECK_SYSFS_ROOT=" + SteamDeckPad.sysfsRoot(fakeInputDir.parentFile!!.parentFile!!)) } } else if (SessionState.mode == MODE_STEAM) { // Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity - 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 4175b7944..c4e3b06d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -24,12 +24,10 @@ import java.io.File * 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; - * - [listingDir] - a merged `/sys/class` listing, with the host's class names plus `hidraw`. - * A proot bind is reachable by path but does not add its name to the parent's directory listing; - * a kernel without a native `hidraw` class therefore hid our bound class from libudev. The - * libfakeinput uses this only when `hidraw` is missing or the native listing is denied, and routes - * relative opens back to the real guest paths. `/sys` and `/sys/bus` also have minimal - * stand-ins for a client whose SELinux policy refuses those listings. + * - [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. */ @@ -56,9 +54,6 @@ object SteamDeckPad { /** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */ fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") - /** The canonical backing path, also used when a guest fd resolves to its Android path. */ - fun sysfsRoot(sessionRoot: File) = File(sessionRoot, "sys/deck").canonicalPath - /** 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") @@ -102,19 +97,10 @@ object SteamDeckPad { hidrawClass.mkdirs() link(File(hidrawClass, NODE), HIDRAW) - // Snapshot the host's class names at session start. Relative opens on the listing's - // fd are routed back to /sys/class/, retaining their real attributes. Use dirs: - // a link back to /sys/class/ loops when sd-device chases it through this listing. + // 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", "bus")) File(listing, name).mkdirs() - val nativeClasses = File("/sys/class").list() - for (name in (nativeClasses?.toList().orEmpty() + "hidraw").distinct()) { - File(listing, "class/$name").mkdirs() - } - Log.i(TAG, "deck pad: native /sys/class " + when (nativeClasses) { - null -> "not listable; hidraw listing fallback prepared" - else -> "${nativeClasses.size} classes, hidraw ${if ("hidraw" in nativeClasses) "present; native listing retained" else "absent; merged listing prepared"}" - }) + 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)