mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Merge branch 'main' into feat/performance-fixes-2
This commit is contained in:
20 files changed
+716
-80
No files matched your search
@@ -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="<!-- droiddeck-pr-build -->"
|
||||
@@ -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\`."
|
||||
;;
|
||||
|
||||
@@ -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='<!-- droiddeck-pr-release -->'
|
||||
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 <<EOF
|
||||
$mark
|
||||
## Signed test build
|
||||
@@ -240,7 +300,7 @@ jobs:
|
||||
|
||||
It is also available in **DroidDeck → Updates → Test builds → PR #$PR_NUMBER**.
|
||||
|
||||
Pushes to this PR do not re-sign automatically. After a new green build, a maintainer can add the \`publish-test\` label again.
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
@@ -251,8 +311,10 @@ jobs:
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/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 }}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* a game become more of these nodes instead of vanishing.
|
||||
*/
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <climits>
|
||||
#include <cstdint>
|
||||
@@ -38,6 +39,9 @@
|
||||
#include <sys/inotify.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -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<int>(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<int>(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<int>(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<int (*)(const char *, int, ...)>(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<FakeInputRingHeader *>(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<bool> 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<int (*)(int, struct statfs *)>(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<int (*)(const char *, struct statfs *)>(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<unsigned> 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<int>(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<int>(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<std::string> *seen = new std::vector<std::string>();
|
||||
bool first = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<int> 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<DIR *(*)(const char *)>(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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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<Integer> 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<PadState> 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) {
|
||||
|
||||
@@ -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/<logName>.log beside the session logs, so a problem can
|
||||
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
|
||||
* be handed over like a session's.
|
||||
*/
|
||||
fun run(context: Context, argv: List<String>, 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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<File> {
|
||||
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)
|
||||
|
||||
@@ -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<File> = 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<File> =
|
||||
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" }
|
||||
}
|
||||
@@ -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<String>, fakeInputDir: File) {
|
||||
private fun addControllerEnvironment(guest: MutableList<String>, 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<String>()
|
||||
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. */
|
||||
|
||||
@@ -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<String> {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("!!!"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <stddef.h>
|
||||
#include <sys/ioctl.h>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user