diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1e50a70..ff200371 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -377,6 +377,7 @@ jobs: PR: ${{ github.event.pull_request.number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} BUILD_OUTCOME: ${{ steps.build.outcome }} + SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} run: | set -uo pipefail MARK="" @@ -392,7 +393,11 @@ jobs: echo echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." echo - echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + if [ "$SAME_REPO" = true ]; then + echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that." + else + echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + fi echo echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." ;; diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index f78700ee..f2b6b994 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,5 +1,5 @@ name: PR Release Publish -run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} with the release key" +run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} with the release key" on: workflow_dispatch: @@ -11,6 +11,11 @@ on: pull_request_target: types: [labeled] branches: [main] + # A PR from a branch of this repo is signed on its own after each green build: only people who + # can push here can change it. A fork's PR waits for a maintainer's publish-test label. + workflow_run: + workflows: [Build APK] + types: [completed] permissions: actions: read @@ -19,15 +24,20 @@ permissions: pull-requests: write concurrency: - group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} + group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} cancel-in-progress: false jobs: authorize: if: >- (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || - (github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') + (github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') || + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.pull_requests[0] != null) runs-on: ubuntu-24.04 + timeout-minutes: 45 outputs: pr_number: ${{ steps.gate.outputs.pr_number }} run_id: ${{ steps.gate.outputs.run_id }} @@ -35,6 +45,8 @@ jobs: base_sha: ${{ steps.gate.outputs.base_sha }} version_code: ${{ steps.gate.outputs.version_code }} label: ${{ steps.gate.outputs.label }} + skip: ${{ steps.gate.outputs.skip }} + automatic: ${{ steps.gate.outputs.automatic }} variants: ${{ steps.variants.outputs.list }} steps: - uses: actions/checkout@v4 @@ -50,10 +62,24 @@ jobs: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} REQUESTER: ${{ github.actor }} - PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} + PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} + BUILT_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number '$PR_NUMBER'"; exit 1; } + # The automatic path stands down quietly, never failing: a draft, a PR labelled + # no-test-build, or a build that a later push has already overtaken. + skip() { echo "::notice::Not publishing PR #$PR_NUMBER: $1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } + if [ "$EVENT_NAME" = workflow_run ]; then + echo "automatic=true" >> "$GITHUB_OUTPUT" + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + [ "$(jq -r .state <<<"$pr")" = open ] || skip "it is not open" + [ "$(jq -r .draft <<<"$pr")" = false ] || skip "it is a draft" + [ "$(jq -r .base.ref <<<"$pr")" = main ] || skip "it does not target main" + [ "$(jq -r .head.repo.full_name <<<"$pr")" = "$GITHUB_REPOSITORY" ] || skip "it comes from a fork" + jq -e 'any(.labels[]; .name == "no-test-build")' <<<"$pr" >/dev/null && skip "it is labelled no-test-build" + [ "$(jq -r .head.sha <<<"$pr")" = "$BUILT_SHA" ] || skip "a newer push is building" + fi if [ "$EVENT_NAME" = pull_request_target ]; then permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$REQUESTER/permission" --jq .permission) case "$permission" in @@ -66,13 +92,28 @@ jobs: head=$(jq -r '.head.sha' <<<"$pr") base=$(jq -r '.base.sha' <<<"$pr") - run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \ - --event pull_request --commit "$head" \ - --json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 | - jq -r --arg head "$head" '[.[] | select(.headSha == $head)] | - sort_by([.createdAt, .attempt]) | last | - select(.status == "completed" and .conclusion == "success") | .databaseId // empty') - [[ -n "$run_id" ]] || { echo '::error::The latest PR build is not green'; exit 1; } + # The label is often added while the PR's build is still running, or just after a push + # before it has started: wait for the latest build of the head commit to finish (a PR + # build takes about 4 minutes) rather than fail and need the label added again. + run_id= + for attempt in $(seq 1 60); do + latest=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \ + --event pull_request --commit "$head" \ + --json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 | + jq -c --arg head "$head" '[.[] | select(.headSha == $head)] | + sort_by([.createdAt, .attempt]) | last // empty') + if [[ -n "$latest" && $(jq -r .status <<<"$latest") == completed ]]; then + conclusion=$(jq -r .conclusion <<<"$latest") + [[ "$conclusion" == success ]] || { echo "::error::The latest PR build is not green ($conclusion)"; exit 1; } + run_id=$(jq -r .databaseId <<<"$latest") + break + fi + status=not-started + [[ -z "$latest" ]] || status=$(jq -r .status <<<"$latest") + echo "Waiting for the PR build of ${head:0:7} to finish ($status)" + sleep 30 + done + [[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 30 minutes'; exit 1; } run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id") jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" ' .event == "pull_request" and .head_sha == $head and .conclusion == "success" and @@ -122,6 +163,7 @@ jobs: label: ${{ needs.authorize.outputs.label }} artifact: droiddeck-pr-signed secrets: inherit + if: ${{ needs.authorize.outputs.skip != 'true' }} publish: needs: [authorize, sign] @@ -191,9 +233,12 @@ jobs: \`\`\` $(cat signed/SHA256SUMS.txt) \`\`\`" + first=false if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease + first=true fi + echo "first=$first" >> "$GITHUB_OUTPUT" gh release upload "$tag" signed/* -R "$repo" --clobber gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease # Drop the previous build's files: anything this build did not just upload. @@ -211,6 +256,15 @@ jobs: echo "release=$release" >> "$GITHUB_OUTPUT" echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT" + - name: Whether the PR's branch is in this repo + id: same-repo + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + run: | + head_repo=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.repo.full_name) + echo "same=$([ "$head_repo" = "$GITHUB_REPOSITORY" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - name: Clear the publish request label if: ${{ github.event_name == 'pull_request_target' && steps.publish.outcome == 'success' }} env: @@ -226,10 +280,16 @@ jobs: HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} RELEASE: ${{ steps.publish.outputs.release }} APK: ${{ steps.publish.outputs.apk }} + SAME_REPO: ${{ steps.same-repo.outputs.same }} run: | set -euo pipefail short=${HEAD_SHA:0:7} mark='' + if [ "$SAME_REPO" = true ]; then + NEXT='Each green build of a new push is signed and published here on its own. Label the PR `no-test-build` to stop that.' + else + NEXT='Pushes to this PR do not re-sign automatically. After a new green build, a maintainer can add the `publish-test` label again.' + fi body=$(cat </dev/null fi + # Asked for (the label, a manual run): always announced. Signed on its own after a push: + # only a PR's first test build is, and later ones update its release quietly. - name: Post to Discord - if: ${{ always() && steps.publish.outcome == 'success' }} + if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }} env: WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }} GH_TOKEN: ${{ github.token }} diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 41d6cfe7..128cb8d9 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -8,6 +8,7 @@ * a game become more of these nodes instead of vanishing. */ #include +#include #include #include #include @@ -38,6 +39,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -295,21 +299,52 @@ static void controller_fork_child() { namespace Logger { int log_enabled; +// FAKE_EVDEV_LOG_FILE: the session's pad.log, shared by every process the library is loaded in +// (the client, each game), so a pad report has the guest side in one file of the session folder. +// Opened with the raw syscall: open() is this library's own hook. +static int log_fd = -1; +static char log_name[17]; void init() { log_enabled = getenv("FAKE_EVDEV_LOG") && atoi(getenv("FAKE_EVDEV_LOG")); + const char *path = getenv("FAKE_EVDEV_LOG_FILE"); + if (log_enabled && path && *path) + log_fd = static_cast(syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644)); + if (prctl(PR_GET_NAME, log_name) != 0) strcpy(log_name, "?"); } void log(const char *message, ...) { if (!log_enabled) return; + if (log_fd < 0) { + va_list args; + va_start(args, message); + vfprintf(stderr, message, args); + va_end(args); + std::cerr.flush(); + return; + } + // One write per line: O_APPEND keeps lines from different processes whole. + char line[512]; + struct timespec now; + clock_gettime(CLOCK_REALTIME, &now); + struct tm local; + localtime_r(&now.tv_sec, &local); + int n = snprintf(line, sizeof(line), "%02d:%02d:%02d.%03ld %d %s: ", local.tm_hour, local.tm_min, + local.tm_sec, now.tv_nsec / 1000000, getpid(), log_name); va_list args; va_start(args, message); - vfprintf(stderr, message, args); + if (n > 0 && n < static_cast(sizeof(line))) + n += vsnprintf(line + n, sizeof(line) - n, message, args); va_end(args); - - std::cerr.flush(); + if (n <= 0) return; + if (n >= static_cast(sizeof(line))) { + n = sizeof(line) - 1; + line[n - 1] = '\n'; + } + ssize_t ignored = write(log_fd, line, n); + (void)ignored; } } // namespace Logger @@ -649,31 +684,43 @@ open_fake_input_ring(const char *event, int flags) { // does not exist here, so the game was left with no controller at all. With // FAKE_EVDEV_STEAM_VIRTUAL set the pad carries the virtual gamepad's identity for every process // but the client's own, which must keep seeing the pad as the physical one it reads. -__attribute__((visibility("hidden"))) static bool presents_steam_virtual() { +// Whether this process is the Steam client itself. Asked once: a process never becomes another. +__attribute__((visibility("hidden"))) static bool is_steam_client() { static int known; if (known == 0) { - const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL"); char exe[PATH_MAX]; - ssize_t length = enabled && atoi(enabled) ? readlink("/proc/self/exe", exe, sizeof(exe) - 1) : -1; + ssize_t length = readlink("/proc/self/exe", exe, sizeof(exe) - 1); bool client = false; if (length > 0) { exe[length] = '\0'; const char *name = strrchr(exe, '/'); client = !strncmp(name ? name + 1 : exe, "steam", 5); } - known = length > 0 && !client ? 1 : -1; + known = client ? 1 : -1; } return known == 1; } -// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine places a -// gamepad's axes by their position among the ones it advertises, so the triggers have to sit +__attribute__((visibility("hidden"))) static bool presents_steam_virtual() { + static int known; + if (known == 0) { + const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL"); + known = enabled && atoi(enabled) && !is_steam_client() ? 1 : -1; + } + return known == 1; +} + +// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine and SDL place +// a gamepad's axes by their position among the ones it advertises, so the triggers have to sit // where that pad has them: as ABS_GAS and ABS_BRAKE they follow the sticks, the right stick -// lands on the left trigger, and a released right trigger reads as the right stick held up. -// A pad made through /dev/uinput is that pad for real, whoever reads it. +// lands on the left trigger, and a released right trigger reads as the right stick held up. That +// is every reader but the Steam client, which maps the pad from what it finds (controller.txt) +// and has always been shown ABS_GAS/ABS_BRAKE: a game Steam Input is off for reads this pad +// itself (the Xbox 360 setting), and so do the desktop's programs. A pad made through +// /dev/uinput is that pad for real, whoever reads it. __attribute__((visibility("hidden"))) static bool presents_xbox_triggers(const FakeController &fake) { - return fake.uinput || presents_steam_virtual(); + return fake.uinput || !is_steam_client(); } __attribute__((visibility("hidden"))) static uint16_t @@ -1256,6 +1303,14 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() { return known == 1; } +// What the client starts with, so a pad log says whether it was offered a Deck at all. +__attribute__((constructor)) static void log_client_start() { + config(); + 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()); +} + __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH); } @@ -1364,12 +1419,16 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); if (!process_is_steam_client()) { + // Only the client reads the Deck; a game sees Steam Input's virtual pad instead. + Logger::log("deck: %s refused: not the Steam client\n", DECK_HIDRAW_PATH); errno = ENOENT; return -1; } std::string ring_path = get_ring_path_for_slot(0); int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDONLY | O_CLOEXEC); if (ring_fd < 0) { + Logger::log("deck: %s refused: ring %s not openable (%s)\n", DECK_HIDRAW_PATH, + ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno)); errno = ENODEV; return -1; } @@ -1382,12 +1441,15 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, ring_fd, 0); syscall(SYS_close, ring_fd); if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast(mapping))) { + Logger::log("deck: %s refused: ring %s %s\n", DECK_HIDRAW_PATH, ring_path.c_str(), + mapping == MAP_FAILED ? "could not be mapped" : "has no valid header"); if (mapping != MAP_FAILED) munmap(mapping, mapping_size); errno = ENODEV; return -1; } int pair[2]; if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) { + Logger::log("deck: %s refused: socketpair failed (%s)\n", DECK_HIDRAW_PATH, strerror(errno)); munmap(mapping, mapping_size); return -1; } @@ -1426,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) { @@ -1439,17 +1511,32 @@ __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { return is_deck_sysfs_path(path); } +// 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)\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); - if (result == 0 && fake_deck_enabled() && is_deck_sysfs_fd(fd)) 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; + } 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); - if (result == 0 && fake_deck_enabled() && is_deck_sysfs_path(path)) buf->f_type = SYSFS_FS_MAGIC; + if (fake_deck_enabled() && is_deck_sysfs_path(path)) { + note_deck_sysfs(path, result); + if (result == 0) buf->f_type = SYSFS_FS_MAGIC; + } return result; } @@ -1471,6 +1558,15 @@ __attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request } // Feature reports carry the report id (0) first, then the Valve message: type, length, payload. +// The client repeats some feature reports (settings, rumble) for as long as it has the pad; each +// is logged the first time and then at 2, 4, 8... so the log shows what it asked and how often +// without a line per report. +__attribute__((visibility("hidden"))) static void log_deck_feature(const char *what, uint8_t feature) { + static std::atomic counts[2][256]; + unsigned n = ++counts[what[0] == 's'][feature]; + if ((n & (n - 1)) == 0) Logger::log("deck: feature 0x%02x %s (x%u)\n", feature, what, n); +} + __attribute__((visibility("hidden"))) static int deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { size_t size = _IOC_SIZE(op); @@ -1484,7 +1580,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8; send_vibration(left, right, left || right ? 1000 : 0, 0); } - Logger::log("deck: feature 0x%02x set\n", buf[1]); + log_deck_feature("set", buf[1]); return static_cast(size); } uint8_t reply[DECK_REPORT_BYTES + 1] = {}; @@ -1514,7 +1610,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { reply[1] = deck.pending_feature; break; } - Logger::log("deck: feature 0x%02x read\n", deck.pending_feature); + log_deck_feature("read", deck.pending_feature); size_t length = std::min(size, sizeof(reply)); memcpy(buf, reply, length); return static_cast(length); @@ -1558,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; @@ -1621,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); @@ -1691,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/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 9dcd051d..03b0f773 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -476,6 +476,16 @@ class MainActivity : ComponentActivity() { } }, "share-logs").start() }, + onClearLogs = { + Thread({ + val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0) + ui.post { + android.widget.Toast.makeText(this, + if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.", + android.widget.Toast.LENGTH_SHORT).show() + } + }, "clear-logs").start() + }, onOffline = { OfflineMode.setEnabled(this, !OfflineMode.enabled(this)) offline = OfflineMode.enabled(this) @@ -625,6 +635,7 @@ class MainActivity : ComponentActivity() { if (!SessionState.running) Thread({ SessionArtifacts.finishAbandoned(this) SessionArtifacts.scrubOlder(this) + SessionArtifacts.prune(this) }, "finish-abandoned").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 49160f70..0f63ca6e 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -98,7 +98,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var padMotion: com.droiddeck.launcher.input.PadMotion? = null /** Between onResume and onPause. */ private var resumed = false - private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } } + private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } } + + /** + * A Deck's controller is there from the moment it boots, and the client looks for it once, as + * it starts. The ring behind the Deck (libfakeinput's /dev/hidraw16) opens on the pad's first + * input otherwise - on a phone with nothing attached, the first touch of the on-screen + * controls, seconds after the client had looked, found no controller and never set the touch + * mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a + * Deck instead. + */ + private fun openDeckRing(why: String) { + val deck = SessionState.deckPad + val opened = if (deck) padBridge?.start() else null + Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) { + null -> ", left for first input" + true -> ", ring open" + false -> ", ring NOT open" + }) + } /** Motion is read while the session shows and only if the pad is a Deck controller - which * the service can decide after this activity has resumed (SessionState.deckPadListener). */ @@ -219,15 +237,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { * press, and a user without one must not be left with no way to answer Big Picture. */ private val deviceListener = object : InputManager.InputDeviceListener { - override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls() - override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls() + override fun onInputDeviceAdded(deviceId: Int) { + logInputDevice("connected", deviceId) + updateOnScreenControls() + } + override fun onInputDeviceRemoved(deviceId: Int) { + Log.i(TAG, "input device $deviceId disconnected") + updateOnScreenControls() + } override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { - SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM + // A Flatpak app or AppImage is named by its arguments, which the name lookup reads here. + SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() + SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM))) } SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() @@ -269,6 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 } SessionState.deckPadListener = deckPadListener + openDeckRing("activity created") // A player on the pad or the on-screen controls has no use for the mouse arrow; the next // touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment. bridge.setOnPlayerInput { @@ -586,7 +614,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun shareCurrentSessionLogs() { - val folder = SessionPaths.current() + // Once a session has ended its folder has been handed on (SessionPaths.take), and the + // ended screen's Share logs is for that one. + val folder = SessionPaths.current() ?: SessionPaths.lastEnded() if (folder == null || !folder.isDirectory) { Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show() return @@ -1526,6 +1556,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } /** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */ + /** Which controller the session has, so a pad report says what was plugged in and when. */ + private fun logInputDevice(what: String, deviceId: Int) { + val d = InputDevice.getDevice(deviceId) ?: return + if (!PadBridge.isFromController(d)) return + Log.i(TAG, String.format(java.util.Locale.ROOT, "controller %s: \"%s\" (%04x:%04x, id %d, sources 0x%x)", + what, d.name, d.vendorId, d.productId, d.id, d.sources)) + } + private fun updateOnScreenControls() { val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc") .takeIf { it.isFile } diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 745afd92..85eb554d 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -174,7 +174,7 @@ object DeviceReport { h("Device switch files in Download") for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver", - "droiddeck-osc", "droiddeck-no-pad", "droiddeck-pad-log", + "droiddeck-osc", "droiddeck-no-pad", "droiddeck-no-hud", "droiddeck-wlr-renderer")) { val f = File(Environment.getExternalStorageDirectory(), "Download/$name") if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)") diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 9fe6f6ec..a385650e 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -66,6 +66,16 @@ public final class PadBridge { private volatile Runnable onPlayerInput; private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean(); + // What reached the pad over the last stats window, so a report of "the pad does nothing" or + // "touch and the pad don't work together" shows in the app log whether input arrived, from + // which device, and whether it got to the ring. Logged only for a window that had input. + private static final long STATS_WINDOW_MS = 10_000; + private int statButtons, statAxes, statOnScreen, statDropped; + private String statDevice; + private boolean statsScheduled; + private final java.util.Set seenDevices = new java.util.HashSet<>(); + private int lastDeviceId = Integer.MIN_VALUE; + public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); } @@ -134,6 +144,7 @@ public final class PadBridge { /** @return true when the event was a pad button and has been consumed. */ public synchronized boolean onKeyEvent(KeyEvent event) { if (!isFromController(event.getDevice())) return false; + noteDevice(event.getDevice()); boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; switch (event.getKeyCode()) { case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break; @@ -161,6 +172,8 @@ public final class PadBridge { default: return false; } if (pressed) notePlayerInput(); + statButtons++; + scheduleStats(); publish(); return true; } @@ -169,6 +182,9 @@ public final class PadBridge { public synchronized boolean onMotionEvent(MotionEvent event) { if (!isFromController(event.getDevice())) return false; if (event.getAction() != MotionEvent.ACTION_MOVE) return false; + noteDevice(event.getDevice()); + statAxes++; + scheduleStats(); state.leftX = axis(event, MotionEvent.AXIS_X); // Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the // pad reports as "down" is what the client is told. @@ -203,6 +219,8 @@ public final class PadBridge { public synchronized void applyTouch(java.util.function.Consumer mutation) { mutation.accept(state); notePlayerInput(); + statOnScreen++; + scheduleStats(); publish(); } @@ -279,8 +297,39 @@ public final class PadBridge { publish(); } + /** Per event, so the common case - the same pad as last time - is a single compare. */ + private void noteDevice(InputDevice device) { + if (device.getId() == lastDeviceId) return; + lastDeviceId = device.getId(); + statDevice = device.getName(); + if (seenDevices.add(device.getId())) { + Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b", + device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(), + open ? "open" : "not open yet", SessionState.getDeckPad())); + } + } + + private void scheduleStats() { + if (statsScheduled) return; + statsScheduled = true; + mainHandler.postDelayed(this::logStats, STATS_WINDOW_MS); + } + + private synchronized void logStats() { + statsScheduled = false; + Log.i(TAG, "[input] last 10 s: pad " + statButtons + " buttons, " + statAxes + " axis events" + + (statDevice != null ? " (\"" + statDevice + "\")" : "") + + ", on-screen " + statOnScreen + + (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "") + + "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad()); + statButtons = statAxes = statOnScreen = statDropped = 0; + } + private void publish() { - if (!open && !start()) return; + if (!open && !start()) { + statDropped++; + return; + } boolean guide = systemGuidePressed || qamChordActive; boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed); if (!guide && !qam) { 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 d420d577..1f8a7233 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -5,6 +5,7 @@ import android.os.Environment import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.session.OrphanReaper import com.droiddeck.launcher.session.SessionFiles +import com.droiddeck.launcher.session.SessionPaths import com.droiddeck.launcher.session.SessionPrefs import java.io.File @@ -16,13 +17,15 @@ object GuestCommand { /** * Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is * for the package tools, which refuse any uid but 0. With [logName], everything the command - * says also goes to Download/DroidDeck/.log beside the session logs, so a problem can + * says also goes to Download/DroidDeck/tools/.log beside the session logs, so a problem can * be handed over like a session's. */ fun run(context: Context, argv: List, fakeRoot: Boolean = false, logName: String? = null, onLine: (String) -> Unit): Int { val log = logName?.let { - try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null } + try { + File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter() + } catch (e: Exception) { null } } log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}") try { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 5f8726e4..20e04141 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -66,7 +66,7 @@ object SessionArtifacts { fun scrubOlder(context: Context) { val current = SessionPaths.current() val dirs = LinuxRuntime.debugLogDir().listFiles { f -> - f.isDirectory && f.name.startsWith("session-") && f != current && !File(f, SCRUBBED_MARKER).exists() + SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists() } ?: return if (dirs.isEmpty()) return LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) @@ -124,8 +124,8 @@ object SessionArtifacts { fun finishAbandoned(context: Context) { val parent = LinuxRuntime.debugLogDir() val abandoned = parent.listFiles { f -> - f.isDirectory && f.name.startsWith("session-") && !File(f, COMPLETE_MARKER).exists() - }?.sortedBy { it.name } ?: return + SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() + }?.sortedWith(SessionPaths.chronological) ?: return if (abandoned.isEmpty()) return val current = SessionPaths.current() LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) @@ -157,5 +157,37 @@ object SessionArtifacts { } } + /** + * Keeps the newest [SessionPaths.KEEP_SESSIONS] session folders and deletes the rest: a few + * days of testing left hundreds, and the one a report needed was lost among them. Only folders + * that are finished; the session in progress is never touched. Runs at app start after + * [finishAbandoned], on its worker thread. + */ + @Synchronized + fun prune(context: Context) { + val current = SessionPaths.current() + val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() } + val old = finished.dropLast(SessionPaths.KEEP_SESSIONS) + old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) } + if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}") + } + + /** + * Every session folder but the one in progress, and the one-off command logs (tools/, or loose + * beside the folders from before it): the Setup page's Clear logs. Returns how many session + * folders went. + */ + @Synchronized + fun clearAll(context: Context): Int { + val current = SessionPaths.current() + val gone = SessionPaths.sessionFolders(context).filter { it != current } + gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) } + com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR)) + // Before tools/, those logs sat loose beside the session folders. + LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() } + Log.i(TAG, "cleared ${gone.size} session folder(s)") + return gone.size + } + private fun now(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt index cd7955a5..5f8f2bf9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt @@ -11,11 +11,12 @@ object SessionEvents { private const val TAG = "SessionEvents" private val lock = Any() - fun begin(context: Context, mode: String) { + /** [label] names the session's folder for what is being run (SessionPaths.label). */ + fun begin(context: Context, mode: String, label: String? = null) { // A runtime-install failure can leave the folder claimed before the service ever starts. // Keep that folder, but give the next attempt its own session directory. SessionPaths.take() - val dir = SessionPaths.beginOrCurrent(context) + val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null)) synchronized(SessionState) { SessionState.phase = SessionPhase.PREPARING SessionState.mode = mode diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 37b766df..bbe627a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -17,10 +17,7 @@ import java.util.zip.ZipOutputStream */ object SessionLogShare { /** The newest session folder in either place logs are written, or null if there is none. */ - fun latest(context: Context): File? = - listOf(LinuxRuntime.debugLogDir(), File(context.filesDir, "logs")) - .flatMap { dir -> dir.listFiles { f -> f.isDirectory && f.name.startsWith("session-") }?.toList() ?: emptyList() } - .maxByOrNull { it.name } + fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull() /** Builds the zip (blocking). Returns null when there is no session to share. */ fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) } @@ -36,21 +33,40 @@ object SessionLogShare { // session pass yet, and the redactor changes nothing in a line that is already clean. LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) ZipOutputStream(zip.outputStream().buffered()).use { z -> - files.forEach { f -> - z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path)) - if (LogRedactor.isText(f)) { - val w = z.bufferedWriter() - LogRedactor.scrubTo(f, w) - w.flush() - } else { - f.inputStream().use { it.copyTo(z) } - } - z.closeEntry() - } + files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) } + liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) } } return zip } + private fun addEntry(z: ZipOutputStream, name: String, f: File) { + z.putNextEntry(ZipEntry(name)) + if (LogRedactor.isText(f)) { + val w = z.bufferedWriter() + LogRedactor.scrubTo(f, w) + w.flush() + } else { + f.inputStream().use { it.copyTo(z) } + } + z.closeEntry() + } + + /** + * The client's own logs as they stand, for a session shared while it runs. The session script + * copies them into steam/ only as it exits, so a zip made from the drawer had none - and + * controller.txt (which pad the client opened, the touch mode it set) is what a controller or + * touch report needs most. Only for the running session: an older folder would get this + * session's logs. The same files the script copies; nothing holding credentials is in logs/. + */ + private fun liveSteamLogs(context: Context, folder: File): List { + if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList() + val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs") + return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty() + } + + /** The client's content and bootstrap logs grow large over months and say nothing about a session. */ + private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024 + fun shareIntent(context: Context, zip: File): Intent { val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip) val send = Intent(Intent.ACTION_SEND) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index 485bdc45..896d08a1 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -8,18 +8,24 @@ import java.util.Date import java.util.Locale /** - * One folder per session under `Download/DroidDeck/`, holding everything that session recorded: + * One folder per session under `Download/DroidDeck/`, holding everything that session recorded, + * named for the day, that day's session number and what was run, so the one a report is about can + * be picked out without opening any: * * ``` - * Download/DroidDeck/session-20260921-161256/ + * Download/DroidDeck/2026-09-30-03-steam/ * device.txt what this device is, and every setting the session ran with * session.log the guest session: proot, gamescope, the client's stdout * wayland.log the app's compositor * steam.log the Steam client's own log, scrubbed (Steam mode) * steam/ the rest of the client's logs, scrubbed (Steam mode) * desktop.log labwc, the panel and the programs on it (desktop mode) + * Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports * ``` * + * Folders from before this naming (`session-20260930-180642`) are still recognised, shared and + * pruned with the rest. + * * The folder is claimed by whoever starts first - the activity starts the compositor before the * service starts the session - so both write into the same one, and a recreated activity (a * foldable opening mid-session) joins the folder in progress instead of opening another. @@ -27,24 +33,44 @@ import java.util.Locale object SessionPaths { private const val TAG = "SessionPaths" + /** How many session folders are kept; older ones go at the next app start. */ + const val KEEP_SESSIONS = 30 + + /** Beside the session folders: the logs of one-off commands (GuestCommand's logName). */ + const val TOOLS_DIR = "tools" + + /** `2026-09-30-03-steam`: day, that day's session number, what was run. */ + private val NAMED = Regex("""^(\d{4})-(\d{2})-(\d{2})-(\d{2,})-.+$""") + /** `session-20260930-180642`, from before. */ + private val LEGACY = Regex("""^session-(\d{8})-(\d{6})(-\d+)?$""") + @Volatile private var dir: File? = null + @Volatile + private var ended: File? = null + /** * The folder for the session now starting, or the one already in progress. With logs turned * off on the main screen the folder lives in the app's cache instead of Downloads - the * scripts and the compositor still need somewhere to write - and [release] deletes it. + * [label] says what is being run (see [label]); without one it comes from the session's state. */ @Synchronized - fun beginOrCurrent(context: Context): File { + fun beginOrCurrent(context: Context, label: String? = null): File { dir?.let { if (it.isDirectory) return it } - val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context) else File(context.cacheDir, "session-logs") - val baseName = "session-$stamp" - var made = File(parent, baseName) - var suffix = 2 - while (made.exists()) made = File(parent, "$baseName-${suffix++}") + val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + // The day's highest number plus one, not a count: pruning removes the oldest folders, and + // a count would hand out a number already in use. + val last = parent.listFiles()?.mapNotNull { f -> + NAMED.matchEntire(f.name)?.takeIf { f.name.startsWith("$day-") }?.groupValues?.get(4)?.toIntOrNull() + }?.maxOrNull() ?: 0 + val what = slug(label ?: label(SessionState.mode, SessionState.program)) + var number = last + 1 + var made = File(parent, "$day-${"%02d".format(Locale.US, number)}-$what") + while (made.exists()) made = File(parent, "$day-${"%02d".format(Locale.US, ++number)}-$what") if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made") dir = made Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)") @@ -53,6 +79,9 @@ object SessionPaths { fun current(): File? = dir + /** The folder of the session that ended last, while it is still there: what its Share logs sends. */ + fun lastEnded(): File? = ended?.takeIf { it.isDirectory } + /** * The ending session takes its folder with it: the next session (one that replaces this one * in place) then claims a folder of its own instead of sharing, and this one's collector @@ -60,9 +89,10 @@ object SessionPaths { */ @Synchronized fun take(): File? { - val ended = dir + val taken = dir dir = null - return ended + if (taken != null) ended = taken + return taken } fun file(name: String): File? = dir?.let { File(it, name) } @@ -78,4 +108,39 @@ object SessionPaths { com.droiddeck.launcher.core.FileUtils.delete(ended) } } + + /** + * What a session's folder is named for: Steam, the desktop, or the program started from the + * rail by its own name (`2026-10-01-02-retroarch`). + */ + fun label(mode: String, program: String?): String = when (mode) { + SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(program) + ?: program?.substringAfterLast('/') + ?: mode + SessionService.MODE_DESKTOP -> "desktop" + else -> mode + } + + /** Whether [f] is a session's folder, in either naming. */ + fun isSessionFolder(f: File): Boolean = + f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name)) + + /** + * Oldest first: by day, then by when in the day. A folder from before the new naming sorts + * ahead of a new one from the same day, which it always predates. + */ + val chronological: Comparator = compareBy { f -> + NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" } + ?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" } + ?: f.name + } + + /** Every session folder in the places logs are written, oldest first. */ + fun sessionFolders(context: Context): List = + listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs")) + .flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() } + .sortedWith(chronological) + + internal fun slug(text: String): String = + text.lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24).trim('-').ifEmpty { "session" } } 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 35ed4f26..02a5fe06 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -302,7 +302,7 @@ class SessionService : Service() { val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() SessionState.deckPad = false deckBinds = emptyList() - if (controllersOn) addControllerEnvironment(guest, fakeInputDir) + if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir) // The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render // node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the // default is pixman (software, shm), where a Vulkan program cannot draw at all; those run @@ -637,7 +637,7 @@ class SessionService : Service() { } /** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */ - private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File) { + private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File, sessionDir: File) { FakeInputWriter.prepareRingSlots(fakeInputDir, 4) // Virtual pads a client made last session (event16 and up, and their hidden rings) are // not there any more; a client that crashed never took its own down. @@ -663,11 +663,15 @@ class SessionService : Service() { deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList() SessionState.deckPad = deckBinds.isNotEmpty() if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller") + logControllersAtStart() if (uinput) { // /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 @@ -680,9 +684,11 @@ class SessionService : Service() { // The client reads a Deck controller through SDL's HIDAPI; a hint in the environment // outranks the client's own. Nothing else is shown the Deck (libfakeinput). if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0") - if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) { - guest.add("FAKE_EVDEV_LOG=1") - } + // The guest side of the pads (libfakeinput: which pads were opened, the Deck's hidraw and why + // it was refused) in pad.log beside the session's other logs, so it is in every shared zip. + // Setup-time lines only, nothing per input event. + guest.add("FAKE_EVDEV_LOG=1") + guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path) SessionState.fakeInputDir = fakeInputDir } @@ -1211,6 +1217,18 @@ class SessionService : Service() { getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification()) } + /** The pads attached as the session starts; the activity logs the ones that come and go after. */ + private fun logControllersAtStart() { + val pads = ArrayList() + for (id in android.view.InputDevice.getDeviceIds()) { + val d = android.view.InputDevice.getDevice(id) ?: continue + if (!com.droiddeck.launcher.input.PadBridge.isFromController(d)) continue + pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId)) + } + Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) + + "; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller") + } + companion object { private const val TAG = "SessionService" /** proot's tracer: above everything in the guest, under the compositor thread's -8. */ @@ -1240,7 +1258,6 @@ class SessionService : Service() { private const val STEAM_PICKUP_MS = 1500L private const val STEAM_EXIT_MS = 10_000L private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" - private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log" private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput" private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad" /** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */ 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 39747df8..c4e3b06d 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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 74f5caaf..e94d9edb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -153,6 +153,7 @@ class FrontEndActions( val onBrowseFiles: (File) -> Unit = {}, val onLogs: () -> Unit, val onShareLogs: () -> Unit = {}, + val onClearLogs: () -> Unit = {}, val onOffline: () -> Unit, val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 697e04b3..e7ab0098 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -289,6 +289,7 @@ internal fun SetupPanel( } ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() } ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs) + ActionRow("Saved session logs", "The newest ${com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS} are kept", "Clear logs", a.onClearLogs) ToggleRow( host, "offline", "Offline mode", s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first", diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt new file mode 100644 index 00000000..4cc7562f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SessionPathsTest { + @get:Rule val tmp = TemporaryFolder() + + private fun dir(name: String) = tmp.newFolder(name) + + @Test fun bothNamingsAreSessionFolders() { + assertTrue(SessionPaths.isSessionFolder(dir("2026-10-01-03-steam"))) + assertTrue(SessionPaths.isSessionFolder(dir("2026-10-01-12-retroarch"))) + assertTrue(SessionPaths.isSessionFolder(dir("session-20260930-180642"))) + assertTrue(SessionPaths.isSessionFolder(dir("session-20260930-180642-2"))) + } + + @Test fun otherFoldersAndFilesAreNot() { + assertFalse(SessionPaths.isSessionFolder(dir("tools"))) + assertFalse(SessionPaths.isSessionFolder(dir("backup-test"))) + assertFalse(SessionPaths.isSessionFolder(dir("2026-10-01"))) + assertFalse(SessionPaths.isSessionFolder(tmp.newFile("2026-10-01-01-steam.zip"))) + } + + @Test fun foldersSortByDayThenNumber() { + val folders = listOf( + dir("2026-10-01-10-desktop"), dir("session-20260930-180642"), dir("2026-10-01-02-steam"), + dir("session-20261001-063000"), dir("2026-09-30-01-steam"), dir("2026-10-01-01-steam"), + ) + assertEquals( + listOf( + "session-20260930-180642", "2026-09-30-01-steam", "session-20261001-063000", + "2026-10-01-01-steam", "2026-10-01-02-steam", "2026-10-01-10-desktop", + ), + folders.sortedWith(SessionPaths.chronological).map { it.name }, + ) + } + + @Test fun labelsBecomeShortLowercaseWords() { + assertEquals("steam", SessionPaths.slug("steam")) + assertEquals("dolphin-emulator", SessionPaths.slug("Dolphin Emulator")) + assertEquals("org-telegram-desktop", SessionPaths.slug("org.telegram.desktop")) + assertEquals("a-very-long-program-name", SessionPaths.slug("A very long program name that keeps going")) + assertEquals("session", SessionPaths.slug("!!!")) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 01fb6819..89c35ce3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -150,6 +150,11 @@ if [ -z "${BL_INSIDE:-}" ]; then args+=(-r "$BL_REFRESH") fi [ "$mode" = steam ] && args+=(-e) + # Touch passes through to the client as touch until the client says otherwise, as SteamOS's + # gamescope-session starts it (TOUCH_MODE=4). The client sets the mode itself (STEAM_TOUCH_CLICK_MODE) + # only once it has a controller; until then gamescope's own default made a finger a left-click + # mouse, so the Steam interface took taps but would not scroll on a phone with no pad attached. + [ "$mode" = steam ] && args+=(--default-touch-mode 4) # A game that resizes its own window when it loses focus (the Steam menu opening over it) comes # back smaller, and gamescope draws a window that is not flagged fullscreen at its own size in # the top-left corner. Forcing every window to the output's size keeps it filling the screen. @@ -431,6 +436,37 @@ case $mode in prev=$app done ) & + # Evidence for touch reports: the touch mode the client asks gamescope for (STEAM_TOUCH_CLICK_MODE, + # set once it has a controller, per focused app) and which app has focus, logged as they change. + # A finger that clicks but won't scroll, or touch that stops once a pad is used, shows here as + # the mode in force at that moment. 4 is touch passed through, 1 a left-click mouse. + [ -z "${BL_DESKTOP:-}" ] && ( + # What follows "=" in an xprop line, or $2 for a property that is missing or has no value + # yet ("NAME(CARDINAL) =", which read leaves with nothing after the "="). + xprop_value() { + case $1 in *'not found'*|*=) echo "$2"; return ;; esac + local v=${1#*=} + v=${v# } + echo "${v:-$2}" + } + tmode= app= + xprop -spy -root STEAM_TOUCH_CLICK_MODE GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do + case $line in + STEAM_TOUCH_CLICK_MODE*) + v=$(xprop_value "$line" unset) + [ "$v" = "$tmode" ] && continue + tmode=$v + echo "== touch mode: $tmode (focused app ${app:-?}) at $(date +%T)" + ;; + GAMESCOPE_FOCUSED_APP*) + v=$(xprop_value "$line" none) + [ "$v" = "$app" ] && continue + app=$v + echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)" + ;; + esac + done + ) & # The library beat. Both helpers are whole programs under proot (the compat one a python # start and a parse of the client's config), and nearly every beat has nothing new for them. # The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born diff --git a/tools/linuxfs/preload/kgslid.c b/tools/linuxfs/preload/kgslid.c new file mode 100644 index 00000000..3dee629d --- /dev/null +++ b/tools/linuxfs/preload/kgslid.c @@ -0,0 +1,58 @@ +/* + * Adreno revisions the runtime's Turnip does not know, reported as the revision it does. + * + * Retail SM8850 phones report their A840 as chip_id 0x44050A21 (KGSL gpu_model "Adreno840v2"). + * Mesa only lists 0x44050A31, so the runtime's glibc Turnip refuses the device outright + * ("device (chip_id = 44050A21, gpu_id = 6860) is unsupported") and gamescope exits before Steam + * starts. Banners-Turnip fixes its own builds by registering the v2 id on the same A840 GPU info + * (patches/a840v2.py); answering KGSL's device-info query with the known id has the same effect + * on any Turnip in the session, including the one the runtime was built with. A driver that + * already knows the v2 id resolves the rewritten one to the same entry. + * + * Only the KGSL device-info property is touched. Its layout is the kernel's uapi (msm_kgsl.h). + */ +#define _GNU_SOURCE 1 +#include +#include + +struct kgsl_devinfo { + unsigned int device_id; + unsigned int chip_id; + unsigned int mmu_enabled; + unsigned long gmem_gpubaseaddr; + unsigned int gpu_id; + size_t gmem_sizebytes; +}; + +struct kgsl_device_getproperty { + unsigned int type; + void *value; + size_t sizebytes; +}; + +#define KGSL_IOC_TYPE 0x09 +#define IOCTL_KGSL_DEVICE_GETPROPERTY _IOWR(KGSL_IOC_TYPE, 0x2, struct kgsl_device_getproperty) +#define KGSL_PROP_DEVICE_INFO 0x1 + +static const struct { + unsigned int reported; + unsigned int known; +} CHIP_IDS[] = { + {0x44050A21, 0x44050A31}, /* Adreno 840v2 -> Adreno 840 */ +}; + +void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc) { + if (rc != 0 || request != IOCTL_KGSL_DEVICE_GETPROPERTY || arg == NULL) return; + struct kgsl_device_getproperty *prop = arg; + if (prop->type != KGSL_PROP_DEVICE_INFO || prop->value == NULL + || prop->sizebytes < sizeof(struct kgsl_devinfo)) { + return; + } + struct kgsl_devinfo *info = prop->value; + for (size_t i = 0; i < sizeof(CHIP_IDS) / sizeof(CHIP_IDS[0]); i++) { + if (info->chip_id == CHIP_IDS[i].reported) { + info->chip_id = CHIP_IDS[i].known; + return; + } + } +} diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index a835c6cd..078f6a30 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -349,6 +349,7 @@ static int is_link_query(unsigned long request) { || request == SIOCGIFBRDADDR; } +void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc); /* kgslid.c */ int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden"))); @@ -364,8 +365,8 @@ int ioctl(int fd, unsigned long request, ...) { if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); int rc; - if (bl_kgsl_poll(real, fd, request, arg, &rc)) return rc; - rc = real(fd, request, arg); + if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = real(fd, request, arg); + bl_kgsl_chip_id_fixup(request, arg, rc); if (arg == NULL || !is_link_query(request)) return rc; int saved = errno; if (answer_ifreq(request, (struct ifreq *) arg, rc)) return 0;