diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 864786267..df1071dc1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -454,6 +454,7 @@ jobs: timeout-minutes: 10 permissions: contents: read + pull-requests: read env: CI_REPO: Droid-Deck/DroidDeck-CI KEEP: 10 @@ -472,6 +473,34 @@ jobs: owner: Droid-Deck repositories: DroidDeck-CI + # What this build is, for the top of the release notes: the merged PR's title and the first + # paragraph of its description, or the commit's subject for a push made without a PR. + - name: Describe the change + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ + --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty') + if [ -n "$pr" ]; then + number=$(jq -r .number <<<"$pr") + title=$(jq -r .title <<<"$pr") + merged=$(jq -r '.merged_at[:10]' <<<"$pr") + summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } | + awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400) + { + echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)" + if [ -n "$summary" ]; then + echo + echo "$summary" + fi + } > notes-head.md + else + subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]') + echo "**Pushed to main** without a PR - $subject" > notes-head.md + fi + cat notes-head.md + - name: Publish to DroidDeck-CI id: publish env: @@ -481,7 +510,9 @@ jobs: tag=main-${GITHUB_SHA::7} cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt rm signed/SHA256SUMS-*.txt - notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing. + notes="$(cat notes-head.md) + + Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing. Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it. SHA-256: diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 3ae1ab307..cbd532c42 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -115,11 +115,32 @@ jobs: permissions: actions: read contents: read + pull-requests: read steps: - uses: actions/download-artifact@v4 with: name: droiddeck-pr-signed-standard path: signed + # What this build is, for the top of the release notes: the PR's title and the first + # paragraph of its description. + - name: Describe the change + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + title=$(jq -r .title <<<"$pr") + summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } | + awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400) + { + echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title" + if [ -n "$summary" ]; then + echo + echo "$summary" + fi + } > notes-head.md + cat notes-head.md # A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses. - uses: actions/create-github-app-token@v2 id: ci-token @@ -140,7 +161,9 @@ jobs: tag="pr-$PR_NUMBER" apk="DroidDeck-$LABEL.apk" sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1) - notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER + notes="$(cat notes-head.md) + + Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER Commit: $HEAD_SHA APK SHA-256: $sha" if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then diff --git a/.gitignore b/.gitignore index 965928cb6..3240a2ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ captures/ # copies cannot drift. app/src/main/assets/linuxfs/ app/src/main/jniLibs/arm64-v8a/libproot.so +app/src/main/jniLibs/arm64-v8a/.proot-inputs app/src/main/jniLibs/arm64-v8a/libproot-loader.so # Kotlin compiler session files (build-tool state, never source). diff --git a/README.md b/README.md index 664860498..a71461045 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big ## Requirements and install -Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. +Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md). On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options. diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 4d71ce186..41d6cfe75 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -3,7 +3,9 @@ * through f1f34cd7, 2026-09-14..19), GPL-3.0: the locking, the fork handlers, the shared device * table, the Steam virtual-gamepad identity and the Z/RZ trigger placement are that author's. * Added here: an opt-in Xbox 360 identity (FAKE_EVDEV_IDENTITY=xbox360), which is what gets SDL's - * built-in mapping applied with no configuration when the pad is read by the Steam client itself. + * built-in mapping applied with no configuration when the pad is read by the Steam client itself; + * and a stand-in for /dev/uinput (FAKE_EVDEV_UINPUT=1), so the virtual pads Steam Input makes for + * a game become more of these nodes instead of vanishing. */ #include #include @@ -24,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -31,12 +34,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -80,6 +85,11 @@ static constexpr uint32_t FAKE_INPUT_RING_CAPACITY = 4096; static constexpr unsigned int FAKE_INPUT_MAJOR = 13; static constexpr unsigned int FAKE_INPUT_EVENT_MINOR_BASE = 64; static constexpr unsigned int FAKE_INPUT_JS_MINOR_BASE = 0; +// Nodes from event16 up are the virtual pads programs make through the /dev/uinput stand-in; +// below that are the app's own rings, one per slot. +static constexpr int UINPUT_EVENT_BASE = 16; +static constexpr int UINPUT_EVENT_LIMIT = 64; +static constexpr uint32_t UINPUT_DESCRIPTION_MAGIC = 0x46495544; struct FakeInputRingHeader { uint32_t magic; // 0 @@ -107,8 +117,28 @@ static constexpr size_t FAKE_INPUT_RING_SIZE = FAKE_INPUT_RING_HEADER_SIZE + (FAKE_INPUT_RING_CAPACITY * FAKE_INPUT_EVENT_SIZE); +// What a program told the /dev/uinput stand-in about the device it made, stored after the ring +// in that device's file so every process that opens the node answers the same identity. +struct UinputDescription { + uint32_t magic; + int32_t owner_pid; + struct input_id id; + uint32_t ff_effects_max; + char name[UINPUT_MAX_NAME_SIZE]; + char phys[64]; + uint8_t evbits[(EV_MAX + 8) / 8]; + uint8_t keybits[(KEY_MAX + 8) / 8]; + uint8_t absbits[(ABS_MAX + 8) / 8]; + uint8_t ffbits[(FF_MAX + 8) / 8]; + struct input_absinfo absinfo[ABS_CNT]; +}; + +static constexpr size_t UINPUT_FILE_SIZE = FAKE_INPUT_RING_SIZE + sizeof(UinputDescription); + struct FakeController { char *event = nullptr; + // Set for a node made through the /dev/uinput stand-in; null for the app's own pads. + const UinputDescription *uinput = nullptr; int slot = -1; FakeInputRingHeader *ring = nullptr; uint64_t read_seq = 0; @@ -189,6 +219,7 @@ struct FakeConfig { const char *udev_data_dir; bool vibration_enabled; bool xbox360_identity; + bool uinput_enabled; }; static FakeConfig &config() { @@ -201,6 +232,7 @@ static FakeConfig &config() { getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION")); const char *identity = getenv("FAKE_EVDEV_IDENTITY"); c->xbox360_identity = identity && !strcmp(identity, "xbox360"); + c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT")); pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child); Logger::init(); @@ -233,6 +265,7 @@ static const char *fake_hook_dir() { return config().hook_dir; } static const char *fake_udev_data_dir() { return config().udev_data_dir; } static bool fake_vibration_enabled() { return config().vibration_enabled; } static bool fake_xbox360_identity() { return config().xbox360_identity; } +static bool fake_uinput_enabled() { return config().uinput_enabled; } static bool ring_paths_loaded = false; static int next_ff_id = 0; @@ -535,10 +568,22 @@ keyframe_value(const FakeController &fake, uint16_t type, uint16_t code) { return 0; // SYN / unknown } +// A virtual pad's ring and description live beside its node, hidden from anything that lists +// event* names: /dev/input/.uinput-event. +__attribute__((visibility("hidden"))) static std::string uinput_ring_path(int node) { + return std::string(fake_hook_dir()) + "/.uinput-event" + std::to_string(node); +} + +__attribute__((visibility("hidden"))) static bool process_alive(pid_t pid) { + return pid > 0 && (kill(pid, 0) == 0 || errno == EPERM); +} + __attribute__((visibility("hidden"))) static int open_fake_input_ring(const char *event, int flags) { int slot = get_event_number(event); - std::string ring_path = get_ring_path_for_slot(slot); + bool virtual_pad = slot >= UINPUT_EVENT_BASE; + std::string ring_path = virtual_pad ? uinput_ring_path(slot) : get_ring_path_for_slot(slot); + size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE; if (ring_path.empty()) { Logger::log("No input ring is configured for %s (slot %d)\n", event, slot); errno = ENODEV; @@ -551,8 +596,7 @@ open_fake_input_ring(const char *event, int flags) { if (fd < 0) return -1; - void *mapping = - mmap(nullptr, FAKE_INPUT_RING_SIZE, PROT_READ, MAP_SHARED, fd, 0); + void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, fd, 0); if (mapping == MAP_FAILED) { int saved_errno = errno; syscall(SYS_close, fd); @@ -562,9 +606,16 @@ open_fake_input_ring(const char *event, int flags) { FakeInputRingHeader *ring = reinterpret_cast(mapping); - if (!ring_header_is_valid(ring)) { + const UinputDescription *description = + virtual_pad ? reinterpret_cast( + static_cast(mapping) + FAKE_INPUT_RING_SIZE) + : nullptr; + // A virtual pad whose maker has gone (a crashed client leaves its files) is not there. + if (!ring_header_is_valid(ring) || + (description && (description->magic != UINPUT_DESCRIPTION_MAGIC || + !process_alive(description->owner_pid)))) { Logger::log("Input ring %s has no valid header\n", ring_path.c_str()); - munmap(mapping, FAKE_INPUT_RING_SIZE); + munmap(mapping, mapping_size); syscall(SYS_close, fd); errno = ENODEV; return -1; @@ -574,7 +625,8 @@ open_fake_input_ring(const char *event, int flags) { controller->event = strdup(event); controller->slot = slot; controller->ring = ring; - controller->mapping_size = FAKE_INPUT_RING_SIZE; + controller->uinput = description; + controller->mapping_size = mapping_size; controller->generation = ring_generation(ring); // Establish the cursor at open so taps arriving before the first read stay // queued. If publication is busy, read() will finish establishing it later. @@ -618,20 +670,32 @@ __attribute__((visibility("hidden"))) static bool presents_steam_virtual() { // 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. -__attribute__((visibility("hidden"))) static uint16_t presented_abs_code(uint16_t code) { - if (!presents_steam_virtual()) return code; +// 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(); +} + +__attribute__((visibility("hidden"))) static uint16_t +presented_abs_code(const FakeController &fake, uint16_t code) { + if (!presents_xbox_triggers(fake)) return code; if (code == ABS_BRAKE) return ABS_Z; if (code == ABS_GAS) return ABS_RZ; return code; } -__attribute__((visibility("hidden"))) static uint16_t ring_abs_code(uint16_t code) { - if (!presents_steam_virtual()) return code; +// The snapshot keeps the triggers in the ABS_GAS/ABS_BRAKE places either way. +__attribute__((visibility("hidden"))) static uint16_t snapshot_abs_code(uint16_t code) { if (code == ABS_Z) return ABS_BRAKE; if (code == ABS_RZ) return ABS_GAS; return code; } +__attribute__((visibility("hidden"))) static uint16_t +ring_abs_code(const FakeController &fake, uint16_t code) { + return presents_xbox_triggers(fake) ? snapshot_abs_code(code) : code; +} + __attribute__((visibility("hidden"))) static void copy_slot_ioctl_string(int op, void *argp, const char *format, int event_number) { size_t size = _IOC_SIZE(op); @@ -707,6 +771,793 @@ __attribute__((visibility("hidden"))) static long long monotonic_ms() { return static_cast(now.tv_sec) * 1000LL + now.tv_nsec / 1000000LL; } +// ---- /dev/uinput, stood in for ---- +// +// Under Steam, a game is meant to read Steam Input's output, not the pad: the client hides the +// physical pad from the game (SDL_GAMECONTROLLER_IGNORE_DEVICES, and its overlay refuses the +// open) and makes it a virtual X-Box 360 pad through /dev/uinput, which it then drives with the +// layout the player chose. The sandbox has no uinput the client may use, so the virtual pad never +// appeared and every game was left reading the physical pad as-is. +// +// So /dev/uinput is answered here. A gamepad the client makes becomes another of this library's +// nodes - /dev/input/event16 and up - backed by a ring file of the same layout the app writes, +// and the client's writes are that ring's events. Any process reading the node sees the identity +// the client gave it and gets exactly what the client wrote. A device that is not a gamepad (the +// client's virtual keyboard and mouse) is accepted and its events dropped, which is what happened +// to them before. +struct UinputWriter { + UinputDescription description{}; + bool created = false; + bool published = false; + int node = -1; + pid_t owner = 0; + FakeInputRingHeader *ring = nullptr; + UinputDescription *shared = nullptr; + std::vector pending; + uint32_t buttons = 0; + int16_t axes[8] = {}; +}; + +static std::unordered_map> &uinput_map() { + static auto *map = new std::unordered_map>(); + return *map; +} + +__attribute__((visibility("hidden"))) static bool is_uinput_path(const char *pathname) { + return pathname && (!strcmp(pathname, "/dev/uinput") || !strcmp(pathname, "/dev/input/uinput")); +} + +__attribute__((visibility("hidden"))) static void set_bit(uint8_t *bits, size_t size, int bit) { + if (bit >= 0 && static_cast(bit) / 8 < size) bits[bit / 8] |= 1u << (bit % 8); +} + +__attribute__((visibility("hidden"))) static bool test_bit(const uint8_t *bits, size_t size, int bit) { + return bit >= 0 && static_cast(bit) / 8 < size && (bits[bit / 8] >> (bit % 8)) & 1u; +} + +// A real uinput fd reads EV_UINPUT requests and polls as writable. An eventfd that is never +// signalled does both: nothing to read, always writable, blocking or not as the caller asked. +__attribute__((visibility("hidden"))) static int open_uinput(int flags) { + int fd = eventfd(0, ((flags & O_NONBLOCK) ? EFD_NONBLOCK : 0) | ((flags & O_CLOEXEC) ? EFD_CLOEXEC : 0)); + if (fd < 0) return -1; + auto writer = std::make_shared(); + writer->owner = getpid(); + std::lock_guard guard(controller_mutex()); + uinput_map()[fd] = writer; + Logger::log("uinput: opened as fd %d\n", fd); + return fd; +} + +// The layout the client asks for only matters to readers; what makes it a pad is a south button +// and a left stick, which is also what every pad the ring format carries has. +__attribute__((visibility("hidden"))) static bool is_gamepad(const UinputDescription &d) { + return test_bit(d.evbits, sizeof(d.evbits), EV_KEY) && test_bit(d.keybits, sizeof(d.keybits), BTN_SOUTH) && + test_bit(d.evbits, sizeof(d.evbits), EV_ABS) && test_bit(d.absbits, sizeof(d.absbits), ABS_X); +} + +__attribute__((visibility("hidden"))) static void unpublish_uinput(UinputWriter &w) { + if (!w.published) return; + // Readers holding the mapping see the generation move and report the device gone; the node's + // removal is what tells SDL and Wine through inotify. + __atomic_add_fetch(&w.ring->generation, 1, __ATOMIC_RELEASE); + std::string node = std::string(fake_hook_dir()) + "/event" + std::to_string(w.node); + unlink(node.c_str()); + unlink((uinput_ring_path(w.node) + ".uevent").c_str()); + unlink(uinput_ring_path(w.node).c_str()); + munmap(w.ring, UINPUT_FILE_SIZE); + Logger::log("uinput: removed event%d\n", w.node); + w.ring = nullptr; + w.shared = nullptr; + w.published = false; +} + +// A node number is claimed by creating its ring file exclusively, so two processes making pads +// at once cannot take the same one. A file left by a process that has gone is reclaimed. +__attribute__((visibility("hidden"))) static int claim_uinput_node(int *fd_out) { + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + for (int node = UINPUT_EVENT_BASE; node < UINPUT_EVENT_LIMIT; node++) { + std::string path = uinput_ring_path(node); + int fd = my_open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0644); + if (fd >= 0) { + *fd_out = fd; + return node; + } + if (errno != EEXIST) return -1; + int existing = my_open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (existing < 0) continue; + UinputDescription old{}; + bool stale = pread(existing, &old, sizeof(old), FAKE_INPUT_RING_SIZE) == sizeof(old) && + old.magic == UINPUT_DESCRIPTION_MAGIC && !process_alive(old.owner_pid); + syscall(SYS_close, existing); + if (stale) { + unlink((std::string(fake_hook_dir()) + "/event" + std::to_string(node)).c_str()); + unlink((path + ".uevent").c_str()); + unlink(path.c_str()); + node--; // try the same number again + } + } + errno = ENOSPC; + return -1; +} + +__attribute__((visibility("hidden"))) static int publish_uinput(UinputWriter &w) { + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + int fd = -1; + int node = claim_uinput_node(&fd); + if (node < 0) return -1; + void *mapping = MAP_FAILED; + if (ftruncate(fd, UINPUT_FILE_SIZE) == 0) + mapping = mmap(nullptr, UINPUT_FILE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + syscall(SYS_close, fd); + std::string ring_path = uinput_ring_path(node); + if (mapping == MAP_FAILED) { + unlink(ring_path.c_str()); + return -1; + } + auto *ring = static_cast(mapping); + ring->magic = FAKE_INPUT_RING_MAGIC; + ring->version = FAKE_INPUT_RING_VERSION; + ring->event_size = FAKE_INPUT_EVENT_SIZE; + ring->capacity = FAKE_INPUT_RING_CAPACITY; + ring->generation = 1; + auto *shared = reinterpret_cast(static_cast(mapping) + FAKE_INPUT_RING_SIZE); + *shared = w.description; + shared->magic = UINPUT_DESCRIPTION_MAGIC; + shared->owner_pid = w.owner; + __atomic_thread_fence(__ATOMIC_RELEASE); + + // The identity Wine's HID bus reads through libudev (inputudev.c in libblsession), written + // before the node so the first lookup finds it. + char uevent[256]; + int length = snprintf(uevent, sizeof(uevent), "PRODUCT=%x/%x/%x/%x\nNAME=\"%s\"\nPHYS=\"%s\"\n", + shared->id.bustype, shared->id.vendor, shared->id.product, shared->id.version, + shared->name, shared->phys); + int uevent_fd = my_open((ring_path + ".uevent").c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + if (uevent_fd >= 0) { + if (write(uevent_fd, uevent, std::min(length, sizeof(uevent) - 1)) < 0) {} + syscall(SYS_close, uevent_fd); + } + std::string node_path = std::string(fake_hook_dir()) + "/event" + std::to_string(node); + int node_fd = my_open(node_path.c_str(), O_WRONLY | O_CREAT | O_CLOEXEC, 0644); + if (node_fd < 0) { + munmap(mapping, UINPUT_FILE_SIZE); + unlink((ring_path + ".uevent").c_str()); + unlink(ring_path.c_str()); + return -1; + } + syscall(SYS_close, node_fd); + w.node = node; + w.ring = ring; + w.shared = shared; + w.published = true; + Logger::log("uinput: \"%s\" %04x:%04x is /dev/input/event%d\n", shared->name, shared->id.vendor, + shared->id.product, node); + return 0; +} + +// Same publication as the app's writer (FakeInputWriter.flushBufferToRing): the events, then the +// absolute state under its seqlock, then the cursor that makes both visible. +__attribute__((visibility("hidden"))) static void flush_uinput(UinputWriter &w) { + if (!w.published || w.pending.empty()) { + w.pending.clear(); + return; + } + FakeInputRingHeader *ring = w.ring; + auto *events = reinterpret_cast(reinterpret_cast(ring) + + FAKE_INPUT_RING_HEADER_SIZE); + uint64_t write_seq = __atomic_load_n(&ring->write_seq, __ATOMIC_RELAXED); + struct timeval now = {}; + gettimeofday(&now, nullptr); + for (struct input_event ev : w.pending) { + ev.time = now; + events[write_seq % FAKE_INPUT_RING_CAPACITY] = ev; + write_seq++; + if (ev.type == EV_KEY) { + for (size_t i = 0; i < kSnapshotButtonCount; i++) + if (kSnapshotButtons[i] == ev.code) + w.buttons = ev.value ? (w.buttons | (1u << i)) : (w.buttons & ~(1u << i)); + } else if (ev.type == EV_ABS) { + uint16_t code = snapshot_abs_code(ev.code); + for (int i = 0; i < 8; i++) + if (kSnapshotAxisCodes[i] == code) + w.axes[i] = static_cast(std::max(-32768, std::min(32767, ev.value))); + } + } + w.pending.clear(); + uint64_t sequence = __atomic_load_n(&ring->snapshot_seq, __ATOMIC_RELAXED); + __atomic_store_n(&ring->snapshot_seq, sequence + 1, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(&ring->snapshot_buttons, w.buttons, __ATOMIC_RELAXED); + for (int i = 0; i < 8; i++) __atomic_store_n(&ring->snapshot_axes[i], w.axes[i], __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(&ring->snapshot_seq, sequence + 2, __ATOMIC_RELEASE); + __atomic_store_n(&ring->write_seq, write_seq, __ATOMIC_RELEASE); +} + +__attribute__((visibility("hidden"))) static ssize_t +write_uinput(UinputWriter &w, const void *buf, size_t count) { + if (!w.created) { + // The legacy description: written once, before UI_DEV_CREATE. + if (count != sizeof(struct uinput_user_dev)) { + errno = EINVAL; + return -1; + } + const auto *legacy = static_cast(buf); + memcpy(w.description.name, legacy->name, sizeof(w.description.name)); + w.description.name[sizeof(w.description.name) - 1] = '\0'; + w.description.id = legacy->id; + w.description.ff_effects_max = legacy->ff_effects_max; + for (int i = 0; i < ABS_CNT; i++) { + w.description.absinfo[i].minimum = legacy->absmin[i]; + w.description.absinfo[i].maximum = legacy->absmax[i]; + w.description.absinfo[i].fuzz = legacy->absfuzz[i]; + w.description.absinfo[i].flat = legacy->absflat[i]; + } + return static_cast(count); + } + if (count % sizeof(struct input_event)) { + errno = EINVAL; + return -1; + } + const auto *events = static_cast(buf); + for (size_t i = 0; i < count / sizeof(struct input_event); i++) { + w.pending.push_back(events[i]); + // A frame ends at SYN_REPORT; a writer that never sends one still gets its events out. + if ((events[i].type == EV_SYN && events[i].code == SYN_REPORT) || w.pending.size() >= 64) + flush_uinput(w); + } + return static_cast(count); +} + +__attribute__((visibility("hidden"))) static int +ioctl_uinput(UinputWriter &w, ioctl_request_t op, void *argp) { + if (_IOC_TYPE(op) != UINPUT_IOCTL_BASE) { + errno = ENOTTY; + return -1; + } + int number = _IOC_NR(op); + int value = static_cast(reinterpret_cast(argp)); + UinputDescription &d = w.description; + if (number == _IOC_NR(UI_GET_VERSION)) { + *static_cast(argp) = 5; + return 0; + } + if (number == _IOC_NR(UI_GET_SYSNAME(0))) { + if (!w.created) { + errno = ENOENT; + return -1; + } + snprintf(static_cast(argp), _IOC_SIZE(op), "input%d", w.published ? w.node : 0); + return 0; + } + if (number == _IOC_NR(UI_DEV_DESTROY)) { + if (getpid() == w.owner) unpublish_uinput(w); + w.created = false; + return 0; + } + if (w.created && number != _IOC_NR(UI_BEGIN_FF_UPLOAD) && number != _IOC_NR(UI_END_FF_UPLOAD) && + number != _IOC_NR(UI_BEGIN_FF_ERASE) && number != _IOC_NR(UI_END_FF_ERASE)) { + errno = EINVAL; // the kernel refuses configuration once the device exists + return -1; + } + switch (number) { + case _IOC_NR(UI_SET_EVBIT): set_bit(d.evbits, sizeof(d.evbits), value); return 0; + case _IOC_NR(UI_SET_KEYBIT): set_bit(d.keybits, sizeof(d.keybits), value); return 0; + case _IOC_NR(UI_SET_ABSBIT): set_bit(d.absbits, sizeof(d.absbits), value); return 0; + case _IOC_NR(UI_SET_FFBIT): set_bit(d.ffbits, sizeof(d.ffbits), value); return 0; + case _IOC_NR(UI_SET_RELBIT): + case _IOC_NR(UI_SET_MSCBIT): + case _IOC_NR(UI_SET_LEDBIT): + case _IOC_NR(UI_SET_SNDBIT): + case _IOC_NR(UI_SET_SWBIT): + case _IOC_NR(UI_SET_PROPBIT): + return 0; + case _IOC_NR(UI_SET_PHYS): + snprintf(d.phys, sizeof(d.phys), "%s", static_cast(argp)); + return 0; + case _IOC_NR(UI_DEV_SETUP): { + const auto *setup = static_cast(argp); + d.id = setup->id; + memcpy(d.name, setup->name, sizeof(d.name)); + d.name[sizeof(d.name) - 1] = '\0'; + d.ff_effects_max = setup->ff_effects_max; + return 0; + } + case _IOC_NR(UI_ABS_SETUP): { + const auto *setup = static_cast(argp); + if (setup->code >= ABS_CNT) { + errno = EINVAL; + return -1; + } + d.absinfo[setup->code] = setup->absinfo; + return 0; + } + case _IOC_NR(UI_DEV_CREATE): + w.created = true; + if (!is_gamepad(d)) { + Logger::log("uinput: \"%s\" %04x:%04x is not a gamepad; its events are dropped\n", d.name, + d.id.vendor, d.id.product); + return 0; + } + if (publish_uinput(w) < 0) { + Logger::log("uinput: could not publish \"%s\": %s\n", d.name, strerror(errno)); + w.created = false; + errno = ENOMEM; + return -1; + } + return 0; + // Force feedback reaches the client only as EV_UINPUT requests on this fd, and none are ever + // sent: a game's rumble on a virtual pad goes straight to the app (check_ff_event). + case _IOC_NR(UI_BEGIN_FF_UPLOAD): + case _IOC_NR(UI_END_FF_UPLOAD): + case _IOC_NR(UI_BEGIN_FF_ERASE): + case _IOC_NR(UI_END_FF_ERASE): + errno = EINVAL; + return -1; + default: + Logger::log("uinput: unhandled ioctl %d\n", number); + errno = EINVAL; + return -1; + } +} + +// Only the process that made the device takes it down: a forked child closing its inherited +// copy of the fd (as a spawn does before exec) must not remove the parent's pad. +__attribute__((visibility("hidden"))) static void close_uinput(int fd) { + std::lock_guard guard(controller_mutex()); + auto it = uinput_map().find(fd); + if (it == uinput_map().end()) return; + if (getpid() == it->second->owner) unpublish_uinput(*it->second); + uinput_map().erase(it); +} + +// ---- The pad as a Steam Deck controller ---- +// +// On a Deck, and on the handhelds InputPlumber serves, the Steam client reads the built-in +// controller itself as a Steam Deck controller: a vendor HID device it talks to over hidraw, which +// is what gives it the Quick Access button, the back grips, gyro and trackpads, and Steam Input's +// full treatment of the pad. With FAKE_EVDEV_DECK set the app's pad in ring slot 0 is presented to +// the client that way: /dev/hidraw16, found through the sysfs tree the app binds into the session +// (SteamDeckPad.kt), streams the Deck's 64-byte state report every 4 ms, as the real one does, built +// from the ring's absolute snapshot; its feature reports are answered as InputPlumber answers them +// for a virtual Deck. The evdev nodes of the app's pads are then withdrawn - the client would +// otherwise count the pad twice - and games get Steam Input's virtual pad (the /dev/uinput stand-in). +// +// A read returns one report and never blocks past the next one, so the fd is one end of a +// SOCK_SEQPACKET socket pair with a thread writing reports into the other end: poll, select and +// epoll then work on it with no help from the hooks here. +static constexpr const char *DECK_HIDRAW_PATH = "/dev/hidraw16"; +static constexpr unsigned int DECK_HIDRAW_MAJOR = 240; +static constexpr unsigned int DECK_HIDRAW_MINOR = 16; +static constexpr int DECK_REPORT_BYTES = 64; +static constexpr int DECK_REPORT_INTERVAL_US = 4000; +static constexpr const char *DECK_NAME = "Valve Software Steam Deck Controller"; +static constexpr const char *DECK_SERIAL = "DROIDDECK0001"; + +// InputPlumber, src/drivers/steam_deck/report_descriptor.rs (CONTROLLER_DESCRIPTOR): one 64-byte +// vendor input report and one 64-byte feature report, no report ids. +static const uint8_t kDeckReportDescriptor[] = { + 0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, + 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, + 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0}; + +// Feature report ids (SDL, src/joystick/hidapi/steam/controller_constants.h). +static constexpr uint8_t DECK_GET_ATTRIBUTES_VALUES = 0x83; +static constexpr uint8_t DECK_GET_STRING_ATTRIBUTE = 0xAE; +static constexpr uint8_t DECK_GET_CHIP_ID = 0xBA; +static constexpr uint8_t DECK_TRIGGER_RUMBLE_CMD = 0xEB; + +// Button bits of the state report (SDL, SDL_hidapi_steamdeck.c): the low word, then the high. +static constexpr uint32_t DECK_L_R2 = 0x00000001, DECK_L_L2 = 0x00000002, DECK_L_R1 = 0x00000004, + DECK_L_L1 = 0x00000008, DECK_L_Y = 0x00000010, DECK_L_B = 0x00000020, + DECK_L_X = 0x00000040, DECK_L_A = 0x00000080, DECK_L_UP = 0x00000100, + DECK_L_RIGHT = 0x00000200, DECK_L_LEFT = 0x00000400, + DECK_L_DOWN = 0x00000800, DECK_L_VIEW = 0x00001000, + DECK_L_STEAM = 0x00002000, DECK_L_MENU = 0x00004000, + DECK_L_L3 = 0x00400000, DECK_L_R3 = 0x04000000; +static constexpr uint32_t DECK_L_L5 = 0x00008000, DECK_L_R5 = 0x00010000, + DECK_L_LEFT_PAD = 0x00020000, DECK_L_RIGHT_PAD = 0x00040000, + DECK_L_LEFT_TOUCH = 0x00080000, DECK_L_RIGHT_TOUCH = 0x00100000; +static constexpr uint32_t DECK_H_L4 = 0x00000200, DECK_H_R4 = 0x00000400; +static constexpr uint32_t DECK_H_QAM = 0x00040000; +// Bit 11 of the ring snapshot's button word: the Quick Access button, which an Xbox pad does not +// have and the evdev nodes therefore never report (FakeInputWriter.SNAPSHOT_IDX_QAM). +static constexpr uint32_t SNAPSHOT_QAM_BIT = 1u << 11; + +struct DeckHidraw { + int peer = -1; + FakeInputRingHeader *ring = nullptr; + size_t mapping_size = 0; + uint8_t pending_feature = 0; +}; + +// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the +// back grips and trackpads (the second screen's Deck controls, DeckControls), already in the +// Deck's axes and units, under a seqlock (FakeInputWriter.writeMotion / writeDeckControls). +// Absent from a ring file written without one. +struct DeckImu { + uint32_t magic; // IMU1 once written + uint32_t reserved; + uint64_t seq; // odd while being written + int16_t accel[3]; + int16_t gyro[3]; + int16_t pads[4]; // left X, Y, right X, Y: -32768..32767, Y up + uint16_t pressure[2]; // left, right: 0..32767 + uint32_t controls; // DECK_EXTRA_* bits +}; +// DeckImu::controls (DeckControls.kt). +static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4, + DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16, + DECK_EXTRA_RPAD_TOUCH = 32, DECK_EXTRA_LPAD_CLICK = 64, + DECK_EXTRA_RPAD_CLICK = 128; + +struct DeckExtras { + int16_t accel[3] = {}; + int16_t gyro[3] = {}; + int16_t pads[4] = {}; + uint16_t pressure[2] = {}; + uint32_t controls = 0; +}; +static constexpr uint32_t DECK_IMU_MAGIC = 0x31554D49; +static constexpr size_t DECK_IMU_BLOCK_SIZE = 64; +static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes in the ring file"); + +__attribute__((visibility("hidden"))) static bool read_deck_extras(const DeckHidraw &deck, DeckExtras &out) { + if (deck.mapping_size < FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE) return false; + const auto *imu = reinterpret_cast(reinterpret_cast(deck.ring) + + FAKE_INPUT_RING_SIZE); + for (int attempt = 0; attempt < 4; attempt++) { + uint64_t seq = __atomic_load_n(&imu->seq, __ATOMIC_ACQUIRE); + if (seq & 1) continue; + if (__atomic_load_n(&imu->magic, __ATOMIC_RELAXED) != DECK_IMU_MAGIC) return false; + DeckExtras read; + for (int i = 0; i < 3; i++) { + read.accel[i] = __atomic_load_n(&imu->accel[i], __ATOMIC_RELAXED); + read.gyro[i] = __atomic_load_n(&imu->gyro[i], __ATOMIC_RELAXED); + } + for (int i = 0; i < 4; i++) read.pads[i] = __atomic_load_n(&imu->pads[i], __ATOMIC_RELAXED); + for (int i = 0; i < 2; i++) read.pressure[i] = __atomic_load_n(&imu->pressure[i], __ATOMIC_RELAXED); + read.controls = __atomic_load_n(&imu->controls, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (seq == __atomic_load_n(&imu->seq, __ATOMIC_RELAXED)) { + out = read; + return true; + } + } + return false; +} + +static std::unordered_map> &deck_map() { + static auto *map = new std::unordered_map>(); + return *map; +} + +static bool fake_deck_enabled() { + static int enabled = -1; + if (enabled < 0) enabled = getenv("FAKE_EVDEV_DECK") && atoi(getenv("FAKE_EVDEV_DECK")) ? 1 : 0; + return enabled == 1; +} + +// The Deck is the client's alone: a game that found it would have the pad twice, once through it +// and once through Steam Input's virtual pad. +__attribute__((visibility("hidden"))) static bool process_is_steam_client() { + static int known; + if (known == 0) { + char exe[PATH_MAX]; + 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 = !strcmp(name ? name + 1 : exe, "steam"); + } + known = client ? 1 : -1; + } + return known == 1; +} + +__attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { + return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH); +} + +// The app's own pads are the Deck now; their evdev nodes are withdrawn from everyone. +__attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) { + if (!fake_deck_enabled() || !pathname || strncmp(pathname, "/dev/input/", 11)) return false; + const char *event = strrchr(pathname, '/') + 1; + if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false; + int number = get_event_number(event); + return number >= 0 && number < UINPUT_EVENT_BASE; +} + +static inline void put16(uint8_t *at, int value) { + at[0] = static_cast(value & 0xff); + at[1] = static_cast((value >> 8) & 0xff); +} + +static inline void put32(uint8_t *at, uint32_t value) { + for (int i = 0; i < 4; i++) at[i] = static_cast(value >> (8 * i)); +} + +// The Deck's state report (SDL, controller_structs.h: ValveInReportHeader_t then +// SteamDeckStatePacket_t) from the ring's snapshot: kSnapshotButtons order, kSnapshotAxisCodes +// order, sticks as evdev has them (Y down), triggers 0..255. +__attribute__((visibility("hidden"))) static void +build_deck_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes, + const DeckExtras &extras) { + memset(report, 0, DECK_REPORT_BYTES); + report[0] = 0x01; // report version + report[2] = 0x09; // ID_CONTROLLER_DECK_STATE + report[3] = DECK_REPORT_BYTES; + put32(report + 4, packet); + static const uint32_t kButtonBits[11] = {DECK_L_A, DECK_L_B, DECK_L_X, DECK_L_Y, + DECK_L_L1, DECK_L_R1, DECK_L_VIEW, DECK_L_MENU, + DECK_L_L3, DECK_L_R3, DECK_L_STEAM}; + uint32_t low = 0, high = 0; + for (int i = 0; i < 11; i++) + if (buttons & (1u << i)) low |= kButtonBits[i]; + if (buttons & SNAPSHOT_QAM_BIT) high |= DECK_H_QAM; + int32_t hat_x = axes[6], hat_y = axes[7]; + if (hat_y < 0) low |= DECK_L_UP; + if (hat_y > 0) low |= DECK_L_DOWN; + if (hat_x < 0) low |= DECK_L_LEFT; + if (hat_x > 0) low |= DECK_L_RIGHT; + int32_t left_trigger = axes[5], right_trigger = axes[4]; // ABS_BRAKE, ABS_GAS + if (left_trigger > 127) low |= DECK_L_L2; + if (right_trigger > 127) low |= DECK_L_R2; + uint32_t controls = extras.controls; + if (controls & DECK_EXTRA_L4) high |= DECK_H_L4; + if (controls & DECK_EXTRA_R4) high |= DECK_H_R4; + if (controls & DECK_EXTRA_L5) low |= DECK_L_L5; + if (controls & DECK_EXTRA_R5) low |= DECK_L_R5; + if (controls & DECK_EXTRA_LPAD_TOUCH) low |= DECK_L_LEFT_TOUCH; + if (controls & DECK_EXTRA_RPAD_TOUCH) low |= DECK_L_RIGHT_TOUCH; + if (controls & DECK_EXTRA_LPAD_CLICK) low |= DECK_L_LEFT_PAD; + if (controls & DECK_EXTRA_RPAD_CLICK) low |= DECK_L_RIGHT_PAD; + put32(report + 8, low); + put32(report + 12, high); + for (int i = 0; i < 4; i++) put16(report + 16 + i * 2, extras.pads[i]); // s{Left,Right}PadX/Y + for (int i = 0; i < 3; i++) { + put16(report + 24 + i * 2, extras.accel[i]); // sAccelX/Y/Z + put16(report + 30 + i * 2, extras.gyro[i]); // sGyroX/Y/Z + } + put16(report + 56, extras.pressure[0]); // sPressurePadLeft + put16(report + 58, extras.pressure[1]); // sPressurePadRight + put16(report + 44, std::min(32767, left_trigger * 32767 / 255)); + put16(report + 46, std::min(32767, right_trigger * 32767 / 255)); + auto stick = [](int32_t value) { return std::max(-32767, std::min(32767, value)); }; + put16(report + 48, stick(axes[0])); + put16(report + 50, stick(-axes[1])); + put16(report + 52, stick(axes[2])); + put16(report + 54, stick(-axes[3])); +} + +__attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) { + auto *deck = static_cast *>(arg); + std::shared_ptr self = *deck; + delete deck; + uint32_t packet = 0; + SnapshotState snap; + uint32_t buttons = 0; + int32_t axes[8] = {}; + DeckExtras extras; + for (;;) { + if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { + buttons = snap.buttons; + memcpy(axes, snap.axes, sizeof(axes)); + } + read_deck_extras(*self, extras); + uint8_t report[DECK_REPORT_BYTES]; + build_deck_report(report, ++packet, buttons, axes, extras); + // A reader that fell behind loses reports rather than getting stale ones later; a reader that + // has closed ends the stream. + if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && + errno != EAGAIN && errno != EWOULDBLOCK) + break; + struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L}; + nanosleep(&interval, nullptr); + } + syscall(SYS_close, self->peer); + munmap(self->ring, self->mapping_size); + return nullptr; +} + +__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()) { + 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) { + errno = ENODEV; + return -1; + } + // The IMU block is mapped only where the file has one: past its end a mapping faults. + struct stat ring_stat; + size_t mapping_size = FAKE_INPUT_RING_SIZE; + if (fstat(ring_fd, &ring_stat) == 0 && + static_cast(ring_stat.st_size) >= FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE) + mapping_size = FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE; + 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))) { + 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) { + munmap(mapping, mapping_size); + return -1; + } + // Room for a few reports only, so what the client reads is never more than a few ms old. + int buffer = DECK_REPORT_BYTES * 4; + setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer)); + if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0); + if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK); + auto deck = std::make_shared(); + deck->peer = pair[1]; + deck->ring = static_cast(mapping); + deck->mapping_size = mapping_size; + { + std::lock_guard guard(controller_mutex()); + deck_map()[pair[0]] = deck; + } + pthread_t thread; + auto *arg = new std::shared_ptr(deck); + if (pthread_create(&thread, nullptr, deck_report_thread, arg) != 0) { + delete arg; + std::lock_guard guard(controller_mutex()); + deck_map().erase(pair[0]); + syscall(SYS_close, pair[0]); + syscall(SYS_close, pair[1]); + munmap(mapping, mapping_size); + errno = ENOMEM; + return -1; + } + pthread_detach(thread); + Logger::log("deck: %s opened as fd %d\n", DECK_HIDRAW_PATH, pair[0]); + return pair[0]; +} + +// libudev (systemd's sd-device) accepts a device only where its directory is on sysfs, which it +// checks with fstatfs on the directory it resolved. The Deck's sysfs is the app's own files bound +// into /sys, so those paths are said to be sysfs; nothing else is touched. +static constexpr long SYSFS_FS_MAGIC = 0x62656572; + +__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)); +} + +__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { + char link[64], path[PATH_MAX]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + ssize_t length = readlink(link, path, sizeof(path) - 1); + if (length <= 0) return false; + path[length] = '\0'; + return is_deck_sysfs_path(path); +} + +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; + 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; + return result; +} + +#ifdef __GLIBC__ +EXPORT int fstatfs64(int fd, struct statfs64 *buf) { + return fstatfs(fd, reinterpret_cast(buf)); +} + +EXPORT int statfs64(const char *path, struct statfs64 *buf) { + return statfs(path, reinterpret_cast(buf)); +} +#endif + +__attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value) { + size_t size = _IOC_SIZE(op); + if (!argp || !size) return 0; + snprintf(static_cast(argp), size, "%s", value); + return static_cast(std::min(size, strlen(value) + 1)); +} + +// Feature reports carry the report id (0) first, then the Valve message: type, length, payload. +__attribute__((visibility("hidden"))) static int +deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { + size_t size = _IOC_SIZE(op); + if (!buf || size < 2) { + errno = EINVAL; + return -1; + } + if (set) { + deck.pending_feature = buf[1]; + if (buf[1] == DECK_TRIGGER_RUMBLE_CMD && size >= 10) { + 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]); + return static_cast(size); + } + uint8_t reply[DECK_REPORT_BYTES + 1] = {}; + switch (deck.pending_feature) { + case DECK_GET_ATTRIBUTES_VALUES: { + // InputPlumber's capture from a real Deck (steam_deck_uhid.rs, handle_get_report). + static const uint8_t kAttributes[] = {0x00, 0x83, 0x2d, 0x01, 0x05, 0x12, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x0a, 0x2b, 0x12, 0xa9, 0x62, + 0x04, 0xad, 0xf1, 0xe4, 0x65, 0x09, 0x2e, 0x00, 0x00, + 0x00, 0x0b, 0xa0, 0x0f, 0x00, 0x00, 0x0d, 0x00, 0x00, + 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x0e}; + memcpy(reply, kAttributes, sizeof(kAttributes)); + break; + } + case DECK_GET_STRING_ATTRIBUTE: + reply[1] = DECK_GET_STRING_ATTRIBUTE; + reply[2] = 0x14; + reply[3] = 0x01; + memcpy(reply + 4, DECK_SERIAL, strlen(DECK_SERIAL)); + break; + case DECK_GET_CHIP_ID: + reply[1] = DECK_GET_CHIP_ID; + reply[2] = 0x11; + memcpy(reply + 4, "DROIDDECKCHIP01", 15); + break; + default: + reply[1] = deck.pending_feature; + break; + } + Logger::log("deck: feature 0x%02x read\n", deck.pending_feature); + size_t length = std::min(size, sizeof(reply)); + memcpy(buf, reply, length); + return static_cast(length); +} + +__attribute__((visibility("hidden"))) static int +ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) { + if (_IOC_TYPE(op) != 'H') { + errno = ENOTTY; + return -1; + } + switch (_IOC_NR(op)) { + case 0x01: // HIDIOCGRDESCSIZE + *static_cast(argp) = sizeof(kDeckReportDescriptor); + return 0; + case 0x02: { // HIDIOCGRDESC: struct hidraw_report_descriptor { __u32 size; __u8 value[4096]; } + auto *descriptor = static_cast(argp); + uint32_t size; + memcpy(&size, descriptor, sizeof(size)); + memcpy(descriptor + 4, kDeckReportDescriptor, std::min(size, sizeof(kDeckReportDescriptor))); + return 0; + } + case 0x03: { // HIDIOCGRAWINFO: struct hidraw_devinfo { __u32 bustype; __s16 vendor, product; } + struct { + uint32_t bustype; + int16_t vendor; + int16_t product; + } info = {BUS_USB, static_cast(0x28de), static_cast(0x1205)}; + memcpy(argp, &info, sizeof(info)); + return 0; + } + case 0x04: return copy_ioctl_string(op, argp, DECK_NAME); // HIDIOCGRAWNAME + case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-1/input2"); // HIDIOCGRAWPHYS + case 0x08: return copy_ioctl_string(op, argp, DECK_SERIAL); // HIDIOCGRAWUNIQ + case 0x06: return deck_feature(deck, op, static_cast(argp), true); // HIDIOCSFEATURE + case 0x07: return deck_feature(deck, op, static_cast(argp), false); // HIDIOCGFEATURE + default: + Logger::log("deck: unhandled hidraw ioctl 0x%02x\n", _IOC_NR(op)); + errno = EINVAL; + return -1; + } +} + EXPORT int open(const char *pathname, int flags, ...) { va_list va; mode_t mode; @@ -725,6 +1576,13 @@ EXPORT int open(const char *pathname, int flags, ...) { static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); + if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); + if (is_withdrawn_pad_path(pathname)) { + errno = ENOENT; + return -1; + } + char *fake_path = nullptr; const char *event = nullptr; if (pathname) { @@ -788,6 +1646,13 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { static auto my_openat = reinterpret_cast(dlsym(RTLD_NEXT, "openat")); + if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); + if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); + if (is_withdrawn_pad_path(pathname)) { + errno = ENOENT; + return -1; + } + char *fake_path = nullptr; const char *event = nullptr; if (pathname) { @@ -836,6 +1701,17 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { EXPORT int stat(const char *pathname, struct stat *statbuf) { static auto my_stat = reinterpret_cast(dlsym(RTLD_NEXT, "stat")); + if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) { + errno = ENOENT; + return -1; + } + if (is_deck_hidraw_path(pathname)) { + memset(statbuf, 0, sizeof(*statbuf)); + statbuf->st_mode = S_IFCHR | 0666; + statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR); + return 0; + } + const char *event = nullptr; char *fake_path = nullptr; @@ -879,6 +1755,11 @@ EXPORT int fstat(int fd, struct stat *buf) { int ret = my_fstat(fd, buf); std::lock_guard guard(controller_mutex()); + if (ret == 0 && deck_map().count(fd)) { + buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR; + buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR); + return ret; + } auto controller = controller_map().find(fd); if (ret == 0 && controller != controller_map().end()) { buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR; @@ -890,6 +1771,12 @@ EXPORT int fstat(int fd, struct stat *buf) { EXPORT int access(const char *pathname, int mode) { static auto my_access = reinterpret_cast(dlsym(RTLD_NEXT, "access")); + if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; + if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0; + if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) { + errno = ENOENT; + return -1; + } char *fake_path = nullptr; if (pathname) { @@ -920,6 +1807,12 @@ EXPORT int access(const char *pathname, int mode) { EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) { static auto my_faccessat = reinterpret_cast(dlsym(RTLD_NEXT, "faccessat")); + if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; + if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0; + if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) { + errno = ENOENT; + return -1; + } char *fake_path = nullptr; if (pathname) { @@ -1034,6 +1927,10 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { // blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own // controller_mutex() for as long as it waits and deadlock every other ioctl. std::unique_lock guard(controller_mutex()); + auto maker = uinput_map().find(fd); + if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp); + auto deck = deck_map().find(fd); + if (deck != deck_map().end()) return ioctl_deck(*deck->second, op, argp); auto controller = controller_map().find(fd); if (controller == controller_map().end()) { guard.unlock(); @@ -1044,6 +1941,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { int number = (op >> 0 & 0xFF); const char *event = controller->second->event ? controller->second->event : "event0"; int event_number = controller->second->slot; + // A virtual pad answers with what its maker described, not with the app's pad identity. + const UinputDescription *made = controller->second->uinput; if (type == 0x45 && number == 0x1) { Logger::log("Hooking ioctl EVIOCGVERSION for event %s\n", event); @@ -1055,7 +1954,9 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { struct input_id id; memset(&id, 0, sizeof(id)); id.bustype = 0x03; - if (presents_steam_virtual()) { + if (made) { + id = made->id; + } else if (presents_steam_virtual()) { id.vendor = STEAM_VIRTUAL_VENDOR_ID; id.product = STEAM_VIRTUAL_PRODUCT_ID; } else if (fake_xbox360_identity()) { @@ -1065,11 +1966,15 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { id.vendor = static_cast(GAMEPAD_VENDOR_ID_BASE + event_number); id.product = static_cast(GAMEPAD_PRODUCT_ID_BASE + event_number); } - id.version = GAMEPAD_VERSION; + if (!made) id.version = GAMEPAD_VERSION; memcpy(argp, (void *)&id, sizeof(id)); return 0; } else if (type == 0x45 && number == 0x6) { Logger::log("Hooking ioctl EVIOCGNAME for event %s\n", event); + if (made) { + if (argp && _IOC_SIZE(op)) snprintf(static_cast(argp), _IOC_SIZE(op), "%s", made->name); + return 0; + } copy_slot_ioctl_string(op, argp, presents_steam_virtual() ? STEAM_VIRTUAL_NAME_TEMPLATE : (fake_xbox360_identity() ? X360_NAME_TEMPLATE : GAMEPAD_NAME_TEMPLATE), @@ -1077,10 +1982,18 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return 0; } else if (type == 0x45 && number == 0x7) { Logger::log("Hooking ioctl EVIOCGPHYS for event %s\n", event); + if (made) { + if (argp && _IOC_SIZE(op)) snprintf(static_cast(argp), _IOC_SIZE(op), "%s", made->phys); + return 0; + } copy_slot_ioctl_string(op, argp, GAMEPAD_PHYS_TEMPLATE, event_number); return 0; } else if (type == 0x45 && number == 0x8) { Logger::log("Hooking ioctl EVIOCGUNIQ for event %s\n", event); + if (made) { + if (argp && _IOC_SIZE(op)) static_cast(argp)[0] = '\0'; + return 0; + } copy_slot_ioctl_string(op, argp, GAMEPAD_UNIQ_TEMPLATE, event_number); return 0; } else if (type == 0x45 && number == 0x9) { @@ -1097,6 +2010,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x20) { Logger::log("Hooking ioctl EVIOCGBIT(0, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->evbits); unsigned char bitmask[(EV_MAX + 8) / 8] = {}; bitmask[EV_SYN / 8] |= (1 << (EV_SYN % 8)); bitmask[EV_KEY / 8] |= (1 << (EV_KEY % 8)); @@ -1104,6 +2018,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x21) { Logger::log("Hooking ioctl EVIOCGBIT(EV_KEY, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->keybits); unsigned char bitmask[(KEY_MAX + 8) / 8] = {}; const int xbox_buttons[] = {BTN_A, BTN_B, BTN_X, BTN_Y, BTN_TL, BTN_TR, BTN_SELECT, BTN_START, @@ -1117,13 +2032,14 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x23) { Logger::log("Hooking ioctl EVIOCGBIT(EV_ABS, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->absbits); unsigned char bitmask[(ABS_MAX + 8) / 8] = {}; bitmask[ABS_X / 8] |= (1 << (ABS_X % 8)); bitmask[ABS_Y / 8] |= (1 << (ABS_Y % 8)); bitmask[ABS_RX / 8] |= (1 << (ABS_RX % 8)); bitmask[ABS_RY / 8] |= (1 << (ABS_RY % 8)); for (uint16_t trigger : {ABS_GAS, ABS_BRAKE}) { - uint16_t code = presented_abs_code(trigger); + uint16_t code = presented_abs_code(*controller->second, trigger); bitmask[code / 8] |= (1 << (code % 8)); } bitmask[ABS_HAT0X / 8] |= (1 << (ABS_HAT0X % 8)); @@ -1131,6 +2047,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x35) { Logger::log("Hooking ioctl EVIOCGBIT(EV_FF, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->ffbits); unsigned char bitmask[(FF_MAX + 8) / 8] = {}; bitmask[FF_RUMBLE / 8] |= (1 << (FF_RUMBLE % 8)); bitmask[FF_PERIODIC / 8] |= (1 << (FF_PERIODIC % 8)); @@ -1163,8 +2080,10 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { Logger::log("Hooking ioctl EVIOCGABS(ABS) for event %s\n", event); struct input_absinfo abs_info; memset(&abs_info, 0, sizeof(abs_info)); - uint16_t code = ring_abs_code(number - 0x40); - if (code == ABS_GAS || code == ABS_BRAKE) { + uint16_t code = ring_abs_code(*controller->second, number - 0x40); + if (made) { + abs_info = made->absinfo[number - 0x40]; + } else if (code == ABS_GAS || code == ABS_BRAKE) { abs_info.value = 0; abs_info.minimum = 0; abs_info.maximum = 255; @@ -1218,7 +2137,9 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { EXPORT int close(int fd) { static auto my_close = reinterpret_cast(dlsym(RTLD_NEXT, "close")); + close_uinput(fd); std::unique_lock guard(controller_mutex()); + deck_map().erase(fd); auto controller = controller_map().find(fd); if (controller != controller_map().end()) { Logger::log("Removing controller, fd %d event %s\n", controller->first, @@ -1283,7 +2204,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY; struct input_event ev; memcpy(&ev, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE); - if (ev.type == EV_ABS) ev.code = presented_abs_code(ev.code); + if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); memcpy(static_cast(buf) + i * FAKE_INPUT_EVENT_SIZE, &ev, FAKE_INPUT_EVENT_SIZE); } @@ -1309,7 +2230,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { ev.type = kNeutralEvents[index].type; ev.code = kNeutralEvents[index].code; ev.value = keyframe_value(fake, ev.type, ev.code); - if (ev.type == EV_ABS) ev.code = presented_abs_code(ev.code); + if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); memcpy(static_cast(buf) + i * FAKE_INPUT_EVENT_SIZE, &ev, FAKE_INPUT_EVENT_SIZE); --fake.keyframe_remaining; @@ -1333,6 +2254,9 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) { static auto my_write = reinterpret_cast(dlsym(RTLD_NEXT, "write")); std::unique_lock guard(controller_mutex()); + auto made = uinput_map().find(fd); + if (made != uinput_map().end()) return write_uinput(*made->second, buf, count); + if (deck_map().count(fd)) return static_cast(count); auto controller = controller_map().find(fd); if (controller != controller_map().end()) { if (fake_fd_is_stale(fd)) { @@ -1355,6 +2279,22 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) { EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) { std::unique_lock guard(controller_mutex()); + if (deck_map().count(fd)) { + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) total += static_cast(iov[i].iov_len); + return total; + } + auto made = uinput_map().find(fd); + if (made != uinput_map().end()) { + // One description or whole events per vector, which is how uinput writers write. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t written = write_uinput(*made->second, iov[i].iov_base, iov[i].iov_len); + if (written < 0) return total ? total : -1; + total += written; + } + return total; + } auto controller = controller_map().find(fd); if (controller != controller_map().end()) { if (fake_fd_is_stale(fd)) { diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index d2f5f0595..8b2509a68 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -435,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) { * delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier * limit had spaced out, and the game's first frames after that waited on empty slots. */ static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) { - int limit = g_fps_limit; - if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; } - int64_t interval = 1000000000LL / limit, now = now_ns(); + const int limit = g_fps_limit; + /* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for + * frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced + * buffer straight back let it draw as fast as the GPU allowed and everything above the refresh + * rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05). + * One release per refresh is the back-pressure X11 gives, and the screen shows the same frames + * either way. */ + const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns; + if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; } + int64_t now = now_ns(); int64_t at = s->next_release_ns + interval; int64_t latest = now + interval * (int64_t)(s->releases_pending + 1); if (at < now) at = now; diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index e1764bc57..8caeae182 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -1,12 +1,17 @@ package com.droiddeck.launcher import android.app.Application +import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.session.CrashHandler -/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */ +/** + * Process-wide setup: the crash handler, so a session's folder is finished even when we die, and + * the socket Flatpak's sandboxes are started through (BwrapSpawner). + */ class App : Application() { override fun onCreate() { super.onCreate() CrashHandler.install(this) + BwrapSpawner.start(this) } } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index df58f900b..06b8b39da 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -38,6 +38,7 @@ import com.droiddeck.launcher.session.OfflineMode import com.droiddeck.launcher.session.ProtonExtras import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.ui.ComponentsPage +import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.ProtonPage @@ -104,12 +105,16 @@ class MainActivity : ComponentActivity() { private var glThread by mutableStateOf(true) private var noGlError by mutableStateOf(true) private var steamDeckMode by mutableStateOf(false) + private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var showCredits by mutableStateOf(false) private var showProtons by mutableStateOf(false) // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) private var showMapping by mutableStateOf(false) + private var saveBusy: String? = null + /** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */ + private var onSavePicked: ((File) -> Unit)? = null private var controllerSettings by mutableStateOf(null) private var catalog by mutableStateOf?>(null) private var catalogLoading by mutableStateOf(false) @@ -126,7 +131,8 @@ class MainActivity : ComponentActivity() { private var gameCores by mutableStateOf>(emptySet()) private var tuSysmem by mutableStateOf(false) private var zinkLazy by mutableStateOf(false) - private var noXalia by mutableStateOf(false) + private var noXalia by mutableStateOf(true) + private var gamescopeRealtime by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) @@ -137,6 +143,8 @@ class MainActivity : ComponentActivity() { private var forceFullscreen by mutableStateOf(true) private var stretch16x9 by mutableStateOf(false) private var launcherFullscreen by mutableStateOf(true) + private var storeEnabled by mutableStateOf(false) + private var appImagesEnabled by mutableStateOf(false) private var mic by mutableStateOf(false) // The app's own picker (files/), once per kind of pick: the two driver lists validate @@ -150,6 +158,42 @@ class MainActivity : ComponentActivity() { private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } + private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) + } + private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) + } + + /** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */ + private fun importSaves(name: String, game: () -> GameSaves.Game) { + if (SessionState.running) { + android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show() + return + } + onSavePicked = { zip -> + saveAction("Importing into $name") { + val (written, backup) = GameSaves.import(game(), zip) + val kind = GameSaves.layoutOf(zip)?.label ?: "zip" + "Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "") + } + } + pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path)) + } + + /** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */ + private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) { + onSavePicked = { dir -> + saveAction("Exporting $name") { + val (zip, count) = GameSaves.export(game(), layout, dir) + "Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}" + } + } + GameSaves.savesDir().mkdirs() + pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path)) + } private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path) @@ -173,6 +217,9 @@ class MainActivity : ComponentActivity() { private var addedGamesArt by mutableStateOf(true) @Volatile private var artFetchRunning = false private var addedGames by mutableStateOf>(emptyList()) + private val pickAppImage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { com.droiddeck.launcher.store.AppImageState.import(this, it) } + } private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setRomsDir(this, path) @@ -265,6 +312,8 @@ class MainActivity : ComponentActivity() { theme = SessionPrefs.theme(this) backActionsInverted = SessionPrefs.backActionsInverted(this) launcherFullscreen = SessionPrefs.launcherFullscreen(this) + storeEnabled = SessionPrefs.storeEnabled(this) + appImagesEnabled = SessionPrefs.appImagesEnabled(this) applyLauncherFullscreen() setContent { DroidDeckTheme(theme) { @@ -308,6 +357,8 @@ class MainActivity : ComponentActivity() { phantomProcessStatus = phantomProcessStatus, showPhantomGate = showPhantomGate, launcherFullscreen = launcherFullscreen, + storeEnabled = storeEnabled, + appImagesEnabled = appImagesEnabled, ), FrontEndActions( onPlay = { startSteamSession() }, @@ -327,6 +378,25 @@ class MainActivity : ComponentActivity() { .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)) }, onEmulator = { e -> launchProgram(e.program) }, + // A Flatpak app from the store, full screen under gamescope like an emulator. + onImportAppImage = { + pickAppImage.launch(InAppFilePicker.buildIntent(this, listOf("appimage"), "Choose an AppImage")) + }, + // An imported AppImage, full screen under gamescope like an emulator. + onAppImage = { dir, name -> + Library.flatpakNames[dir] = name + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) + .putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(dir))) + }, + onFlatpakApp = { id, name -> + Library.flatpakNames[id] = name + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER) + .putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(id))) + }, onRom = { g -> val e = emulatorList.first { it.id == g.emulatorId } startActivity(Intent(this, SessionActivity::class.java) @@ -347,6 +417,9 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, + // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. + onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } }, + onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } }, onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, @@ -379,6 +452,8 @@ class MainActivity : ComponentActivity() { launcherFullscreen = on applyLauncherFullscreen() }, + onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on }, + onAppImagesEnabled = { on -> SessionPrefs.setAppImagesEnabled(this, on); appImagesEnabled = on }, onHomeApp = { manageHomeApp() }, onHomeScreen = { on -> HomeApp.setHomeScreenEnabled(this, on) @@ -504,8 +579,9 @@ class MainActivity : ComponentActivity() { if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } - // The microphone is on by default; ask once, with the storage prompt, so voice chat works - // without a trip to the settings. A refusal is not asked again - the toggle asks when used. + // The microphone is off by default. Someone who turned it on before the permission was + // granted is asked once here, with the storage prompt; a refusal is not asked again - the + // toggle asks when used. if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this) && checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { wanted.add(Manifest.permission.RECORD_AUDIO) @@ -657,6 +733,19 @@ class MainActivity : ComponentActivity() { }, "catalog-desktop").start() } + /** Runs a save import or export off the main thread, one at a time, and says how it went. */ + private fun saveAction(label: String, work: () -> String) { + if (saveBusy != null) return + saveBusy = label + Thread({ + val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" } + ui.post { + saveBusy = null + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + } + }, "game-saves-action").start() + } + private fun openProtons() { settingsMode = null showPerformance = false @@ -808,6 +897,7 @@ class MainActivity : ComponentActivity() { fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null, steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null, steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode, + steamController = if (mode == SessionService.MODE_STEAM) steamController else null, runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup, addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), @@ -865,6 +955,7 @@ class MainActivity : ComponentActivity() { steamDeckMode = on steamChannel = SessionPrefs.steamChannel(this) }, + onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id }, onRunSteamAtStartup = { on -> SessionPrefs.setRunSteamAtStartup(this, on) runSteamAtStartup = on @@ -896,6 +987,7 @@ class MainActivity : ComponentActivity() { cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) }, clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia, + gamescopeRealtime = gamescopeRealtime, prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, @@ -903,6 +995,7 @@ class MainActivity : ComponentActivity() { onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on }, onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on }, onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on }, + onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> @@ -947,6 +1040,8 @@ class MainActivity : ComponentActivity() { customResolution = SessionPrefs.customResolution(this, mode) fexPreset = SessionPrefs.fexPreset(this) steamChannel = SessionPrefs.steamChannel(this) + steamDeckMode = SessionPrefs.steamDeckMode(this) + steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) addedGamesDirs = SessionPrefs.addedGamesDirs(this) refreshAddedGames() @@ -997,6 +1092,7 @@ class MainActivity : ComponentActivity() { tuSysmem = SessionPrefs.tuSysmem(this) zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) + gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() @@ -1014,7 +1110,8 @@ class MainActivity : ComponentActivity() { logsEnabled = SessionPrefs.logsEnabled(this) runningLabel = if (SessionState.running) when (SessionState.mode) { SessionService.MODE_DESKTOP -> "Desktop" - SessionService.MODE_RUN -> SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program" + SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program) + ?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program" else -> "Steam" } else null // The libraries, off the main thread: manifests and a folder scan. @@ -1022,7 +1119,7 @@ class MainActivity : ComponentActivity() { val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g -> com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art -> Library.SteamGame( - g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId, + g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, Library.ADDED, g.gameId, hero = art.hero ?: art.header, gameFiles = g.folder, protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId), ) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 82d9f86e6..2023616d3 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -47,7 +47,6 @@ import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState -import com.droiddeck.launcher.session.PerfHints import com.droiddeck.launcher.session.PerfHud import com.droiddeck.launcher.session.PerfMode import com.droiddeck.launcher.session.SessionPrefs @@ -92,6 +91,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private lateinit var loading: LoadingState private lateinit var hud: PerfHud private var padBridge: PadBridge? = null + /** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */ + private var padMotion: com.droiddeck.launcher.input.PadMotion? = null + /** Between onResume and onPause. */ + private var resumed = false + private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } } + + /** 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). */ + private fun updatePadMotion() { + if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop() + } private var onScreenControls: OnScreenControls? = null private var keyboard: KeyboardHost? = null private var watching = true @@ -166,9 +176,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) - private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE) + private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode) private var secondScreenDisplays by mutableStateOf>(emptyList()) - private var selectedSecondScreenDisplay by mutableStateOf(-1) + private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay) + /** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */ + private var started = false private lateinit var displayManager: DisplayManager private var secondScreenPresentation: SecondScreenPresentation? = null private val displayListener = object : DisplayManager.DisplayListener { @@ -203,8 +215,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode)) } window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - // Game-tier power policy for the whole session: a sustained clock floor, the panel's - // fastest mode, and the OS told it is in gameplay. Logged so a slow device says why. + // Game-tier power policy for the whole session: the panel's fastest mode, and the OS told + // it is in gameplay. Logged so a slow device says why. Log.i(TAG, "perf: " + PerfMode.apply(this)) goFullscreen() // The device's volume keys change the stream the session plays on (the relay and @@ -224,6 +236,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input")) padBridge = bridge + padMotion = com.droiddeck.launcher.input.PadMotion(this) { + @Suppress("DEPRECATION") + (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 + } + SessionState.deckPadListener = deckPadListener // 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 { @@ -761,9 +778,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), ) if (!SessionState.running) SessionEvents.record("compositor.started") - // ADPF: the compositor thread's frame intervals go to the power HAL against the panel's - // period, so a long frame raises CPU clocks now rather than after the load averages up. - PerfHints.arm(this, refreshHz()) // The service owns everything below the compositor. It is started whenever no session is // running - NOT only when the compositor was just started: the compositor lives for the // whole process, so the second Play after a session ended used to re-attach the Surface, @@ -849,9 +863,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return Pair(width and 1.inv(), height and 1.inv()) } + /** + * The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands + * some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel - + * the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.) + */ private fun refreshHz(): Float { val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay - val hz = display?.refreshRate ?: 60f + val wanted = window.attributes.preferredDisplayModeId + val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate + ?: display?.refreshRate ?: 60f return if (hz > 1f) hz else 60f } @@ -928,8 +949,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return } // It stays until the user leaves: a failure read against a timer is a failure not read. + val what = when (SessionState.mode) { + SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program" + SessionService.MODE_DESKTOP -> "The desktop" + else -> "Steam" + } loading.showEnded( - hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.", + hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.", "Exit status $status · ${SessionState.logFile?.path ?: "no log"}", ) focusEndedScreen() @@ -1271,11 +1297,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1 if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true) + // A choice kept while its display was away (asleep, waking): back as soon as it is. + else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) } private fun selectSecondScreenDisplay(displayId: Int) { if (secondScreenDisplays.none { it.id == displayId }) return selectedSecondScreenDisplay = displayId + SessionState.secondScreenDisplay = displayId if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode) } @@ -1297,8 +1326,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun showSecondScreen(mode: SecondScreenMode) { val target = displayManager.getDisplay(selectedSecondScreenDisplay) if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) { - closeSecondScreen(reset = true) - refreshSecondScreenDisplays() + // Not there right now - a panel still waking, say. The choice is kept; the display + // listener shows it again when the display comes back (refreshSecondScreenDisplays). + closeSecondScreen(reset = false) return } var presentation = secondScreenPresentation @@ -1330,6 +1360,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!presentation.isShowing) presentation.show() presentation.showMode(mode) secondScreenMode = mode + SessionState.secondScreenMode = mode + SessionState.secondScreenDisplay = selectedSecondScreenDisplay } catch (e: Exception) { Log.w(TAG, "could not show second-screen controls on ${target.name}", e) closeSecondScreen(reset = true) @@ -1349,7 +1381,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun closeSecondScreen(reset: Boolean) { secondScreenPresentation?.closeControls() secondScreenPresentation = null - if (reset) secondScreenMode = SecondScreenMode.NONE + if (reset) { + secondScreenMode = SecondScreenMode.NONE + SessionState.secondScreenMode = SecondScreenMode.NONE + } } /** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */ @@ -1462,15 +1497,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onStart() { super.onStart() + started = true displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) refreshSecondScreenDisplays() - if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode) + if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) SessionService.setActivityVisible(this, true) } override fun onStop() { + started = false displayManager.unregisterDisplayListener(displayListener) - closeSecondScreen(reset = true) + // Closed while the session is out of sight (sleep, a closed lid, another app), and kept: + // onStart puts it back. + closeSecondScreen(reset = false) SessionService.setActivityVisible(this, false) super.onStop() } @@ -1507,6 +1546,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) onScreenControls?.reload() updateOnScreenControls() + resumed = true + updatePadMotion() readPrefs() if (CompositorHost.isStarted) applyFrameGen() } @@ -1520,6 +1561,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { releaseDrawerDirection() // A button held when the app goes away would stay held in the ring for the whole session. onScreenControls?.releaseAll() + resumed = false + updatePadMotion() keyboard?.takeIf { it.shown }?.hide() super.onPause() } @@ -1573,6 +1616,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { closeSecondScreen(reset = true) if (::hud.isInitialized) hud.stop() padBridge?.stop() + padMotion?.stop() + if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null if (SessionState.endListener === endListener) SessionState.endListener = null WaylandCompositor.clearFirstFrameListener(firstFrameListener) super.onDestroy() 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 a0d44b172..056846f47 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -144,8 +144,10 @@ object DeviceReport { k("Threaded GL (glthread)", SessionPrefs.glThread(context)) k("No GL error checks", SessionPrefs.noGlError(context)) k("Steam Deck mode", SessionPrefs.steamDeckMode(context)) + k("Steam controller", SessionPrefs.steamController(context)) k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" }) k("Skip xalia", SessionPrefs.noXalia(context)) + k("gamescope realtime", SessionPrefs.gamescopeRealtime(context)) k("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) k("Guest host name", SessionPrefs.guestHostname(context)) k("DirectAudio for games", SessionPrefs.directAudio(context)) diff --git a/app/src/main/java/com/droiddeck/launcher/core/FileUtils.java b/app/src/main/java/com/droiddeck/launcher/core/FileUtils.java index 42008fb97..8190e2877 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/FileUtils.java +++ b/app/src/main/java/com/droiddeck/launcher/core/FileUtils.java @@ -104,6 +104,7 @@ public final class FileUtils { public static String sizeToString(long bytes) { if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30)); if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20)); + if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10)); return bytes + " B"; } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt index d73352af7..79334af3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt @@ -64,7 +64,8 @@ object HostProcess { } } - private fun pidOf(process: Process): Int = try { + @JvmStatic + fun pidOf(process: Process): Int = try { val field = process.javaClass.getDeclaredField("pid") field.isAccessible = true val pid = field.getInt(process) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 29191c37e..80b8091be 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -23,6 +23,9 @@ object Library { val hero: File? = null, val lastPlayed: Long = 0L, val gameFiles: File? = null, val protonPrefix: File? = null, ) + /** The [SteamGame.library] of a game added to the library rather than installed by Steam. */ + const val ADDED = "added" + class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null) class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List) { /** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */ @@ -163,7 +166,13 @@ object Library { ) /** The emulator's name for a program path from the rail ("ARMSX2"), or null. */ - fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name + fun nameForProgram(program: String?): String? = + if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) { + com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') } + } else specs.firstOrNull { it.program == program }?.name + + /** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */ + val flatpakNames = java.util.concurrent.ConcurrentHashMap() /** * Whether a program from the rail runs at the panel's own resolution rather than the session's diff --git a/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt b/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt new file mode 100644 index 000000000..23146be37 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.input + +/** + * The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have: + * the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the + * pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report. + * + * Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its + * position, a pad clicked also reports full pressure. + */ +object DeckControls { + /** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */ + const val L4 = 1 + const val R4 = 2 + const val L5 = 4 + const val R5 = 8 + private const val LEFT_TOUCH = 16 + private const val RIGHT_TOUCH = 32 + private const val LEFT_CLICK = 64 + private const val RIGHT_CLICK = 128 + + /** The pad the Deck controller is made from (PadBridge's slot). */ + private const val SLOT = 0 + + private var controls = 0 + private val pads = ShortArray(4) + private val pressure = ShortArray(2) + + @Synchronized + fun setGrip(bit: Int, down: Boolean) { + set(bit, down) + publish() + } + + /** A finger on (or off) one trackpad at x, y in -1..1, y up. */ + @Synchronized + fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) { + val at = if (right) 2 else 0 + set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching) + pads[at] = if (touching) axis(x) else 0 + pads[at + 1] = if (touching) axis(y) else 0 + publish() + } + + @Synchronized + fun setClick(right: Boolean, down: Boolean) { + set(if (right) RIGHT_CLICK else LEFT_CLICK, down) + pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0 + publish() + } + + /** Everything let go - for when the panel goes away with a finger still on it. */ + @Synchronized + fun releaseAll() { + if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return + controls = 0 + pads.fill(0) + pressure.fill(0) + publish() + } + + private fun set(bit: Int, on: Boolean) { + controls = if (on) controls or bit else controls and bit.inv() + } + + private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort() + + private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure) +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index 30b802166..0e4ea2de3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -67,6 +67,9 @@ public class FakeInputWriter { /** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam * button is not, because PadState keeps 10 and 11 for the triggers. */ private static final int SNAPSHOT_IDX_MODE = 10; + /** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key; + * libfakeinput's Deck controller reads it from here. */ + private static final int SNAPSHOT_IDX_QAM = 11; private static final int EVENT_SIZE = 24; static final int MAX_EVENTS_PER_UPDATE = 32; @@ -92,6 +95,18 @@ public class FakeInputWriter { private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40; private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8] private static final String RING_DIR_NAME = "fakeinput-rings"; + // After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and + // an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and + // units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it. + private static final int IMU_OFFSET = RING_SIZE; + private static final int IMU_MAGIC = 0x31554D49; // IMU1 + private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8; + private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3] + private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3] + private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y + private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right + private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits + private static final int RING_FILE_SIZE = RING_SIZE + 64; private static final Object RING_LOCK = new Object(); private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS]; @@ -110,6 +125,7 @@ public class FakeInputWriter { private volatile boolean destroyed = false; private final boolean[] prevButtonStates = new boolean[12]; + private boolean prevQam; private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY; private int prevTriggerL, prevTriggerR; private int prevHatX, prevHatY; @@ -217,30 +233,40 @@ public class FakeInputWriter { for (int i = 0; i < 8; i++) { data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0); } + data.putInt(IMU_OFFSET, 0); + data.putLong(IMU_SEQ_OFFSET, 0L); + for (int i = 0; i < 12; i++) { + data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0); + } + data.putInt(DECK_CONTROLS_OFFSET, 0); } + // Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and + // re-checks it there: a writer that looked the slot up just before this ran finds it gone. private static void releaseRingSlotLocked(int slot) { RingSlot ringSlot = RING_SLOTS[slot]; if (ringSlot == null) { return; } - ringSlot.data = null; - if (ringSlot.ringChannel != null) { - try { - ringSlot.ringChannel.close(); - } catch (IOException ignored) { + synchronized (ringSlot) { + ringSlot.data = null; + if (ringSlot.ringChannel != null) { + try { + ringSlot.ringChannel.close(); + } catch (IOException ignored) { + } + ringSlot.ringChannel = null; } - ringSlot.ringChannel = null; - } - if (ringSlot.ringRaf != null) { - try { - ringSlot.ringRaf.close(); - } catch (IOException ignored) { + if (ringSlot.ringRaf != null) { + try { + ringSlot.ringRaf.close(); + } catch (IOException ignored) { + } + ringSlot.ringRaf = null; + } + if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) { + ringSlot.ringFile.delete(); } - ringSlot.ringRaf = null; - } - if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) { - ringSlot.ringFile.delete(); } RING_SLOTS[slot] = null; } @@ -289,9 +315,9 @@ public class FakeInputWriter { FileChannel channel = null; try { raf = new RandomAccessFile(ringFile, "rw"); - raf.setLength(RING_SIZE); + raf.setLength(RING_FILE_SIZE); channel = raf.getChannel(); - ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE); + ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE); initializeRingHeader(data); RingSlot ringSlot = new RingSlot(); @@ -332,6 +358,9 @@ public class FakeInputWriter { return false; } synchronized (ringSlot) { + if (ringSlot.data == null) { + return false; + } if (!ringSlot.active) { if (ringSlot.everActivated) { ringSlot.generation++; @@ -381,6 +410,9 @@ public class FakeInputWriter { ByteBuffer source = this.buffer.duplicate(); synchronized (ringSlot) { ByteBuffer ring = ringSlot.data; + if (ring == null) { + return false; + } long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET); int sourceLimit = source.limit(); while (source.remaining() >= EVENT_SIZE) { @@ -416,6 +448,9 @@ public class FakeInputWriter { buttons |= (1 << i); } } + if (this.prevQam) { + buttons |= (1 << SNAPSHOT_IDX_QAM); + } long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET); ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress nativeStoreFence(); @@ -434,6 +469,58 @@ public class FakeInputWriter { ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete } + /** + * Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck + * controller's axes and units (see PadMotion). Safe from any thread. + */ + public static void writeMotion(int slot, short[] accel, short[] gyro) { + RingSlot ringSlot; + synchronized (RING_LOCK) { + ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null; + } + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null) return; + long seq = ring.getLong(IMU_SEQ_OFFSET); + ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress + nativeStoreFence(); + for (int i = 0; i < 3; i++) { + ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]); + ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]); + } + ring.putInt(IMU_OFFSET, IMU_MAGIC); + nativeStoreFence(); + ring.putLong(IMU_SEQ_OFFSET, seq + 2); + } + } + + /** + * Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls): + * {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and + * {@code pressure} left, right, already in the Deck's units. Safe from any thread. + */ + public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) { + RingSlot ringSlot; + synchronized (RING_LOCK) { + ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null; + } + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null) return; + long seq = ring.getLong(IMU_SEQ_OFFSET); + ring.putLong(IMU_SEQ_OFFSET, seq + 1); + nativeStoreFence(); + for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]); + for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]); + ring.putInt(DECK_CONTROLS_OFFSET, controls); + ring.putInt(IMU_OFFSET, IMU_MAGIC); + nativeStoreFence(); + ring.putLong(IMU_SEQ_OFFSET, seq + 2); + } + } + private static short clampShort(int value) { if (value > Short.MAX_VALUE) { return Short.MAX_VALUE; @@ -512,6 +599,11 @@ public class FakeInputWriter { } } + if (prevQam) { + prevQam = false; + hasChanges = true; + } + // Zero all axes if (prevThumbLX != 0) { prevThumbLX = 0; @@ -620,6 +712,11 @@ public class FakeInputWriter { // reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad. writeButton(SNAPSHOT_IDX_MODE, state.isDown(PadState.GUIDE)); + boolean qam = state.isDown(PadState.QAM); + if (forceResend || qam != prevQam) { + prevQam = qam; + hasChanges = true; + } // Sticks int lx = (int) (state.leftX * 32767); 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 890fde729..9fe6f6ecd 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -7,6 +7,8 @@ import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; +import com.droiddeck.launcher.session.SessionState; + import java.io.File; /** @@ -19,7 +21,10 @@ import java.io.File; * SDL → Big Picture. * *

The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known - * mapping without the user configuring anything. + * mapping without the user configuring anything. In a Steam session it is a Steam Deck controller + * to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick + * Access button of its own; an Xbox pad has none, so there the client's menu is reached with the + * Guide-then-A chord the client itself answers. */ public final class PadBridge { private static final String TAG = "PadBridge"; @@ -54,6 +59,8 @@ public final class PadBridge { private boolean systemQamPressed; private boolean qamChordActive; private boolean qamSyntheticAPressed; + /** The Deck controller's Quick Access button, held for one tap. */ + private boolean qamTapPressed; private int qamChordGeneration; /** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */ private volatile Runnable onPlayerInput; @@ -77,6 +84,7 @@ public final class PadBridge { systemQamPressed = false; qamChordActive = false; qamSyntheticAPressed = false; + qamTapPressed = false; qamChordGeneration++; if (open) { state.clear(); @@ -204,7 +212,8 @@ public final class PadBridge { boolean qamStarted = qamPressed && !systemQamPressed; systemGuidePressed = guidePressed; systemQamPressed = qamPressed; - if (qamStarted) startQamChord(); + // A Deck controller's QAM button is held for as long as the touch one is. + if (qamStarted && !SessionState.getDeckPad()) startQamChord(); else publish(); } @@ -217,9 +226,23 @@ public final class PadBridge { publish(); } - /** Starts the same Guide-then-A sequence used by the touch QAM button. */ + /** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */ public synchronized void triggerQam() { - startQamChord(); + if (!SessionState.getDeckPad()) { + startQamChord(); + return; + } + if (qamTapPressed) return; + qamTapPressed = true; + int generation = qamChordGeneration; + publish(); + chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS); + } + + private synchronized void releaseQamTap(int generation) { + if (generation != qamChordGeneration || !qamTapPressed) return; + qamTapPressed = false; + publish(); } private void startQamChord() { @@ -258,16 +281,17 @@ public final class PadBridge { private void publish() { if (!open && !start()) return; - if (systemGuidePressed || qamChordActive) { - effectiveState.copyFrom(state); - effectiveState.press(PadState.GUIDE, true); - if (qamSyntheticAPressed) { - effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed); - } - writer.writePad(effectiveState); - } else { + boolean guide = systemGuidePressed || qamChordActive; + boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed); + if (!guide && !qam) { writer.writePad(state); + return; } + effectiveState.copyFrom(state); + if (guide) effectiveState.press(PadState.GUIDE, true); + if (qam) effectiveState.press(PadState.QAM, true); + if (qamSyntheticAPressed) effectiveState.press(PadState.A, true); + writer.writePad(effectiveState); } private static float axis(MotionEvent event, int axis) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt new file mode 100644 index 000000000..31707a9a0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt @@ -0,0 +1,103 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.view.Surface +import kotlin.math.roundToInt + +/** + * The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad). + * + * The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android + * reports them in the device's natural orientation; they are turned to the screen as it is shown + * (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes + * and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the + * player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself. + * + * Sensors run only while the session is on screen with the pad a Deck controller, sampled as often + * as the Deck reports (4 ms); each reading goes straight into the pad's ring + * ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest. + */ +class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener { + private val sensors = context.getSystemService(SensorManager::class.java) + private var thread: HandlerThread? = null + /** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */ + @Volatile private var active = false + // Touched only on the worker thread. + private val accel = ShortArray(3) + private val gyro = ShortArray(3) + + fun start() { + if (thread != null || sensors == null) return + val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (gyroscope == null && accelerometer == null) { + Log.i(TAG, "pad motion: this device has no gyro or accelerometer") + return + } + val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() } + thread = worker + val handler = Handler(worker.looper) + active = true + gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) } + accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) } + Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}") + } + + fun stop() { + val worker = thread ?: return + thread = null + active = false + sensors?.unregisterListener(this) + // A still controller, not one frozen mid-turn - published on the worker, after any reading + // already queued there, so nothing can overwrite it. + Handler(worker.looper).post { + gyro.fill(0) + FakeInputWriter.writeMotion(SLOT, accel, gyro) + } + worker.quitSafely() + } + + override fun onSensorChanged(event: SensorEvent) { + if (!active) return + val values = event.values + // Natural orientation to the screen's: Android's display-rotation transform. + val (x, y) = when (rotation()) { + Surface.ROTATION_90 -> -values[1] to values[0] + Surface.ROTATION_180 -> -values[0] to -values[1] + Surface.ROTATION_270 -> values[1] to -values[0] + else -> values[0] to values[1] + } + val z = values[2] + val (target, scale) = when (event.sensor.type) { + Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S + Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2 + else -> return + } + // Screen frame (right, up, towards the player) to the Deck's (right, away, up). + target[0] = counts(x * scale) + target[1] = counts(-z * scale) + target[2] = counts(y * scale) + FakeInputWriter.writeMotion(SLOT, accel, gyro) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort() + + companion object { + private const val TAG = "PadMotion" + /** The pad the Deck controller is made from (PadBridge's slot). */ + private const val SLOT = 0 + /** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */ + private const val SAMPLING_US = 4000 + private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH + private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadState.kt b/app/src/main/java/com/droiddeck/launcher/input/PadState.kt index 7f4c9efbd..c798b682f 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadState.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PadState.kt @@ -57,5 +57,7 @@ class PadState { const val R3 = 9 /** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */ const val GUIDE = 12 + /** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */ + const val QAM = 13 } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 85fc94ca8..4b08a6d21 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) { NONE("none", "None"), KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"), TERMINAL("terminal", "Terminal"), + /** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */ + DECK_CONTROLS("deck-controls", "Deck grips + trackpads"), } data class SecondScreenDisplay(val id: Int, val label: String) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt new file mode 100644 index 000000000..a11df690e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt @@ -0,0 +1,181 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import android.os.StatFs +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import java.io.File +import java.io.RandomAccessFile + +/** + * AppImages the user brings: picked from storage, checked, and extracted once into the runtime at + * /opt/appimages/user//app. proot has no FUSE to mount an image, and extracting it at every + * start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux + * desktop, and the front end starts it under gamescope through bannerlator-appimage-run. + * + * /name holds the name to show, /icon.png the icon when the image has a PNG one. + */ +object AppImageManager { + private const val TAG = "AppImageManager" + const val GUEST_DIR = "/opt/appimages/user" + const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run" + private const val ELF_AARCH64 = 183 + private const val ELF_X86_64 = 62 + + class Item(val id: String, val name: String, val icon: File?, val comment: String?) { + val guestDir: String get() = "$GUEST_DIR/$id" + } + + private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1)) + + fun list(context: Context): List = + root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) } + ?.map { dir -> + Item( + dir.name, + FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name, + File(dir, "icon.png").takeIf { it.isFile }, + FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() }, + ) + }?.sortedBy { it.name.lowercase() } ?: emptyList() + + /** + * What is wrong with [file] as an AppImage for this device, or null when it can be imported. + * An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8. + */ + internal fun problem(file: File): String? { + if (!file.isFile) return "The file is gone" + val head = ByteArray(20) + try { + RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" } + } catch (e: Exception) { + return "The file cannot be read: ${e.message}" + } + if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) { + return "This is not an AppImage (it is not a Linux program)" + } + val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8) + return when { + machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download." + machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)" + head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage" + head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})" + else -> null + } + } + + /** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */ + internal fun idFor(fileName: String, taken: Set): String { + val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase() + .replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" } + var id = base + var n = 2 + while (id in taken) id = "$base-${n++}" + return id + } + + /** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */ + fun import(context: Context, file: File, onProgress: (String) -> Unit): String? { + if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first" + problem(file)?.let { return it } + val base = root(context).apply { mkdirs() } + // Room for the image while it is extracted, and the extracted tree (about the same again). + if (StatFs(base.path).availableBytes < file.length() * 3) { + return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}" + } + val id = idFor(file.name, base.list()?.toSet() ?: emptySet()) + val dir = File(base, id) + val image = File(dir, "image.AppImage") + try { + dir.mkdirs() + // Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader + // maps a program executable. + onProgress("Copying ${file.name}") + file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } } + image.setExecutable(true, false) + onProgress("Extracting ${file.name}") + val guestDir = "$GUEST_DIR/$id" + val out = StringBuilder() + val status = GuestCommand.run(context, listOf( + "/bin/bash", "-c", + "cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app", + "extract", guestDir, + ), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) } + if (status != 0 || !File(dir, "app").isDirectory) { + FileUtils.delete(dir) + return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "") + } + image.delete() + describe(dir, file) + writeMenuEntry(context, dir) + return null + } catch (e: Exception) { + Log.e(TAG, "import ${file.name}", e) + FileUtils.delete(dir) + return e.message ?: "Import failed" + } finally { + image.delete() + } + } + + /** Name, comment and icon from the image's own desktop entry; the file name when it has none. */ + private fun describe(dir: File, source: File) { + val app = File(dir, "app") + val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull() + val text = entry?.let { FileUtils.readString(it) } + fun key(k: String) = text?.let { desktopKey(it, k) } + val name = key("Name") ?: source.name.substringBeforeLast('.') + FileUtils.writeString(File(dir, "name"), name) + key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) } + key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) } + // The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon. + val iconName = key("Icon") + val candidates = listOfNotNull( + iconName?.let { File(app, "$it.png") }, + iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } }, + File(app, ".DirIcon"), + ) + candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true) + } + + private fun isPng(f: File): Boolean = try { + f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() } + } catch (e: Exception) { false } + + /** A key of a desktop entry's main group, unlocalised. */ + internal fun desktopKey(desktop: String, key: String): String? { + var inMain = false + for (line in desktop.lineSequence()) { + val t = line.trim() + if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue } + if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() } + } + return null + } + + private fun menuEntry(context: Context, id: String) = + // A system directory, not ~/.local/share/applications: the desktop copies game entries from + // here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman. + File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop") + + /** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */ + private fun writeMenuEntry(context: Context, dir: File) { + val id = dir.name + val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id + val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;" + val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable" + FileUtils.writeString(menuEntry(context, id), + "[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" + + "Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n") + } + + fun remove(context: Context, id: String) { + if (id.isEmpty() || '/' in id || id.startsWith(".")) return + FileUtils.delete(File(root(context), id)) + menuEntry(context, id).delete() + } + + private object Files { + fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt new file mode 100644 index 000000000..6ec25ddbf --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -0,0 +1,424 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.os.Process +import android.system.Os +import android.util.Base64 +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.HostProcess +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicInteger + +/** + * Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap). + * + * A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount + * namespaces Android gives no app. proot can show the same picture, and started here - beside the + * session's proot rather than inside it - it traces the app once instead of twice. The stand-in + * sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths), + * this builds it under the cache and starts it, then streams the output, the pid and the exit + * status back until one side goes away: the stand-in ending takes the sandbox down with it. + * + * The socket is abstract, so anything on the device could connect: only this app's own uid - the + * uid every program in the runtime already runs as - is served. + */ +object BwrapSpawner { + private const val TAG = "BwrapSpawner" + const val SOCKET = "com.droiddeck.launcher.bwrap" + private val seq = AtomicInteger() + @Volatile private var server: LocalServerSocket? = null + + /** Listens for sandboxes for the rest of the process's life; later calls do nothing. */ + @Synchronized + fun start(context: Context) { + if (server != null) return + val app = context.applicationContext + val socket = try { + LocalServerSocket(SOCKET) + } catch (e: IOException) { + Log.e(TAG, "cannot listen on $SOCKET", e) + return + } + server = socket + FileUtils.delete(File(app.cacheDir, "bwrap")) + Thread({ + while (true) { + val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break } + Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() } + } + }, "bwrap-server").apply { isDaemon = true; start() } + } + + private fun serve(context: Context, client: LocalSocket) { + val id = seq.incrementAndGet() + val dir = File(context.cacheDir, "bwrap/$id") + client.use { + val out = DataOutputStream(client.outputStream) + try { + val peer = client.peerCredentials + if (peer.uid != Process.myUid()) { + Log.w(TAG, "refused a sandbox for uid ${peer.uid}") + return + } + val input = DataInputStream(client.inputStream) + val kind = input.readUnsignedByte() + val body = ByteArray(input.readInt()).also { input.readFully(it) } + if (kind != 'R'.code) return + val request = JSONObject(String(body, Charsets.UTF_8)) + val plan = plan(context, request, dir) + run(context, plan, client, out, id) + } catch (e: Exception) { + Log.e(TAG, "sandbox $id", e) + runCatching { + frame(out, 'E', (e.message ?: e.toString()).toByteArray()) + frame(out, 'X', "1".toByteArray()) + } + } finally { + FileUtils.delete(dir) + } + } + } + + /** What proot is started with: the sandbox root, its binds (host to guest), and the program. */ + class Plan(val root: File, val binds: List, val cwd: String, val argv: List, val env: Map) + + data class Bind(val host: String, val guest: String) { + val spec: String get() = if (host == guest) host else "$host:$guest" + + companion object { + fun parse(spec: String): Bind { + val colon = spec.indexOf(':') + return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1)) + } + } + } + + /** True when [path] is [dir] or inside it. */ + internal fun under(path: String, dir: String): Boolean = + dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/") + + /** + * Where a path the session's programs see lives on the host: through the session's own binds + * (the most specific one), or else inside the rootfs. + */ + internal fun hostPath(sessionBinds: List, rootfs: String, guest: String): String { + val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length } + ?: return rootfs.trimEnd('/') + guest + val rest = guest.substring(bind.guest.length).trimStart('/') + return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest + } + + /** + * Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved + * under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what + * Android keeps apps from reading. + */ + internal fun bindsFor(sessionBinds: List, rootfs: String, src: String, dest: String): List { + val out = ArrayList() + out.add(Bind(hostPath(sessionBinds, rootfs, src), dest)) + for (b in sessionBinds.sortedBy { it.guest.length }) { + if (b.guest != src && under(b.guest, src)) { + out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/')))) + } + } + return out + } + + internal fun plan(context: Context, request: JSONObject, dir: File): Plan { + val rootfs = LinuxRuntime.rootDir(context).path + val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) } + val root = File(dir, "root").apply { mkdirs() } + val binds = ArrayList() + var scratch = 0 + fun addBind(b: Bind) { + binds.removeAll { it.guest == b.guest } + binds.add(b) + } + fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length } + fun newScratch(): File = File(dir, "x${scratch++}") + /** + * Where [dest] can be created on the host: inside the sandbox root, or inside a directory + * of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the + * GL extension's links). Null inside anything else bound in - the runtime itself, a host + * directory - which must not be written to. + */ + fun writable(dest: String): File? { + val cover = coveringBind(dest) ?: return inRoot(root, dest) + if (!under(cover.host, dir.path)) return null + return inRoot(File(cover.host), dest.substring(cover.guest.length)) + } + val ops = request.getJSONArray("ops") + for (i in 0 until ops.length()) { + val op = ops.getJSONObject(i) + val dest = op.optString("dest") + when (op.getString("t")) { + "bind" -> { + val src = op.getString("src") + val all = bindsFor(session, rootfs, src, dest) + if (!File(all.first().host).exists()) { + if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out") + continue + } + all.forEach { addBind(it) } + } + "proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) } + "dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) } + "dir" -> { + val perms = op.optInt("perms", 0x1ed) + val here = writable(dest) + when { + op.optBoolean("empty") && coveringBind(dest) != null -> + addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest)) + here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms) + File(hostPath(binds, root.path, dest)).isDirectory -> {} + else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest)) + } + } + "file" -> { + val data = Base64.decode(op.getString("data"), Base64.DEFAULT) + val here = writable(dest) + val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here + else newScratch().also { addBind(Bind(it.path, dest)) } + f.writeBytes(data) + chmod(f, op.optInt("perms", 0x1b6)) + } + "symlink" -> { + val target = op.getString("target") + val link = writable(dest) + if (link != null) { + link.parentFile?.mkdirs() + if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target)) + } else { + // A link inside the runtime's own tree cannot be made; what it points at is + // bound in its place. + val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target) + val host = hostPath(binds, root.path, abs) + if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out") + } + } + } + } + if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) } + val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } } + val env = LinkedHashMap() + request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } } + if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) } + preload(session, rootfs, env).forEach { addBind(it) } + browserSandboxes(env) + return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env) + } + + /** Where a sandbox finds the runtime's own GPU driver. */ + private const val GPU_DIR = "/run/droiddeck-gpu" + + /** + * The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM + * kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The + * runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks + * but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's + * own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone. + */ + private fun gpu(rootfs: String, root: File, env: MutableMap): List? { + val lib = File(rootfs, "usr/lib") + val driver = File(lib, "libvulkan_freedreno.so") + if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null + if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null + // A window on the desktop while labwc composites with pixman gets shared-memory buffers + // only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe). + // From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU. + if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null + val binds = ArrayList() + for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) { + val f = File(lib, name) + if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null } + binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name")) + } + // Beside the sandbox root, in the sandbox's own directory: removed with it. + val icd = File(root.parentFile, "freedreno_icd.json") + icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n") + binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json")) + env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json" + env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json" + if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) { + env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink" + env["GALLIUM_DRIVER"] = "zink" + } + // The driver's own libraries, found beside it; nothing else is in that directory. + env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":") + return binds + } + + /** + * What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the + * session shim - SysV shared memory for X11, the network calls Android refuses - and the + * controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through + * LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app + * saw no gamepad. Both are built against an older glibc than any Flathub runtime has. + */ + private fun preload(session: List, rootfs: String, env: MutableMap): List { + val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines() + ?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList() + if (libs.isEmpty()) return emptyList() + val binds = ArrayList() + val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } } + env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":") + // Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then + // hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start. + // The controllers: FAKE_EVDEV_DIR is /dev/input, the rings beside it in /dev. + env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev -> + binds.addAll(bindsFor(session, rootfs, dev, dev)) + } + return binds + } + + private const val PRELOAD_DIR = "/run/droiddeck-preload" + + /** + * Browsers sandbox their own children, and neither way works in here. Firefox wants + * namespaces and seccomp from the kernel; its content processes died without them (the + * desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start + * their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses: + * it knows a Flatpak by /proc//root/.flatpak-info, and a proot sandbox's root is the + * host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead. + */ + private fun browserSandboxes(env: MutableMap) { + env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0") + // Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox. + env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1") + // Chromium on Wayland asks the render node the compositor names for its DRM version; + // KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't + // usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every + // session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings. + if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11" + for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX", + "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) { + env.putIfAbsent(name, "1") + } + } + + /** + * /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have + * SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw + * with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first + * shader compiled killed the app with SIGILL. Without a part number LLVM targets generic + * ARMv8 plus the "Features" line, which lists what the kernel really enabled. + */ + private fun genericCpuinfo(context: Context): File? = try { + val out = File(context.cacheDir, "cpuinfo-generic") + val text = File("/proc/cpuinfo").readLines() + .filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") } + .joinToString("\n", postfix = "\n") + // Sandboxes start side by side; one must never read another's half-written copy. + if (!out.isFile || out.readText() != text) { + val staged = File.createTempFile("cpuinfo", null, context.cacheDir) + staged.writeText(text) + if (!staged.renameTo(out)) staged.delete() + } + out + } catch (e: Exception) { + Log.w(TAG, "cpuinfo", e); null + } + + /** [path] with "." and ".." resolved lexically. */ + internal fun normalize(path: String): String { + val out = ArrayList() + for (part in path.split('/')) when (part) { + "", "." -> {} + ".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1) + else -> out.add(part) + } + return "/" + out.joinToString("/") + } + + /** + * [guest] inside the sandbox root, or null when the way there crosses a symlink: one of + * Flatpak's (var/run -> ../run) may point anywhere once followed on the host. + */ + private fun inRoot(root: File, guest: String): File? { + var f = root + for (part in guest.split('/').filter { it.isNotEmpty() }) { + if (part == "..") return null + f = File(f, part) + if (Files.isSymbolicLink(f.toPath())) return null + } + return f + } + + private fun chmod(f: File, mode: Int) { + try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ } + } + + private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) { + val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd) + // DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot + // say what it does, to logcat in full: for a sandbox that dies without a word. + val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull() + if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) } + plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) } + // proot hands its own environment on; the sandbox gets exactly what Flatpak asked for. + cmd.add("/usr/bin/env") + cmd.add("-i") + plan.env.forEach { (k, v) -> cmd.add("$k=$v") } + cmd.addAll(plan.argv) + val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root) + builder.environment().apply { + clear() + put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path) + put("PROOT_TMP_DIR", context.cacheDir.path) + if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1") + LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } + } + val process = builder.start() + val pid = HostProcess.pidOf(process) + // A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting + // meanwhile (OrphanReaper) must not take it - an install's extra-data step, say. + com.droiddeck.launcher.session.OrphanReaper.keep(pid) + Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)") + synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) } + // The stand-in going away (Flatpak killed, the session over) ends the sandbox. + Thread({ + try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } } + catch (e: IOException) { /* closed */ } + if (process.isAlive) { + Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it") + process.destroy() + } + }, "bwrap-$id-watch").apply { isDaemon = true; start() } + val buffer = ByteArray(16384) + // The start of what the sandbox says goes to logcat too: a sandbox that fails at once + // (proot refusing a bind, a missing library) says why there even when nothing reads it. + var logged = 0 + process.inputStream.use { stream -> + while (true) { + val n = try { stream.read(buffer) } catch (e: IOException) { -1 } + if (n < 0) break + if (logged < 4096 || verbose != null) { + String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() } + .forEach { Log.i(TAG, "sandbox $id: $it") } + logged += n + } + try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break } + } + } + val status = process.waitFor() + com.droiddeck.launcher.session.OrphanReaper.release(pid) + Log.i(TAG, "sandbox $id exited $status") + runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } } + } + + private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) { + out.writeByte(kind.code) + out.writeInt(payload.size) + out.write(payload) + out.flush() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt new file mode 100644 index 000000000..0960d95e9 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -0,0 +1,204 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import org.json.JSONObject +import java.io.File + +/** + * Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed + * from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user + * installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit. + * + * Every command runs in a proot of its own, outside any session: the store works whether or not + * Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper). + */ +object FlatpakManager { + private const val TAG = "FlatpakManager" + const val FLATPAK = "/usr/bin/flatpak" + const val BWRAP = "/usr/local/bin/bannerlator-bwrap" + /** The program the front end starts a Flatpak app through, in a session of its own. */ + const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run" + private const val HELPER = "/usr/local/bin/bannerlator-flatpak" + private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup" + private const val USER_DIR = "root/.local/share/flatpak" + + /** One store operation at a time: Flatpak locks its installation per transaction anyway. */ + @Volatile var busy: String? = null + private set + + class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?) + + /** Flatpak is in the runtime, with Flathub to install from. */ + fun ready(context: Context): Boolean { + val root = LinuxRuntime.rootDir(context) + return File(root, FLATPAK.substring(1)).isFile && + File(root, "var/cache/droiddeck-flatpak/installed").isFile && + (FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true) + } + + private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) { + "x86_64" -> "x86_64" + else -> "aarch64" + } + + /** + * The installed apps, read from the installation itself: each app's deployed files carry the + * desktop entry and icons it exports, so listing them needs no process in the runtime. + */ + fun installedApps(context: Context): List { + val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app") + val arch = arch() + return apps.listFiles()?.mapNotNull { dir -> + val id = dir.name + val archDir = File(dir, arch) + val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null + val active = File(branch, "active") + val export = File(active, "export/share") + val desktop = FileUtils.readString(File(export, "applications/$id.desktop")) + val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.') + val summary = desktop?.let { entry(it, "Comment") } + App(id, name, icon(export, id), branch.name, summary) + }?.sortedBy { it.name.lowercase() } ?: emptyList() + } + + fun isInstalled(context: Context, id: String): Boolean = + File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true + + /** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */ + private fun entry(desktop: String, key: String): String? { + var inMain = false + for (line in desktop.lineSequence()) { + val t = line.trim() + if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue } + if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() } + } + return null + } + + /** The largest PNG the app exports (the front end draws no SVG). */ + private fun icon(export: File, id: String): File? { + val hicolor = File(export, "icons/hicolor") + return hicolor.listFiles() + ?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } } + ?.filter { it.second.isFile } + ?.maxByOrNull { it.first }?.second + } + + /** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-.log. */ + private fun runGuest(context: Context, argv: List, fakeRoot: Boolean, onLine: (String) -> Unit): Int { + val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup") + return GuestCommand.run(context, argv, fakeRoot, name, onLine) + } + + private inline fun exclusive(what: String, block: () -> T): T? { + synchronized(this) { + if (busy != null) return null + busy = what + } + try { return block() } finally { busy = null } + } + + /** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */ + fun setup(context: Context, onProgress: (String, Int) -> Unit): String? { + if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first" + return exclusive("setup") { + var failure: String? = null + val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line -> + Log.i(TAG, "setup: $line") + when { + line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1) + line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ") + } + } + when { + failure != null -> failure + status != 0 -> "Flatpak setup failed (exit $status)" + !ready(context) -> "Flatpak setup did not finish" + else -> null + } + } ?: "Another store task is running" + } + + /** + * One store operation - install, uninstall or update - with its progress as a stage line and + * an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app). + */ + private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? { + if (!ready(context)) return "Set up Flatpak first" + return exclusive("$verb:${id ?: "all"}") { + var error: String? = null + // Progress lines carry no ref; they belong to the step the last "op" line started. + var step = "Starting" + val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id) + val status = runGuest(context, argv, fakeRoot = false) { line -> + val o = runCatching { JSONObject(line) }.getOrNull() + if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest } + when (o.optString("e")) { + "op" -> { step = stage(o); onProgress(step, overall(o, 0)) } + "progress" -> onProgress(step, overall(o, o.optInt("percent"))) + "error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") } + "warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}") + } + } + error ?: if (status != 0) "Flatpak exited with status $status" else null + } ?: "Another store task is running" + } + + private fun stage(o: JSONObject): String { + val ref = o.optString("ref") + val name = refLabel(ref) + val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else "" + return when (o.optString("kind")) { + "uninstall" -> "Removing $name$step" + "update" -> "Updating $name$step" + else -> "Installing $name$step" + }.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it } + } + + /** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */ + internal fun refLabel(ref: String): String { + val parts = ref.split('/') + val id = parts.getOrNull(1) ?: return ref + if (parts.firstOrNull() == "app") return id.substringAfterLast('.') + val branch = parts.getOrNull(3)?.let { " $it" } ?: "" + return when { + id.endsWith(".Locale") -> "translations" + ".GL." in id || ".GL32." in id -> "graphics drivers" + "codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs" + id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) { + "gnome", "kde" -> vendor.uppercase() + else -> vendor.replaceFirstChar { it.uppercase() } + } + " runtime$branch" + else -> id.substringAfterLast('.') + } + } + + /** The whole transaction's percentage: steps done, plus this one's share. */ + private fun overall(o: JSONObject, percent: Int): Int { + val n = o.optInt("n").coerceAtLeast(1) + val i = o.optInt("i").coerceIn(1, n) + return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n) + } + + fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress) + fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress) + fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress) + + /** Installed apps with an update on Flathub, or null when that could not be checked. */ + fun updates(context: Context): Set? { + if (!ready(context)) return emptySet() + var apps: Set? = null + exclusive("updates") { + runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line -> + val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest + if (o.optString("e") == "updates") { + val a = o.optJSONArray("apps") + apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet() + } + } + } + return apps + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt new file mode 100644 index 000000000..d420d5771 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +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.SessionPrefs +import java.io.File + +/** + * One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage + * import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper). + */ +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 + * 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 } + } + log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}") + try { + return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) } + } finally { + log?.close() + } + } + + private fun runLogged(context: Context, argv: List, fakeRoot: Boolean, onLine: (String) -> Unit): Int { + val root = LinuxRuntime.rootDir(context) + LinuxRuntime.writeAccounts(context) + SessionFiles.stage(context, root) + // The resolver otherwise names the DNS of whatever network the last session was on. + LinuxNetworkLinkComponent(context, root).publish() + val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() } + val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot) + LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null) + .forEach { cmd.add("-b"); cmd.add(it) } + cmd += listOf( + "/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8", + "PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}", + "XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}", + "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", + ) + cmd += argv + val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true) + builder.environment().apply { + put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path) + put("PROOT_TMP_DIR", context.cacheDir.path) + if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1") + LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) } + } + val process = builder.start() + val pid = HostProcess.pidOf(process) + OrphanReaper.keep(pid) + try { + process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) } + return process.waitFor() + } finally { + OrphanReaper.release(pid) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 19ffe8529..6ad52da27 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -115,6 +115,37 @@ public final class LinuxRuntime { File externalStorage, List extraBinds, List guestCommand) { File root = rootDir(context); + List binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds); + // A session's binds are the view its programs have; a one-off command's are not recorded. + if (sessionRoot != null) lastBinds = binds; + List cmd = prootPrefix(context, root, "/root"); + for (String spec : binds) bind(cmd, spec); + cmd.addAll(guestCommand); + return cmd; + } + + /** + * The binds of the last command built: the guest's view of the host, which the Flatpak + * sandboxes started beside a session (BwrapSpawner) translate their paths through. + */ + private static volatile List lastBinds; + + public static List lastBinds(Context context) { + List binds = lastBinds; + if (binds != null) return binds; + return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null); + } + + /** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */ + public static List prootPrefix(Context context, File root, String cwd) { + return prootPrefix(context, root, cwd, false); + } + + /** + * As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that + * refuse any other uid. Files it writes still belong to the app. + */ + public static List prootPrefix(Context context, File root, String cwd, boolean fakeRoot) { List cmd = new ArrayList<>(); cmd.add(prootBinary(context).getPath()); cmd.add("--kill-on-exit"); @@ -125,12 +156,24 @@ public final class LinuxRuntime { // this the keymap never compiles and Xwayland dies. -i makes proot answer those calls // itself while still reporting our real ids, so nothing inside sees a different user. int uid = Process.myUid(); - cmd.add("-i"); - cmd.add(uid + ":" + uid); + if (fakeRoot) { + cmd.add("-0"); + } else { + cmd.add("-i"); + cmd.add(uid + ":" + uid); + } cmd.add("-r"); cmd.add(root.getPath()); cmd.add("-w"); - cmd.add("/root"); + cmd.add(cwd); + return cmd; + } + + /** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */ + public static List binds(Context context, File sessionRoot, File runtimeDir, + File externalStorage, List extraBinds) { + File root = rootDir(context); + List cmd = new ArrayList<>(); bind(cmd, "/dev"); bind(cmd, "/proc"); bind(cmd, "/sys"); @@ -142,7 +185,7 @@ public final class LinuxRuntime { bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux"); bind(cmd, context.getFilesDir().getPath()); bind(cmd, context.getCacheDir().getPath()); - bind(cmd, runtimeDir.getPath()); + if (runtimeDir != null) bind(cmd, runtimeDir.getPath()); if (sessionRoot != null) bind(cmd, sessionRoot.getPath()); if (externalStorage != null && externalStorage.isDirectory()) { bind(cmd, externalStorage.getPath()); @@ -198,8 +241,9 @@ public final class LinuxRuntime { if (extraBinds != null) { for (String spec : extraBinds) bind(cmd, spec); } - cmd.addAll(guestCommand); - return cmd; + List specs = new ArrayList<>(); + for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1)); + return specs; } /** @@ -328,7 +372,7 @@ public final class LinuxRuntime { } /** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */ - private static String guestUtsname(String hostname) { + static String guestUtsname(String hostname) { StructUtsname host = Os.uname(); return "\\" + host.sysname + "\\" + hostname + "\\" + host.release + "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\"; diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt new file mode 100644 index 000000000..e73668468 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt @@ -0,0 +1,238 @@ +package com.droiddeck.launcher.session + +import android.os.Environment +import com.droiddeck.launcher.frontend.Library +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** + * Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's + * drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at + * "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but + * Public becomes steamuser, and on the way out the chosen layout decides the user segment. + * + * Saves belong to the game's prefix (compatdata//pfx), whichever Proton it runs with. + * Ported from Bannerlator's GameSaveBackup and SaveLocator. + */ +object GameSaves { + enum class Layout(val label: String, val rootPrefix: String, val user: String) { + GAMEHUB("GameHub zip", "/drive_c/", "steamuser"), + WINLATOR("Winlator zip", "drive_c/", "xuser"), + } + + /** A game, its prefix, and the save folders found in it. */ + data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List) { + val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true + } + + /** A folder of saves, relative to the steamuser profile. */ + data class SaveDir(val relPath: String, val files: Int, val bytes: Long) + + private const val USER = "steamuser" + + fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves") + private fun backupsDir(): File = File(savesDir(), "backups") + + /** [game] with its prefix and the saves found there; reads files, so not on the main thread. */ + fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList()) + + // ------------------------------------------------------------------ finding saves + + /** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */ + private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow") + private const val KEEP_THRESHOLD = 50 + + private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER") + + /** + * Folders under the save roots, one and two deep, whose names match the game's name or its folder's. + * Nested matches keep the outer folder only. + */ + fun locate(prefix: File, game: Library.SteamGame): List { + val profile = profile(prefix) + if (!profile.isDirectory) return emptyList() + val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() } + if (ids.isEmpty()) return emptyList() + val hits = LinkedHashMap() + fun consider(dir: File) { + val d = ident(dir.name) + if (d.norm.isEmpty()) return + val s = ids.maxOf { score(it, d) } + if (s < KEEP_THRESHOLD) return + val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/') + if ((hits[rel] ?: -1) < s) hits[rel] = s + } + for (root in SAVE_ROOTS) { + File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 -> + consider(d1) + d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider) + } + } + val kept = ArrayList() + for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) { + if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel) + } + return kept.map { rel -> + var files = 0 + var bytes = 0L + File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() } + SaveDir(rel, files, bytes) + }.filter { it.files > 0 } + } + + private data class Ident(val norm: String, val tokens: Set) + + private fun ident(raw: String): Ident { + val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "") + return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet()) + } + + /** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */ + private fun score(a: Ident, b: Ident): Int { + if (a.norm.isEmpty() || b.norm.isEmpty()) return 0 + if (a.norm == b.norm) return 100 + if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70 + if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() && + a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50 + return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0 + } + + private fun levenshtein(a: String, b: String): Int { + val prev = IntArray(b.length + 1) { it } + val cur = IntArray(b.length + 1) + for (i in 1..a.length) { + cur[0] = i + for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1) + System.arraycopy(cur, 0, prev, 0, cur.size) + } + return prev[b.length] + } + + // ------------------------------------------------------------------ export + + private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date()) + private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" } + + /** + * Zips the game's saves into [dir] as "_.zip" in [layout]. The found save folders when + * there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip. + */ + fun export(game: Game, layout: Layout, dir: File): Pair { + val prefix = game.prefix ?: error("${game.game.name} has not been launched yet") + dir.mkdirs() + val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip") + val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out) + if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") } + return out to count + } + + private fun zipProfile(profile: File, roots: List?, layout: Layout, out: File): Int { + if (!profile.isDirectory) return 0 + val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile) + var count = 0 + ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos -> + for (top in scope) top.walkTopDown() + .onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } } + .filter { it.isFile } + .forEach { f -> + val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/') + zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel")) + f.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + count++ + } + } + return count + } + + // ------------------------------------------------------------------ import + + /** + * Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser + * profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher + * shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null). + */ + fun import(game: Game, zip: File): Pair { + val prefix = game.prefix ?: error("${game.game.name} has not been launched yet") + val driveC = File(prefix, "drive_c") + if (!driveC.isDirectory) error("${game.game.name} has not been launched yet") + if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip") + + backupsDir().mkdirs() + val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip") + val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup) + if (backedUp == 0) backup.delete() + + val base = driveC.canonicalFile + var written = 0 + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { + remap(e.name)?.let { rel -> + val out = File(driveC, rel).canonicalFile + if (out.path.startsWith(base.path + File.separator)) { + if (e.isDirectory) out.mkdirs() + else { + out.parentFile?.mkdirs() + Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING) + written++ + } + } + } + zis.closeEntry() + e = zis.nextEntry + } + } + return written to backup.takeIf { it.exists() } + } + + /** Which layout a zip was written in, from its first user folder; null when it has none. */ + fun layoutOf(zip: File): Layout? = runCatching { + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { + val segs = driveCSegments(e.name) + if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) + return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB + e = zis.nextEntry + } + null + } + }.getOrNull() + + private fun looksLikeSaveZip(zip: File): Boolean = runCatching { + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry } + false + } + }.getOrDefault(false) + + /** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */ + private fun remap(raw: String): String? { + val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null + if (segs.any { it == ".." }) return null + // GameHub also packs its own launcher shortcuts; they are never saves. + if (segs[0].equals("proton_shortcuts", true)) return null + val last = segs.last().lowercase() + if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null + if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER + return segs.joinToString("/") + } + + private fun driveCSegments(raw: String): MutableList? { + val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." } + if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null + return segs.drop(1).toMutableList() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt index 9264a7f6a..9a17a97ae 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OrphanReaper.kt @@ -13,17 +13,28 @@ import java.io.File * force-stop the app before every launch. Everything of ours that should be alive between * sessions is inside the app process, so at session start anything else under our uid is a * leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name. + * + * The one exception is work the app started outside any session and is still waiting on - a + * Flatpak install from the store - registered with [keep]: that process and everything under it + * are left to finish. */ object OrphanReaper { private const val TAG = "OrphanReaper" + private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet() + + /** Spares [pid] and its descendants until [release]. */ + fun keep(pid: Int) { if (pid > 0) kept.add(pid) } + + fun release(pid: Int) { kept.remove(pid) } fun reap(reason: String): Int { val me = android.os.Process.myPid() val uid = android.os.Process.myUid() + val spared = descendants(kept) var killed = 0 File("/proc").listFiles()?.forEach { entry -> val pid = entry.name.toIntOrNull() ?: return@forEach - if (pid == me || pid <= 1) return@forEach + if (pid == me || pid <= 1 || pid in spared) return@forEach if (uidOf(pid) != uid) return@forEach val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" } try { @@ -38,6 +49,25 @@ object OrphanReaper { return killed } + /** [roots] and every process below them, by each process's parent in /proc//stat. */ + private fun descendants(roots: Set): Set { + if (roots.isEmpty()) return emptySet() + val parent = HashMap() + File("/proc").listFiles()?.forEach { entry -> + val pid = entry.name.toIntOrNull() ?: return@forEach + val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach } + // "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it. + stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it } + } + val out = HashSet(roots) + var grew = true + while (grew) { + grew = false + for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true + } + return out + } + private fun uidOf(pid: Int): Int { return try { File("/proc/$pid/status").useLines { lines -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt deleted file mode 100644 index da651314e..000000000 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.droiddeck.launcher.session - -import android.content.Context -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.PerformanceHintManager -import android.util.Log -import androidx.annotation.RequiresApi -import com.droiddeck.launcher.wayland.WaylandCompositor -import java.util.concurrent.Executors - -/** - * ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the - * panel's frame period as its target and each presented frame's interval as the actual. When a - * frame runs long the power HAL raises CPU clocks for the next one at once, instead of the - * governor waiting for the load average to climb - the difference between a menu that stutters - * on the first scroll and one that does not. - * - * The session may only carry this process's threads, so the guest (gamescope, the Steam client - * under FEX) cannot join it; they are covered by the app-wide game category and sustained mode. - * Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns - * null and the log says so. - */ -object PerfHints { - private const val TAG = "PerfHints" - - @Volatile private var session: PerformanceHintManager.Session? = null - @Volatile private var target = 0L - /** Compositor thread only. */ - private var last = 0L - private val main = Handler(Looper.getMainLooper()) - /** Reports leave the compositor thread: each one is a binder call to the power HAL. */ - private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } } - - /** Opens the session once the compositor thread exists (it is started just before this). */ - fun arm(context: Context, refreshHz: Float) { - if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return } - val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong() - val s = session - if (s != null) { - if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } } - return - } - target = wanted - val app = context.applicationContext - var tries = 0 - main.post(object : Runnable { - override fun run() { - if (session != null) return - val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 } - if (tid == 0) { - if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid") - return - } - open(app, tid) - } - }) - } - - @RequiresApi(31) - private fun open(app: Context, tid: Int) { - val pm = app.getSystemService(PerformanceHintManager::class.java) - if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return } - val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null } - if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return } - last = 0L - session = s - Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs") - } - - /** Compositor thread, once per presented game frame; the interval since the last is the actual. */ - @JvmStatic - fun onFrame() { - val s = session ?: return - val now = System.nanoTime() - val prev = last - last = now - if (prev == 0L) return - val dt = now - prev - // Under half a millisecond is a double present; over half a second is a pause, not a frame. - if (dt < 500_000L || dt > 500_000_000L) return - worker.execute { - try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null } - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt index 16d142560..7e99840d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.app.GameManager import android.app.GameState import android.os.Build -import android.os.PowerManager import android.util.Log /** @@ -13,8 +12,10 @@ import android.util.Log * The manifest already makes the app a game (appCategory + game_mode_config); this is the * per-window half, applied when the session activity is created: * - * - sustained performance mode (Android 7+): a clock floor that does not throttle away after - * two minutes, which suits an hour in Big Picture better than a boost that fades; + * - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at + * a level the device can hold indefinitely rather than holding a floor, which cost games and + * Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to + * the game; * - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to * 120 Hz for the menu, not only when a game votes for it; * - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases @@ -27,15 +28,7 @@ object PerfMode { private const val TAG = "PerfMode" fun apply(a: Activity): String { - val parts = ArrayList(3) - - // Sustained performance mode. - parts += if (Build.VERSION.SDK_INT >= 24) { - val pm = a.getSystemService(PowerManager::class.java) - if (pm?.isSustainedPerformanceModeSupported == true) { - try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" } - } else "sustained mode unsupported" - } else "sustained mode needs Android 7" + val parts = ArrayList(2) // The fastest display mode at the panel's current size. parts += try { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 2bb2f1010..ed23b57f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -42,6 +42,13 @@ object SessionFiles { "usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts", "usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games", "usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults", + // Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher. + "usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap", + "usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak", + "usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup", + "usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run", + // The user's own AppImages, extracted at import (AppImageManager). + "usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run", // The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus // the three under /usr/bin, all no-ops that answer "nothing to do" (see each file). "usr/bin/steamos-update" to "usr/bin/steamos-update", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index f82732517..a6a3eafe6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -8,6 +8,8 @@ object SessionPrefs { const val SUSPEND_MANUAL = "manual" const val SUSPEND_NEVER = "never" + const val CONTROLLER_DECK = "deck" + const val CONTROLLER_XBOX360 = "xbox360" const val OSC_AUTO = "auto" const val OSC_ALWAYS = "always" const val OSC_STEAM_QAM = "steam-qam" @@ -28,6 +30,20 @@ object SessionPrefs { prefs(context).edit().putBoolean("launcherFullscreen", on).apply() } + /** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */ + fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false) + + fun setStoreEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("storeEnabled", on).apply() + } + + /** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */ + fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false) + + fun setAppImagesEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("appImagesEnabled", on).apply() + } + fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true) fun setHudEnabled(context: Context, on: Boolean) { @@ -149,8 +165,8 @@ object SessionPrefs { prefs(context).edit().putBoolean("directAudio", on).apply() } - /** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */ - fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true) + /** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */ + fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false) /** Whether the app has already asked for the microphone once at start-up. */ fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false) @@ -194,14 +210,28 @@ object SessionPrefs { * xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation. * Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls * reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured - - * it storms, and the session dies seconds after Big Picture appears. + * it storms, and the session dies seconds after Big Picture appears. On by default: where it + * does run, it sits beside every game under FEX for nothing a controller-first session needs + * (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2). */ - fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false) + fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true) fun setNoXalia(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noXalia", on).apply() } + /** + * Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1, + * which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in + * Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device + * whose GPU is waiting on the CPU. + */ + fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false) + + fun setGamescopeRealtime(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("gamescopeRealtime", on).apply() + } + /** * Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1). * @@ -233,8 +263,13 @@ object SessionPrefs { return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) } } - /** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */ - fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false) + /** + * Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. + * On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the + * client's interface as many small render passes, each paying GMEM's load/store and binning, + * and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3). + */ + fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true) fun setTuSysmem(context: Context, on: Boolean) { prefs(context).edit().putBoolean("tuSysmem", on).apply() @@ -254,11 +289,23 @@ object SessionPrefs { fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true) fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() } + /** + * What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access + * button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain + * Xbox 360 pad of earlier versions (QAM by the Guide+A chord). + */ + fun steamController(context: Context): String = + prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK + fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() } + /** Runs the SteamOS gamepad client with its Quick Access performance controls. */ fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false) fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() } - /** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */ + /** + * Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout + * (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default. + */ fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true) fun setZinkLazy(context: Context, on: Boolean) { @@ -315,8 +362,15 @@ object SessionPrefs { fun resolutionChosen(context: Context, mode: String): Boolean = prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null - /** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */ - fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: "" + /** + * The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. + * Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO + * and full-precision x87, which cost every x86 game time; a game that needs them can still be + * given another preset. + */ + fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET + + private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO" fun setFexPreset(context: Context, id: String) { prefs(context).edit().putString("fexPreset", id).apply() 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 d5de2804e..304e7dad5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -57,6 +57,8 @@ import java.util.Locale */ class SessionService : Service() { private val components = java.util.concurrent.CopyOnWriteArrayList() + /** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */ + private var deckBinds: List = emptyList() private val stopLock = Any() private var wakeLock: PowerManager.WakeLock? = null private var wifiLock: WifiManager.WifiLock? = null @@ -292,6 +294,8 @@ class SessionService : Service() { val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() } val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() + SessionState.deckPad = false + deckBinds = emptyList() if (controllersOn) addControllerEnvironment(guest, fakeInputDir) // 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 @@ -513,7 +517,11 @@ class SessionService : Service() { // Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is // the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships // by default for its Zink path; a switch here because on one Fold the menus run at 14 fps. - if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy") + // compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default). + if (SessionPrefs.zinkLazy(this)) { + guest.add("ZINK_DESCRIPTORS=lazy") + guest.add("ZINK_DEBUG=compact") + } // The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling // thread, no GL error checks, and the client run as SteamOS runs it (the script reads // BL_STEAMDECK; it is the one that builds the command line). @@ -527,9 +535,12 @@ class SessionService : Service() { SessionPrefs.writeForceFullscreenFlag(this) } // Proton's own gate for its xalia helper (its `proton` script reads this, and sets - // XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device - // whose seccomp answers its syscalls normally there is no reason to take it away. + // XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game + // a slice of a core for gamepad navigation the session already has. if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0") + // gamescope's realtime Vulkan queues (the session script turns this into + // GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on. + guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0")) // Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env // holds KEY=VALUE lines that go into the session's environment as written, after ours, so a // line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, @@ -599,6 +610,12 @@ 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) { 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. + fakeInputDir.listFiles()?.forEach { file -> + val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull() + if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete() + } guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path) val rings = FakeInputWriter.getRingEnv(fakeInputDir) if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings") @@ -606,14 +623,34 @@ class SessionService : Service() { // identity gets the standard layout without the user configuring the pad by hand. guest.add("FAKE_EVDEV_IDENTITY=xbox360") guest.add("FAKE_EVDEV_VIBRATION=1") - // Steam Input's virtual-gamepad identity is for games the client starts, which are - // meant to see that pad. Everywhere else (the desktop, a program from the rail) it - // hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so - // every SDL emulator came up with no controller. There it is a plain Xbox 360 pad. - if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") + val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists() + // In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over + // hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read. + // Decided only once its sysfs is in place: without it the client would find no Deck, and + // with the Deck asked for the pad's own nodes are withdrawn - no controller at all. + val wantsDeck = uinput && SessionState.mode == MODE_STEAM && + SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK && + !File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists() + 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") + 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") + } 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 + // rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it + // runs under Steam, so every SDL emulator came up with no controller. + guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") + } guest.add("SDL_JOYSTICK_DISABLE_UDEV=1") guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1") - guest.add("SDL_JOYSTICK_HIDAPI=0") + // 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") } @@ -624,6 +661,7 @@ class SessionService : Service() { private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList { val binds = ArrayList() if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") + if (controllersOn) binds.addAll(deckBinds) // The client's battery readout (the Quick Access Menu, the top bar) reads // /sys/class/power_supply/BAT/..., a laptop's or a Deck's naming; Android's supply is // called "battery" and its files differ, so the client sees no battery at all. A directory @@ -1139,6 +1177,10 @@ class SessionService : Service() { 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. */ + private const val FIRST_VIRTUAL_PAD = 16 const val EXTRA_MODE = "mode" const val MODE_STEAM = "steam" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 21227ba51..71245d3fc 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -63,6 +63,27 @@ object SessionState { * gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */ @JvmStatic var hdr = false + /** The pad is presented to the Steam client as a Steam Deck controller this session + * (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by + * the service as the session starts, which can be after the activity has resumed - hence + * [deckPadListener]. */ + @Volatile private var deckPadValue = false + @JvmStatic var deckPad: Boolean + get() = deckPadValue + set(value) { + if (deckPadValue == value) return + deckPadValue = value + deckPadListener?.invoke() + } + + /** Told (on the thread that changed it) whenever [deckPad] changes. */ + @Volatile var deckPadListener: (() -> Unit)? = null + + /** What the second screen shows, and on which display: kept for the session, so a screen + * turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */ + @Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE + @Volatile var secondScreenDisplay = -1 + /** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */ @Volatile var mode = "steam" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt new file mode 100644 index 000000000..39747df8f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -0,0 +1,105 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File + +/** + * The session's pad, described to the Steam client as a Steam Deck controller. + * + * On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller + * through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it + * itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring); + * what the client also needs is the sysfs it walks to find and identify that node, which a sandbox + * shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and + * returns the binds that put it in place: + * + * - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID + * device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`; + * - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists; + * - `/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. + * + * Symlinks are absolute guest paths; proot resolves them inside the session. + */ +object SteamDeckPad { + private const val TAG = "SessionService" + /** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */ + private const val MAJOR = 240 + private const val MINOR = 16 + private const val NODE = "hidraw$MINOR" + private const val GUEST_DEVICES = "/sys/devices/droiddeck" + private const val USB = "$GUEST_DEVICES/usb1" + private const val INTERFACE = "$USB/1-1:1.2" + private const val HID = "$INTERFACE/0003:28DE:1205.0001" + private const val HIDRAW = "$HID/hidraw/$NODE" + private const val SERIAL = "DROIDDECK0001" + + /** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */ + private val REPORT_DESCRIPTOR = intArrayOf( + 0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, + 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, + 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, + ).map { it.toByte() }.toByteArray() + + /** 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") + val devices = File(base, "devices") + val hidrawClass = File(base, "class-hidraw") + return try { + FileUtils.clear(base) + fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() } + fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text) + fun link(file: File, target: String) { + file.parentFile?.mkdirs() + file.delete() + Os.symlink(target, file.path) + } + + write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n") + write(USB, "idVendor", "28de\n") + write(USB, "idProduct", "1205\n") + write(USB, "bcdDevice", "0100\n") + write(USB, "manufacturer", "Valve Software\n") + write(USB, "product", "Steam Deck Controller\n") + write(USB, "serial", "$SERIAL\n") + link(File(dir(USB), "subsystem"), "/sys/bus/usb") + + write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n") + write(INTERFACE, "bInterfaceNumber", "02\n") + write(INTERFACE, "bInterfaceClass", "03\n") + link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb") + + write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" + + "HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" + + "HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n") + File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR) + link(File(dir(HID), "subsystem"), "/sys/bus/hid") + + write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n") + write(HIDRAW, "dev", "$MAJOR:$MINOR\n") + link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw") + link(File(dir(HIDRAW), "device"), HID) + + hidrawClass.mkdirs() + link(File(hidrawClass, NODE), HIDRAW) + // The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode). + link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW) + + val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() } + File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") + + Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)") + listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw") + } catch (e: Exception) { + Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e") + emptyList() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt b/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt new file mode 100644 index 000000000..91a7ac58c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.store + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.runtime.AppImageManager +import java.io.File + +/** The imported AppImages and the import in progress, for the Desktop page. */ +object AppImageState { + private val main = Handler(Looper.getMainLooper()) + + var items by mutableStateOf>(emptyList()) + private set + /** The file being imported and what the import is doing; null when idle. */ + var importing by mutableStateOf(null) + private set + var stage by mutableStateOf(null) + private set + /** Why the last import was refused, shown on the page until the next one. */ + var lastError by mutableStateOf(null) + private set + + fun refresh(context: Context) { + val app = context.applicationContext + Thread({ + val list = AppImageManager.list(app) + main.post { items = list } + }, "appimage-refresh").start() + } + + fun import(context: Context, file: File) { + if (importing != null) return + val app = context.applicationContext + importing = file.name; stage = "Starting…"; lastError = null + Thread({ + val problem = try { + AppImageManager.import(app, file) { s -> main.post { stage = s } } + } catch (e: Exception) { + e.message ?: e.toString() + } + val list = AppImageManager.list(app) + main.post { + importing = null; stage = null; items = list + lastError = problem?.let { "${file.name}: $it" } + if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show() + } + }, "appimage-import").start() + } + + fun remove(context: Context, id: String) { + val app = context.applicationContext + Thread({ + AppImageManager.remove(app, id) + val list = AppImageManager.list(app) + main.post { items = list } + }, "appimage-remove").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt b/app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt new file mode 100644 index 000000000..536e2aa9e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt @@ -0,0 +1,158 @@ +package com.droiddeck.launcher.store + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an + * app's AppStream data. Only what this device can install is kept - apps built for aarch64. + */ +object FlathubApi { + private const val TAG = "FlathubApi" + private const val BASE = "https://flathub.org/api/v2" + private const val ARCH = "aarch64" + private const val TIMEOUT_MS = 20_000 + + class AppSummary( + val id: String, val name: String, val summary: String, val icon: String?, + val developer: String?, val verified: Boolean, val installs: Int, + ) + + class Screenshot(val thumb: String, val full: String) + + class AppDetails( + val id: String, val name: String, val summary: String, val description: String, + val developer: String?, val icon: String?, val version: String?, val license: String?, + val homepage: String?, val screenshots: List, val categories: List, + val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List, + ) + + /** A category as Flathub's search filters it, with the name shown for it. */ + class Category(val id: String, val label: String) + + val categories = listOf( + Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"), + Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"), + Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"), + Category("Utility", "Utilities"), + ) + + /** Front-page collections: popular, trending, recently-updated, recently-added. */ + fun collection(name: String, count: Int = 30): List? { + // The collections are not filtered by architecture; ask for more and keep what fits. + val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null + return hits(body)?.take(count) + } + + /** Search, optionally within one category (Flathub's main_categories facet). */ + fun search(query: String, category: String? = null, count: Int = 48): List? { + val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH)) + if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category)) + val payload = JSONObject().put("query", query).put("filters", filters) + .put("hits_per_page", count).put("page", 1) + val body = post("$BASE/search", payload.toString()) ?: return null + return hits(body) + } + + /** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */ + fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png" + + fun details(id: String): AppDetails? { + val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null + val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() } + val shots = app.optJSONArray("screenshots")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null + val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) } + .map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") } + .filter { it.second.startsWith("http") }.sortedBy { it.first } + if (all.isEmpty()) null + else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second, + all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second) + } + } ?: emptyList() + val release = app.optJSONArray("releases")?.optJSONObject(0) + val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList() + val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList() + return AppDetails( + id = app.optString("id", id), + name = app.optString("name", id), + summary = app.optString("summary"), + description = plainText(app.optString("description")), + developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" }, + icon = app.optString("icon").takeIf { it.startsWith("http") }, + version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" }, + license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" }, + homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") }, + screenshots = shots, + categories = cats, + runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() }, + downloadSize = summary?.optLong("download_size") ?: 0L, + installedSize = summary?.optLong("installed_size") ?: 0L, + arches = arches, + ) + } + + private fun hits(body: String): List? = try { + val arr = JSONObject(body).getJSONArray("hits") + (0 until arr.length()).mapNotNull { i -> + val o = arr.getJSONObject(i) + val arches = o.optJSONArray("arches") + if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null + if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null + AppSummary( + id = o.optString("app_id"), + name = o.optString("name"), + summary = o.optString("summary"), + icon = o.optString("icon").takeIf { it.startsWith("http") }, + developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" }, + verified = o.optBoolean("verification_verified"), + installs = o.optInt("installs_last_month"), + ) + }.filter { it.id.isNotEmpty() } + } catch (e: Exception) { + Log.w(TAG, "hits: $e"); null + } + + /** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */ + internal fun plainText(html: String): String = html + .replace(Regex("(?i)

  • \\s*"), "• ") + .replace(Regex("(?i)"), "\n") + .replace(Regex("<[^>]+>"), "") + .replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'") + .lines().map { it.trim().replace(Regex("\\s+"), " ") } + .joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim() + + private fun enc(s: String) = URLEncoder.encode(s, "UTF-8") + + private fun get(url: String): String? = request(url, null) + + private fun post(url: String, json: String): String? = request(url, json) + + private fun request(url: String, json: String?): String? { + var c: HttpURLConnection? = null + return try { + c = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("Accept", "application/json") + if (json != null) { + requestMethod = "POST" + doOutput = true + setRequestProperty("Content-Type", "application/json") + outputStream.use { it.write(json.toByteArray()) } + } + } + if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null } + else c.inputStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { + Log.w(TAG, "$url: $e"); null + } finally { + c?.disconnect() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt new file mode 100644 index 000000000..1564194df --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt @@ -0,0 +1,142 @@ +package com.droiddeck.launcher.store + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.droiddeck.launcher.runtime.FlatpakManager + +/** + * The store's state for the life of the process, so an install carries on - and its progress + * shows - while the user moves around the launcher or plays something meanwhile. + */ +object StoreState { + private val main = Handler(Looper.getMainLooper()) + + var ready by mutableStateOf(false) + private set + var installed by mutableStateOf>(emptyList()) + private set + var updates by mutableStateOf>(emptySet()) + private set + var checkingUpdates by mutableStateOf(false) + private set + /** Flathub has been asked this session, so an empty [updates] means up to date. */ + var updatesChecked by mutableStateOf(false) + private set + /** What is running: "setup", an app id, or "update-all"; null when idle. */ + var busy by mutableStateOf(null) + private set + var stage by mutableStateOf(null) + private set + var percent by mutableStateOf(-1) + private set + + /** The front page's sections, by collection name; absent until loaded, empty list on failure. */ + val sections = mutableStateMapOf>() + var sectionsFailed by mutableStateOf(false) + private set + val details = mutableStateMapOf() + val detailsFailed = mutableStateMapOf() + + var searchResults by mutableStateOf?>(null) + private set + var searching by mutableStateOf(false) + private set + private var searchSeq = 0 + + val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated") + + fun refresh(context: Context) { + val app = context.applicationContext + Thread({ + val r = FlatpakManager.ready(app) + val list = if (r) FlatpakManager.installedApps(app) else emptyList() + main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() } + }, "store-refresh").start() + } + + fun loadSections(force: Boolean = false) { + if (!force && sections.isNotEmpty()) return + sectionsFailed = false + Thread({ + var failed = false + for ((key, _) in SECTIONS) { + val apps = FlathubApi.collection(key, 18) + if (apps == null) failed = true + main.post { sections[key] = apps ?: emptyList() } + } + main.post { sectionsFailed = failed } + }, "store-sections").start() + } + + fun loadDetails(id: String) { + if (details.containsKey(id)) return + detailsFailed.remove(id) + Thread({ + val d = FlathubApi.details(id) + main.post { if (d != null) details[id] = d else detailsFailed[id] = true } + }, "store-details").start() + } + + fun search(query: String, category: String?) { + val seq = ++searchSeq + searching = true + Thread({ + val r = FlathubApi.search(query, category) + main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } } + }, "store-search").start() + } + + fun clearSearch() { searchSeq++; searchResults = null; searching = false } + + private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) { + if (busy != null) return + val app = context.applicationContext + busy = what; stage = "Starting…"; percent = -1 + Thread({ + val problem = try { + work(app) { s, p -> main.post { stage = s; percent = p } } + } catch (e: Exception) { + e.message ?: e.toString() + } + val r = FlatpakManager.ready(app) + val list = if (r) FlatpakManager.installedApps(app) else emptyList() + main.post { + busy = null; stage = null; percent = -1 + ready = r; installed = list + if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show() + } + }, "store-$what").start() + } + + fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) } + + fun install(context: Context, id: String, name: String) = + run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) } + + fun uninstall(context: Context, id: String, name: String) = + run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } } + + fun update(context: Context, id: String?, name: String) = + run(context, id ?: "update-all", name) { c, p -> + FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } } + } + + fun checkUpdates(context: Context) { + if (checkingUpdates || busy != null) return + val app = context.applicationContext + checkingUpdates = true + Thread({ + val u = FlatpakManager.updates(app) + main.post { + checkingUpdates = false + if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show() + } + }, "store-updates").start() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt new file mode 100644 index 000000000..e1bce8375 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -0,0 +1,351 @@ +package com.droiddeck.launcher.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RadialGradient +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.droiddeck.launcher.input.DeckControls +import kotlin.math.min + +/** + * The Steam Deck controller's back grips and trackpads, on the second screen. + * + * Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a + * Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a + * row beneath. A trackpad clicks from its bar or from a second finger while the first is down. + * Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only + * where something is being touched. + */ +@SuppressLint("ViewConstructor") +class DeckControlsPanel( + context: Context, + private val onClose: () -> Unit, + private val onSteamMenu: () -> Unit, + private val onQam: () -> Unit, +) : LinearLayout(context) { + private enum class Tab(val label: String) { GRIPS("Grips"), LEFT("Left pad"), RIGHT("Right pad"), BOTH("Both") } + + private val content = FrameLayout(context) + private val tabViews = HashMap() + + init { + orientation = VERTICAL + setBackgroundColor(Color.BLACK) + addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) + addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) + select(selected) + } + + override fun onDetachedFromWindow() { + DeckControls.releaseAll() + super.onDetachedFromWindow() + } + + private fun topBar(): View = LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(10), dp(12), dp(6)) + addView(roundButton("×", "Close second-screen controls", onClose)) + val tabs = LinearLayout(context).apply { + orientation = HORIZONTAL + background = outline(CORNER_PILL, SURFACE) + setPadding(dp(4), dp(4), dp(4), dp(4)) + for (tab in Tab.values()) { + val view = TextView(context).apply { + text = tab.label + textSize = 14f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + gravity = Gravity.CENTER + isSingleLine = true + setPadding(dp(14), 0, dp(14), 0) + setOnClickListener { select(tab) } + } + tabViews[tab] = view + addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)) + } + } + val centre = FrameLayout(context).apply { + addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER)) + } + addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) + addView(roundButton("STEAM", "Open Steam menu", onSteamMenu, wide = true)) + addView(space(dp(8))) + addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true)) + } + + private fun select(tab: Tab) { + selected = tab + DeckControls.releaseAll() + for ((each, view) in tabViews) { + val on = each == tab + view.background = if (on) filled(CORNER_PILL, ACCENT) else null + view.setTextColor(if (on) ON_ACCENT else TEXT_DIM) + } + content.removeAllViews() + content.addView( + when (tab) { + Tab.GRIPS -> gripsGrid() + Tab.LEFT -> singlePad(right = false) + Tab.RIGHT -> singlePad(right = true) + Tab.BOTH -> bothPads() + }, + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT), + ) + } + + private fun gripsGrid(): View = LinearLayout(context).apply { + orientation = HORIZONTAL + setPadding(dp(12), dp(6), dp(12), dp(12)) + fun column(upper: View, lower: View) = LinearLayout(context).apply { + orientation = VERTICAL + addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) }) + addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) }) + } + addView(column(GripButton(context, "L4", "Left · upper", DeckControls.L4), GripButton(context, "L5", "Left · lower", DeckControls.L5)), + LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) }) + addView(column(GripButton(context, "R4", "Right · upper", DeckControls.R4), GripButton(context, "R5", "Right · lower", DeckControls.R5)), + LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) }) + } + + private fun singlePad(right: Boolean): View = LinearLayout(context).apply { + orientation = VERTICAL + setPadding(dp(12), dp(6), dp(12), dp(12)) + addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) }) + addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76))) + } + + private fun bothPads(): View = LinearLayout(context).apply { + orientation = VERTICAL + setPadding(dp(12), dp(6), dp(12), dp(12)) + val pads = LinearLayout(context).apply { + orientation = HORIZONTAL + addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) }) + addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) }) + } + addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) }) + // Mirrored, as hands hold them: the upper grips outside, the lower ones in the middle. + val grips = LinearLayout(context).apply { + orientation = HORIZONTAL + val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4) + row.forEachIndexed { index, (label, bit) -> + addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { + if (index > 0) leftMargin = dp(6) + if (index < row.size - 1) rightMargin = dp(6) + }) + } + } + addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84))) + } + + private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) = + TextView(context).apply { + text = label + contentDescription = description + textSize = if (wide) 13f else 20f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + letterSpacing = if (wide) 0.08f else 0f + gravity = Gravity.CENTER + setTextColor(TEXT) + background = outline(CORNER_PILL, SURFACE) + setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0) + minWidth = dp(44) + layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44)) + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + } + + private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) } + + private fun outline(radius: Float, fill: Int) = GradientDrawable().apply { + cornerRadius = radius * resources.displayMetrics.density + setColor(fill) + setStroke(dp(1), LINE) + } + + private fun filled(radius: Float, fill: Int) = GradientDrawable().apply { + cornerRadius = radius * resources.displayMetrics.density + setColor(fill) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt() + + /** A surface drawn the same way everywhere on the panel; pressed, it lights up in the accent. */ + private abstract class Surface(context: Context) : View(context) { + protected val density = resources.displayMetrics.density + protected val rect = RectF() + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + color = TEXT_DIM + } + + protected fun drawSurface(canvas: Canvas, lit: Boolean) { + val inset = density + rect.set(inset, inset, width - inset, height - inset) + val radius = CORNER * density + fill.color = if (lit) ACCENT_WASH else SURFACE + canvas.drawRoundRect(rect, radius, radius, fill) + stroke.color = if (lit) ACCENT else LINE + stroke.strokeWidth = (if (lit) 2f else 1f) * density + canvas.drawRoundRect(rect, radius, radius, stroke) + } + } + + /** A held button: down while a finger is on it. */ + private open class HoldButton( + context: Context, + private val label: String, + private val caption: String?, + private val onChange: (Boolean) -> Unit, + ) : Surface(context) { + private var down = false + + override fun onDraw(canvas: Canvas) { + drawSurface(canvas, down) + val cx = width / 2f + labelPaint.textSize = min(height * 0.3f, 30 * density) + labelPaint.color = if (down) ACCENT else TEXT + val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f) + canvas.drawText(label, cx, labelY, labelPaint) + if (caption != null) { + captionPaint.textSize = 12 * density + canvas.drawText(caption, cx, labelY + 22 * density, captionPaint) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> set(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false) + } + return true + } + + private fun set(pressed: Boolean) { + if (down == pressed) return + down = pressed + if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onChange(pressed) + invalidate() + } + } + + private class GripButton(context: Context, label: String, caption: String?, bit: Int) : + HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) }) + + private class ClickBar(context: Context, right: Boolean) : + HoldButton(context, "Click", if (right) "Right trackpad" else "Left trackpad", { DeckControls.setClick(right, it) }) + + /** + * One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click + * while a second finger is down. + */ + private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) { + private var touching = false + private var clicked = false + private var fingerX = 0f + private var fingerY = 0f + private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE } + private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT } + private val glow = Paint(Paint.ANTI_ALIAS_FLAG) + + override fun onDraw(canvas: Canvas) { + drawSurface(canvas, clicked) + val cx = width / 2f + val cy = height / 2f + guide.strokeWidth = density + val reach = min(width, height) / 2f - 24 * density + canvas.drawCircle(cx, cy, reach, guide) + canvas.drawCircle(cx, cy, reach / 2f, guide) + canvas.drawLine(cx - reach, cy, cx + reach, cy, guide) + canvas.drawLine(cx, cy - reach, cx, cy + reach, guide) + captionPaint.textSize = 12 * density + captionPaint.color = if (touching) ACCENT else TEXT_DIM + canvas.drawText(if (right) "RIGHT TRACKPAD" else "LEFT TRACKPAD", cx, 28 * density, captionPaint) + if (touching) { + val radius = 56 * density + glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP) + canvas.drawCircle(fingerX, fingerY, radius, glow) + canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + touch(event.getX(0), event.getY(0)) + click(event.pointerCount > 1) + } + MotionEvent.ACTION_POINTER_DOWN -> click(true) + MotionEvent.ACTION_POINTER_UP -> { + // The finger that lifted may be the first; the pad follows whichever stays. + val stays = if (event.actionIndex == 0) 1 else 0 + touch(event.getX(stays), event.getY(stays)) + click(event.pointerCount - 1 > 1) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + click(false) + touching = false + DeckControls.setPad(right, touching = false) + invalidate() + } + } + return true + } + + private fun touch(x: Float, y: Float) { + fingerX = x.coerceIn(0f, width.toFloat()) + fingerY = y.coerceIn(0f, height.toFloat()) + touching = true + DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f)) + invalidate() + } + + private fun click(down: Boolean) { + if (clicked == down) return + clicked = down + if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + DeckControls.setClick(right, down) + invalidate() + } + } + + companion object { + /** The tab last shown, kept for the session so reopening the panel lands where it was. */ + private var selected = Tab.BOTH + + private const val CORNER = 20f + private const val CORNER_PILL = 22f + private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF) + private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF) + private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF) + private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F) + private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D) + private val LINE = Color.rgb(0x26, 0x29, 0x2E) + private val GUIDE = Color.rgb(0x16, 0x18, 0x1B) + private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7) + private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 3d6424001..e480a96a3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -154,6 +154,11 @@ private fun Content( SteamHome(s, a, modifier) return } + // The Store scrolls and navigates inside itself (its tabs and an app's page). + if (selected == "store" && s.storeEnabled) { + StorePage(s, a, modifier) + return + } // The Games tab lays out its own list and detail. if (selected == "games" || selected.startsWith("app:")) { GamesPage(s, a, selected, onSelect, modifier) @@ -192,6 +197,8 @@ private fun Content( Rise(3) { SectionTitle("Emulators", "${installed.size} installed") } Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) } } + if (s.storeEnabled) Rise(5) { InstalledAppsGrid(a) } + if (s.appImagesEnabled) Rise(5) { AppImagesSection(a, s.ready) } if (available.isNotEmpty()) { Rise(5) { SectionTitle("Available to install", available.size.toString()) } Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 523ce958a..3c279aa87 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -120,11 +120,56 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio } g.protonPrefix?.takeIf { it.isDirectory }?.let { dir -> SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) } + // Only games added to the library; Steam titles keep their saves with Steam Cloud. + if (g.library == Library.ADDED) ManageSaves(g, dir, a) } BusyChip(s) } } +/** + * Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as + * either, or open the save folder found for it. The saves are looked for when the menu opens. + */ +@Composable +private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) { + var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) } + val saves by androidx.compose.runtime.produceState?>(null, g.gameId, open) { + if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList()) + } + } + val found = saves + val summary = when { + found == null -> "Looking for this game's saves…" + found.isEmpty() -> "No save folder found by name: an export takes the whole user folder." + else -> { + val mb = found.sumOf { it.bytes } / 1048576.0 + (if (found.size == 1) found[0].relPath else "${found.size} save folders") + " · ${found.sumOf { it.files }} files · " + + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb)) + } + } + Box { + SecondaryButton("Manage saves", compact = true) { open = !open } + AnchoredMenu(open, onDismiss = { open = false }, title = "Game saves", note = summary) { first -> + MenuItem("Import saves…", checked = false, detail = "A GameHub or Winlator zip · the saves there now are backed up first", focusRequester = first) { + open = false; a.onSaveImport(g) + } + MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") { + open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB) + } + MenuItem("Export as Winlator zip…", checked = false, detail = "Saves under xuser, for Winlator, WinNative and Bannerlator") { + open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR) + } + found?.firstOrNull()?.let { d -> + MenuItem("Open save folder", checked = false, detail = d.relPath) { + open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath)) + } + } + } + } +} + /** When it was last played (or where it is, if never) over its name, then room for Launch. */ @Composable private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) { @@ -234,7 +279,7 @@ private fun lastPlayedText(lastPlayed: Long): String? { private fun libraryLabel(library: String): String = when (library) { "internal" -> "Internal storage" - "added" -> "Added game" + Library.ADDED -> "Added game" else -> library } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 41f38fe41..88661a110 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.ui.graphics.vector.ImageVector @@ -145,6 +146,7 @@ internal fun SideRail( // Always there, so the items below it never move; an empty library says how to fill it. RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") } RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } + if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, onFocus = { onFocusSelect("store") }) { onSelect("store") } RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") } // Home mode's extra section goes last, so it shifts nothing above it. if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } 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 d245f0346..680566069 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -114,6 +114,9 @@ class FrontEndState( val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE, val showPhantomGate: Boolean = false, val launcherFullscreen: Boolean = true, + /** Beta features the user turns on in Setup: the Flathub Store and AppImage import. */ + val storeEnabled: Boolean = false, + val appImagesEnabled: Boolean = false, ) class FrontEndActions( @@ -122,6 +125,11 @@ class FrontEndActions( val onSteamGame: (Library.SteamGame) -> Unit, val onDesktop: () -> Unit, val onEmulator: (Library.Emulator) -> Unit, + /** A Flatpak app by id and name, from the Store. */ + val onFlatpakApp: (String, String) -> Unit = { _, _ -> }, + /** Pick an AppImage to import; open an imported one by its directory and name. */ + val onImportAppImage: () -> Unit = {}, + val onAppImage: (String, String) -> Unit = { _, _ -> }, val onRom: (Library.Rom) -> Unit, val onResume: () -> Unit, val onSteamSettings: () -> Unit, @@ -133,6 +141,9 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, + /** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */ + val onSaveImport: (Library.SteamGame) -> Unit = {}, + val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> }, val onPerformance: () -> Unit, val onRoms: () -> Unit, val onFiles: () -> Unit, @@ -144,6 +155,8 @@ class FrontEndActions( val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, + val onStoreEnabled: (Boolean) -> Unit = {}, + val onAppImagesEnabled: (Boolean) -> Unit = {}, val onHomeApp: () -> Unit = {}, val onHomeScreen: (Boolean) -> Unit = {}, val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> }, @@ -350,6 +363,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':') } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } + // The Store turned off in Setup takes its page with it. + LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" } // The last game uninstalled leaves the Games tab on its empty state. LaunchedEffect(s.steamGames.isEmpty()) { if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" 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 a8a225dfe..4ddda06e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -312,6 +312,20 @@ internal fun SetupPanel( ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp) } } + SettingsGroup("Linux apps (beta)") { + ToggleRow( + host, "store-enabled", "Flathub Store", + if (s.storeEnabled) "The Store is in the menu. Some apps may not start; logs are in Download/DroidDeck" + else "Off: install Linux apps and games from Flathub with Flatpak", + s.storeEnabled, + ) { a.onStoreEnabled(it) } + ToggleRow( + host, "appimages-enabled", "AppImages", + if (s.appImagesEnabled) "Add AppImage is on the Desktop page. ARM64 (aarch64) AppImages only" + else "Off: import ARM64 AppImages from your storage", + s.appImagesEnabled, + ) { a.onAppImagesEnabled(it) } + } } else -> { SettingsGroup("About") { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index cbdf0cc78..9a3760703 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -71,6 +71,8 @@ class ModeSettings( val steamChannel: String? = null, /** Steam only: enable the SteamOS client interface and its performance controls. */ val steamDeckMode: Boolean = false, + /** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */ + val steamController: String? = null, /** Steam only: start a Steam session when DroidDeck opens. */ val runSteamAtStartup: Boolean = false, /** Steam only: the user's chosen Games folders; null outside Steam. */ @@ -128,6 +130,7 @@ class ModeSettingsActions( val onStretch16x9: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, val onSteamDeckMode: (Boolean) -> Unit = {}, + val onSteamController: (String) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, val onPickAddedGamesDir: () -> Unit = {}, val onForgetAddedGamesDir: (path: String) -> Unit = {}, @@ -261,6 +264,16 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ), s.oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc, ) + if (steam && s.steamController != null) ChoiceRow( + host, "controller", "Controller", "What your controller is to Steam. Applies next session.", + listOf( + SessionPrefs.CONTROLLER_DECK to "Steam Deck controller", + SessionPrefs.CONTROLLER_XBOX360 to "Xbox 360 controller", + ), s.steamController, + note = "Steam Deck controller: Steam reads it as a Deck's own, with its Quick Access button and the device's gyro. " + + "Xbox 360 controller: the plain pad of earlier versions; Quick Access opens with Guide+A.", + onPick = a.onSteamController, + ) if (steam) ChoiceRow( host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted), listOf( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index 7756cd72f..1083babe2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -37,6 +37,7 @@ fun PerformancePage( glThread: Boolean, noGlError: Boolean, noXalia: Boolean, + gamescopeRealtime: Boolean, prootNoSeccomp: Boolean, guestHostname: String, phantomWarning: String?, @@ -46,6 +47,7 @@ fun PerformancePage( onGlThread: (Boolean) -> Unit, onNoGlError: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, + onGamescopeRealtime: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, @@ -86,7 +88,7 @@ fun PerformancePage( ) ToggleRow( host, "zink", "Zink: lazy descriptors", - "Recommended for drivers without descriptor buffers.", + "Lazy, compact descriptors. Recommended for drivers without descriptor buffers.", zinkLazy, onChange = onZinkLazy, ) ToggleRow( @@ -94,16 +96,21 @@ fun PerformancePage( "Disables per-call GL validation.", noGlError, onChange = onNoGlError, ) + ToggleRow( + host, "gsrealtime", "gamescope: realtime GPU queue", + "Gives the compositor's GPU work priority over the game's. Can smooth frame pacing, but can cost games GPU time.", + gamescopeRealtime, onChange = onGamescopeRealtime, + ) } SettingsGroup("Session fixes") { ToggleRow( host, "sysmem", "Turnip: sysmem rendering", - "Required on Adreno 710/720/722. May fix corruption on other Adreno GPUs, but can reduce performance.", + "On by default: faster for the Steam interface and most games. Required on Adreno 710/720/722.", tuSysmem, onChange = onTuSysmem, ) ToggleRow( host, "xalia", "Skip Steam's xalia helper", - "Disables Proton's gamepad navigation helper. Try if sessions crash at startup.", + "Disables Proton's gamepad navigation helper, which costs every game CPU time. Turn off only if a game needs it.", noXalia, onChange = onNoXalia, ) ToggleRow( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 3a4bdb2ef..4ad50ed2e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -112,6 +112,7 @@ class SecondScreenPresentation( when (currentMode) { SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad() SecondScreenMode.TERMINAL -> renderTerminal() + SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams()) SecondScreenMode.NONE -> Unit } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index d853906a8..f02b05134 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -357,7 +357,9 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") { ChoiceRow(host, "second-screen-mode", "Shows", null, - listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).map { it to it.label }, + (listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) + + (if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList())) + .map { it to it.label }, a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode) if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null, a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt new file mode 100644 index 000000000..da057cb72 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -0,0 +1,556 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.Verified +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.store.FlathubApi +import com.droiddeck.launcher.store.StoreState + +// The Store rail section: Flathub's apps for this device's architecture, installed into the +// Linux runtime with Flatpak, and the ones already installed. + +private val TABS = listOf("Discover", "Search", "Installed") + +@Composable +internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { + val ctx = LocalContext.current + val narrow = LocalNarrowPane.current + val padH = if (narrow) 16.dp else 22.dp + val padV = if (narrow) 12.dp else 18.dp + var tab by rememberSaveable { mutableStateOf(0) } + var openApp by rememberSaveable { mutableStateOf(null) } + var query by rememberSaveable { mutableStateOf("") } + var category by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(s.ready) { StoreState.refresh(ctx) } + LaunchedEffect(Unit) { StoreState.loadSections() } + // A pad's focus sits on something the page is about to replace - the tile that opens an app, + // a tab's contents - and would be lost with it. Each move says where focus goes next. + val ff = LocalFrontFocus.current + val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current + val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } } + var focusMove by remember { mutableStateOf?>(null) } + var lastApp by remember { mutableStateOf(null) } + fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 } + fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 } + BackHandler(enabled = openApp != null) { closeApp() } + val scroll = rememberScrollState() + LaunchedEffect(openApp, tab) { scroll.scrollTo(0) } + LaunchedEffect(focusMove, openApp) { + if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect + val target = when { + openApp != null -> ff?.primary + focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab] + focusMove?.first == "tab" -> tabFocus[tab] + else -> null + } ?: return@LaunchedEffect + var landed = false + focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } } + } + Column( + modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV) + .bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) }, + onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }), + ) { + val id = openApp + if (id != null) { + AppDetail(s, a, id) { closeApp() } + return@Column + } + Rise(0) { + PageHeader("Store") { + Chip("Flathub · ARM64", ok = false) + if (StoreState.ready) Chip("● Flatpak ready", ok = true) + } + } + if (!s.ready) { + Rise(1) { Note("Install the Linux runtime from Setup first; the store installs apps into it.") } + return@Column + } + if (!StoreState.ready) Rise(1) { SetupCard() } + Rise(2) { + TabStrip( + TABS.mapIndexed { i, t -> if (i == 2 && StoreState.installed.isNotEmpty()) "$t · ${StoreState.installed.size}" else t }, + tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus, + ) + } + BusyBar() + when (tab) { + 0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) }) + 1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it }) + else -> Installed(a, onOpen = { openApp = it }) + } + } +} + +/** Flatpak is not in the runtime yet: one button puts it there. */ +@Composable +private fun SetupCard() { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val busy = StoreState.busy == "setup" + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), + ) { + Text("Set up Flatpak", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text( + "Adds Flatpak to the Linux runtime and connects it to Flathub, about 20 MB. Apps then install " + + "with the runtimes they need; the first one is the biggest download.", + fontSize = 14.sp, color = colors.onSurfaceVariant, + ) + Actions { + PrimaryButton(if (busy) "Setting up…" else "Set up Flatpak", enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) } + } + } +} + +/** The running install, update or setup, whichever page it was started from. */ +@Composable +private fun BusyBar() { + val colors = MaterialTheme.colorScheme + val busy = StoreState.busy ?: return + val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name + ?: when (busy) { "setup" -> "Flatpak"; "update-all" -> "Updates"; else -> busy.substringAfterLast('.') } + Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) { + val stage = StoreState.stage + Text( + "$name · " + (if (stage != null && StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage ?: "Starting…"), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 6.dp), + ) + if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) + else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } +} + +@Composable +private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) { + Rise(3) { SectionTitle("Categories", null) } + Rise(3) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) { + FlathubApi.categories.forEach { c -> PillButton(c.label, selected = false) { onCategory(c.id) } } + } + } + if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) { + Rise(4) { Note("Flathub could not be reached. Check the connection and open the Store again.") } + Actions { SecondaryButton("Try again") { StoreState.loadSections(force = true) } } + return + } + StoreState.SECTIONS.forEachIndexed { i, (key, title) -> + val apps = StoreState.sections[key] + Rise(4 + i) { SectionTitle(title, apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: "loading…") } + if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) } + } +} + +@Composable +private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) { + val view = androidx.compose.ui.platform.LocalView.current + val run = { + // The field is an Android EditText; Done leaves its keyboard over the results otherwise. + (view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager) + ?.hideSoftInputFromWindow(view.windowToken, 0) + StoreState.search(query.trim(), category) + } + Rise(3) { + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + AdbTextField( + value = query, onValueChange = onQuery, label = "Search Flathub", keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, placeholder = "Firefox, games, emulators…", modifier = Modifier.weight(1f), onDone = run, + ) + PrimaryButton("Search", main = true, onClick = run) + } + } + Rise(4) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) { + PillButton("All", selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) } + FlathubApi.categories.forEach { c -> + PillButton(c.label, selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) } + } + } + } + val results = StoreState.searchResults + when { + StoreState.searching -> Rise(5) { SectionTitle("Searching…", null) } + results == null -> Rise(5) { Note("Search Flathub by name, or pick a category. Only apps built for ARM64 are listed.") } + results.isEmpty() -> Rise(5) { Note("Nothing on Flathub for ARM64 matches that.") } + else -> { + Rise(5) { SectionTitle("Results", results.size.toString()) } + Rise(6) { AppGrid(results, first = true, onOpen = onOpen) } + } + } +} + +@Composable +private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val apps = StoreState.installed + Rise(3) { + Actions { + SecondaryButton(if (StoreState.checkingUpdates) "Checking…" else "Check for updates", enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) { + StoreState.checkUpdates(ctx) + } + if (StoreState.updates.isNotEmpty()) PrimaryButton("Update all (${StoreState.updates.size})", enabled = StoreState.busy == null) { + StoreState.update(ctx, null, "Updates") + } else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip("● Everything is up to date", ok = true) + } + } + if (apps.isEmpty()) { + Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) "No apps yet. Find some under Discover or Search." else "Set up Flatpak to install apps.") } } + return + } + Rise(4) { SectionTitle("Installed", apps.size.toString()) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + apps.forEachIndexed { i, app -> + key(app.id) { + Rise(5 + i.coerceAtMost(6)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp), + ) { + AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44) + Column(modifier = Modifier.weight(1f)) { + Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + (if (app.id in StoreState.updates) "Update available · " else "") + (app.summary ?: app.id), + fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + PrimaryButton("Open", compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) } + if (app.id in StoreState.updates) SecondaryButton("Update", compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) } + SecondaryButton("Details", compact = true) { onOpen(app.id) } + } + } + } + } + } +} + +private fun s_busyFor(id: String) = StoreState.busy == id + +@Composable +private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + LaunchedEffect(id) { StoreState.loadDetails(id) } + val d = StoreState.details[id] + val local = StoreState.installed.firstOrNull { it.id == id } + val name = d?.name ?: local?.name ?: id + Rise(0) { BackLink("Store", onClick = onBack) } + Rise(1) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) { + AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72) + Column(modifier = Modifier.weight(1f)) { + Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + (d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) } + } + } + } + val busyHere = StoreState.busy == id + val installed = local != null + // Remove asks twice: one stray press of A should not cost a download. The second press has + // to come within a few seconds; the app's own data in ~/.var/app stays either way. + var confirmRemove by remember(id) { mutableStateOf(false) } + LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } } + Rise(2) { + Actions { + if (installed) { + PrimaryButton("Open", enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) } + if (id in StoreState.updates) SecondaryButton("Update", enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) } + SecondaryButton(if (busyHere) "Working…" else if (confirmRemove) "Press again to remove" else "Remove", enabled = StoreState.busy == null) { + if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true + } + } else { + PrimaryButton( + if (busyHere) "Installing…" else "Install", main = true, + enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches), + ) { StoreState.install(ctx, id, name) } + if (!StoreState.ready) ActionChip("Set up Flatpak first", ok = false) + } + d?.version?.let { ActionChip("v$it", ok = false) } + if (d != null && d.downloadSize > 0) ActionChip("${FileUtils.sizeToString(d.downloadSize)} download", ok = false) + if (installed) ActionChip("● Installed", ok = true) + } + } + if (busyHere) BusyBar() + if (d == null) { + Rise(3) { + Box(Modifier.padding(top = 14.dp)) { + Note(if (StoreState.detailsFailed[id] == true) "Flathub's page for this app could not be loaded." else "Loading from Flathub…") + } + } + return + } + if (d.arches.isNotEmpty() && "aarch64" !in d.arches) Rise(3) { Box(Modifier.padding(top = 12.dp)) { Note("Flathub has no ARM64 build of this app, so it cannot be installed here.") } } + if (d.screenshots.isNotEmpty()) { + Rise(3) { SectionTitle("Screenshots", null) } + Rise(3) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + d.screenshots.take(6).forEach { shot -> + AsyncImage( + model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.height(if (narrow) 150.dp else 190.dp).aspectRatio(16f / 9f).clip(Shape12).background(colors.surface), + ) + } + } + } + } + if (d.description.isNotEmpty()) { + Rise(4) { SectionTitle("About", null) } + Rise(4) { Text(d.description, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onBackground, modifier = Modifier.fillMaxWidth()) } + } + Rise(5) { SectionTitle("Details", null) } + Rise(5) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 16.dp)) { + listOfNotNull( + "App ID" to d.id, + d.license?.let { "License" to it }, + d.runtime?.let { "Runtime" to it.replace("/x86_64/", "/aarch64/") }, + if (d.installedSize > 0) "Installed size" to FileUtils.sizeToString(d.installedSize) + " (plus its runtime, shared between apps)" else null, + d.homepage?.let { "Website" to it }, + ).forEach { (k, v) -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(k, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(110.dp)) + Text(v, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.weight(1f)) + } + } + } + } +} + +/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */ +@Composable +private fun AppGrid(apps: List, first: Boolean, onOpen: (String) -> Unit) { + val columns = if (LocalNarrowPane.current) 1 else 3 + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { + apps.chunked(columns).forEachIndexed { r, row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + row.forEachIndexed { i, app -> + key(app.id) { + AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) } + } + } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale") + val installed = StoreState.installed.any { it.id == app.id } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(Shape14) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + AppIcon(app.icon, app.name, 44) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = "Verified", tint = pal.signal, modifier = Modifier.size(14.dp)) + } + Text(if (installed) "Installed" else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + +/** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */ +@Composable +private fun AppIcon(model: Any?, name: String, size: Int) { + val colors = MaterialTheme.colorScheme + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(RoundedCornerShape((size / 5).dp)).background(if (model == null) colors.surfaceVariant else Color.Transparent)) { + if (model == null) Icon(Icons.Outlined.Apps, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size((size / 2).dp)) + else AsyncImage(model = model, contentDescription = name, contentScale = ContentScale.Fit, modifier = Modifier.size(size.dp)) + } +} + +/** A category pill: small, focusable, and filled when it is the one in use. */ +@Composable +private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Text( + label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.paneItem("pill:$label").clip(shape) + .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 9.dp), + ) +} + +/** The apps installed from the Store, as launch tiles - on the Desktop page beside the emulators. */ +@Composable +internal fun InstalledAppsGrid(a: FrontEndActions) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { StoreState.refresh(ctx) } + val apps = StoreState.installed + if (apps.isEmpty()) return + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val columns = if (LocalNarrowPane.current) 1 else 3 + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + SectionTitle("Apps from the Store", apps.size.toString()) + apps.chunked(columns).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + row.forEach { app -> + key(app.id) { + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}") + .clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44) + Column(modifier = Modifier.weight(1f)) { + Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(app.summary ?: "Flatpak", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** + * The user's own AppImages on the Desktop page: an Add button, each one to open, and Remove + * (pressed twice, like the store's). The picker, the check and the extraction live elsewhere + * (MainActivity, AppImageManager); this only shows them. + */ +@Composable +internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) { + val ctx = LocalContext.current + LaunchedEffect(Unit) { com.droiddeck.launcher.store.AppImageState.refresh(ctx) } + val state = com.droiddeck.launcher.store.AppImageState + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + SectionTitle("AppImages", state.items.size.takeIf { it > 0 }?.toString()) + Actions { + SecondaryButton(if (state.importing != null) "Importing…" else "Add AppImage", enabled = runtimeReady && state.importing == null) { a.onImportAppImage() } + if (!runtimeReady) ActionChip("Runtime required", ok = false) + } + state.importing?.let { name -> + Text("$name · ${state.stage ?: "Starting…"}", fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } + state.lastError?.let { Note(it) } + if (state.items.isEmpty() && state.importing == null && state.lastError == null) { + Note("Add an ARM64 (aarch64) AppImage from your storage. It is unpacked into the Linux runtime once, then opens full screen from here or from the desktop's menu.") + } + state.items.forEach { item -> + key(item.id) { + var confirm by remember(item.id) { mutableStateOf(false) } + LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } } + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(end = 12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) + .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + AppIcon(item.icon, item.name, 44) + Column(modifier = Modifier.weight(1f)) { + Text(item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(item.comment ?: "AppImage", fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + SecondaryButton(if (confirm) "Press again to remove" else "Remove", compact = true) { + if (confirm) { confirm = false; com.droiddeck.launcher.store.AppImageState.remove(ctx, item.id) } else confirm = true + } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 2d944ab41..52cde55e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -344,7 +344,7 @@ private fun WirelessStepForm( } @Composable -private fun AdbTextField( +internal fun AdbTextField( value: String, onValueChange: (String) -> Unit, label: String, diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index 8f72523d9..973332542 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -41,6 +41,10 @@ object CompositorHost { } WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight) WaylandCompositor.nativeSetOutputRefreshRate(refreshHz) + // The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate + // already being achieved, and on a phone whose vendor picks clocks from that a session + // that has been slowed reads as one that wants to be. (WinNative, WaylandSession.) + WaylandCompositor.nativeSetLayerFrameRate(refreshHz) WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, ) diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index dd29b6ad1..048ff2ef1 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -91,7 +91,6 @@ public final class WaylandCompositor { long smoothed = frameIntervalNanos; frameIntervalNanos = smoothed == 0 ? interval : (smoothed * 3 + interval) / 4; } - com.droiddeck.launcher.session.PerfHints.onFrame(); GameListener l = gameListener; if (l != null) l.onGameFrame(); } @@ -218,7 +217,7 @@ public final class WaylandCompositor { * once per tick, so games run unthrottled and the screen shows their latest frame. */ public static native void nativeVsync(long frameTimeNanos); - /** The compositor thread's Linux tid, or 0 before it has started (see PerfHints). */ + /** The compositor thread's Linux tid, or 0 before it has started. */ public static native int nativeCompositorTid(); /** Shortcut launches: don't draw explorer's windows (desktop, taskbar, Start menu), matching the diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt new file mode 100644 index 000000000..1cd246ab4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AppImageManagerTest { + private lateinit var dir: File + + @Before fun setUp() { dir = Files.createTempDirectory("appimage").toFile() } + @After fun tearDown() { dir.deleteRecursively() } + + /** An ELF header with the AppImage magic at byte 8 and [machine] at byte 18. */ + private fun image(machine: Int, type: Int = 2, magic: Boolean = true): File { + val b = ByteArray(64) + b[0] = 0x7f; b[1] = 'E'.code.toByte(); b[2] = 'L'.code.toByte(); b[3] = 'F'.code.toByte() + if (magic) { b[8] = 'A'.code.toByte(); b[9] = 'I'.code.toByte(); b[10] = type.toByte() } + b[18] = (machine and 0xff).toByte(); b[19] = (machine shr 8).toByte() + return File(dir, "x.AppImage").apply { writeBytes(b) } + } + + @Test fun acceptsAnArm64TypeTwoImage() = assertNull(AppImageManager.problem(image(183))) + + @Test fun refusesX86WithAHint() = assertTrue(AppImageManager.problem(image(62))!!.contains("x86_64")) + + @Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2")) + + @Test fun refusesAPlainProgram() = assertTrue(AppImageManager.problem(image(183, magic = false))!!.contains("not an AppImage")) + + @Test fun refusesSomethingElse() { + val f = File(dir, "notes.AppImage").apply { writeText("hello, this is not a program at all") } + assertTrue(AppImageManager.problem(f)!!.contains("not an AppImage")) + } + + @Test fun idsAreTidyAndUnique() { + assertEquals("some-app-1-2-aarch64", AppImageManager.idFor("Some_App-1.2-aarch64.AppImage", emptySet())) + assertEquals("some-app-2", AppImageManager.idFor("Some App.AppImage", setOf("some-app"))) + } + + @Test fun readsTheMainDesktopGroup() { + val entry = "[Desktop Entry]\nName=Duck\nName[de]=Ente\nIcon=duck\n[Desktop Action x]\nName=Other\n" + assertEquals("Duck", AppImageManager.desktopKey(entry, "Name")) + assertEquals("duck", AppImageManager.desktopKey(entry, "Icon")) + assertNull(AppImageManager.desktopKey(entry, "Comment")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt new file mode 100644 index 000000000..364c3ef9e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt @@ -0,0 +1,127 @@ +package com.droiddeck.launcher.runtime + +import android.util.Base64 +import org.robolectric.RuntimeEnvironment +import com.droiddeck.launcher.runtime.BwrapSpawner.Bind +import java.io.File +import java.nio.file.Files +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BwrapSpawnerTest { + private val session = listOf( + Bind("/dev", "/dev"), + Bind("/cache/shm", "/dev/shm"), + Bind("/proc", "/proc"), + Bind("/rootfs/etc/bannerlator/proc/stat", "/proc/stat"), + Bind("/data/files", "/data/files"), + ) + + @Test fun underRespectsPathBoundaries() { + assertTrue(BwrapSpawner.under("/usr/lib", "/usr")) + assertTrue(BwrapSpawner.under("/usr", "/usr")) + assertFalse(BwrapSpawner.under("/usrlocal", "/usr")) + assertTrue(BwrapSpawner.under("/anything", "/")) + } + + @Test fun hostPathGoesThroughTheMostSpecificSessionBind() { + assertEquals("/cache/shm/x", BwrapSpawner.hostPath(session, "/rootfs", "/dev/shm/x")) + assertEquals("/dev/null", BwrapSpawner.hostPath(session, "/rootfs", "/dev/null")) + assertEquals("/data/files/.wayland-rt/wayland-1", BwrapSpawner.hostPath(session, "/rootfs", "/data/files/.wayland-rt/wayland-1")) + } + + @Test fun hostPathFallsBackToTheRootfs() { + assertEquals("/rootfs/root/.local/share/flatpak", BwrapSpawner.hostPath(session, "/rootfs/", "/root/.local/share/flatpak")) + } + + @Test fun bindingDevBringsTheSessionsStandInsAlong() { + val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/dev", "/dev") + assertEquals(Bind("/dev", "/dev"), binds.first()) + assertTrue(Bind("/cache/shm", "/dev/shm") in binds) + assertFalse(binds.any { it.guest.startsWith("/proc") }) + } + + @Test fun nestedBindsFollowARenamedDestination() { + val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/proc", "/newproc") + assertTrue(Bind("/rootfs/etc/bannerlator/proc/stat", "/newproc/stat") in binds) + } + + @Test fun normalizeResolvesDotsLexically() { + assertEquals("/usr/lib/x", BwrapSpawner.normalize("/usr/lib/GL/../x")) + assertEquals("/", BwrapSpawner.normalize("/../..")) + } + + @Test fun bindSpecsRoundTrip() { + assertEquals(Bind("/a", "/b"), Bind.parse("/a:/b")) + assertEquals(Bind("/a", "/a"), Bind.parse("/a")) + assertEquals("/a", Bind("/a", "/a").spec) + assertEquals("/a:/b", Bind("/a", "/b").spec) + } +} + +/** The sandbox a Flatpak request describes, built on disk under a temporary directory. */ +@RunWith(RobolectricTestRunner::class) +class BwrapSpawnerPlanTest { + private lateinit var dir: File + private lateinit var runtime: File + + @Before fun setUp() { + dir = Files.createTempDirectory("bwrap-plan").toFile() + // Flatpak names the runtime by its path inside the rootfs. + runtime = File(LinuxRuntime.rootDir(RuntimeEnvironment.getApplication()), "runtime-files") + File(runtime, "lib").mkdirs() + } + + @After fun tearDown() { + dir.deleteRecursively() + runtime.deleteRecursively() + } + + private fun op(vararg pairs: Pair) = JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } } + + private fun request(vararg ops: JSONObject) = JSONObject() + .put("argv", JSONArray().put("/app/bin/game")) + .put("env", JSONObject().put("FLATPAK_ID", "org.example.Game")) + .put("cwd", "/app") + .put("ops", JSONArray().apply { ops.forEach { put(it) } }) + + @Test fun buildsTheTreeFlatpakAsksFor() { + val context = RuntimeEnvironment.getApplication() + val info = Base64.encodeToString("[Application]\nname=org.example.Game\n".toByteArray(), Base64.NO_WRAP) + val plan = BwrapSpawner.plan(context, request( + op("t" to "bind", "src" to "/runtime-files", "dest" to "/usr", "ro" to true), + op("t" to "symlink", "target" to "usr/lib", "dest" to "/lib"), + op("t" to "dir", "dest" to "/usr/lib/GL", "empty" to true), + op("t" to "symlink", "target" to "default/lib/libEGL.so", "dest" to "/usr/lib/GL/libEGL.so"), + op("t" to "dir", "dest" to "/tmp"), + op("t" to "file", "dest" to "/.flatpak-info", "data" to info, "perms" to 0x180), + op("t" to "bind", "src" to "/nonexistent-${System.nanoTime()}", "dest" to "/run/x", "try" to true), + ), dir) + + // The runtime is bound at /usr, not written into. + assertTrue(plan.binds.any { it.guest == "/usr" && File(it.host).canonicalPath == runtime.canonicalPath }) + assertFalse(File(runtime, "lib/GL").exists()) + // The tmpfs under /usr is a directory of ours, and the link Flatpak makes inside it lands there. + val gl = plan.binds.single { it.guest == "/usr/lib/GL" } + assertTrue(gl.host.startsWith(dir.path)) + assertEquals("default/lib/libEGL.so", Files.readSymbolicLink(File(gl.host, "libEGL.so").toPath()).toString()) + // Top-level links, directories and files are made in the sandbox root itself. + assertEquals("usr/lib", Files.readSymbolicLink(File(plan.root, "lib").toPath()).toString()) + assertTrue(File(plan.root, "tmp").isDirectory) + assertEquals("[Application]\nname=org.example.Game\n", File(plan.root, ".flatpak-info").readText()) + // A --bind-try whose source is missing is left out. + assertFalse(plan.binds.any { it.guest == "/run/x" }) + assertEquals(listOf("/app/bin/game"), plan.argv) + assertEquals("/app", plan.cwd) + assertEquals("org.example.Game", plan.env["FLATPAK_ID"]) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt index 1fb648e73..604c9456a 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -49,6 +49,15 @@ class GameEnvironmentStoreTest { assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS")) } + @Test fun unchosenFexPresetPublishesPerformanceTso() { + GameEnvironmentStore.save(context, GameEnvironment.Config()) + val shared = JSONObject(guest.readText()).getJSONObject("shared") + assertEquals("1", shared.getString("FEX_TSOENABLED")) + assertEquals("0", shared.getString("FEX_HALFBARRIERTSOENABLED")) + assertEquals("1", shared.getString("FEX_X87REDUCEDPRECISION")) + assertEquals("1", shared.getString("FEX_MULTIBLOCK")) + } + @Test fun invalidDataCannotReplaceSavedConfiguration() { val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok")) GameEnvironmentStore.save(context, original) diff --git a/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt b/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt new file mode 100644 index 000000000..5aa2dfc2f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt @@ -0,0 +1,20 @@ +package com.droiddeck.launcher.store + +import com.droiddeck.launcher.runtime.FlatpakManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlathubApiTest { + @Test fun appstreamDescriptionsBecomeLines() { + val html = "

    A game & more.

    • Fast
    • Free

    Enjoy

    " + assertEquals("A game & more.\n• Fast\n• Free\n\nEnjoy", FlathubApi.plainText(html)) + } + + @Test fun refsReadAsWords() { + assertEquals("SuperTux", FlatpakManager.refLabel("app/org.supertuxproject.SuperTux/aarch64/stable")) + assertEquals("GNOME runtime 50", FlatpakManager.refLabel("runtime/org.gnome.Platform/aarch64/50")) + assertEquals("Freedesktop runtime 26.08", FlatpakManager.refLabel("runtime/org.freedesktop.Platform/aarch64/26.08")) + assertEquals("graphics drivers", FlatpakManager.refLabel("runtime/org.freedesktop.Platform.GL.default/aarch64/26.08")) + assertEquals("translations", FlatpakManager.refLabel("runtime/org.gnome.Platform.Locale/aarch64/50")) + } +} diff --git a/docs/development/flatpak.md b/docs/development/flatpak.md new file mode 100644 index 000000000..006bab55a --- /dev/null +++ b/docs/development/flatpak.md @@ -0,0 +1,118 @@ +# Flatpak and the Store + +Both features are beta and off by default. Setup → Launcher → "Linux apps (beta)" has one toggle for the +Flathub Store, which shows the Store rail item and the Store's apps on the Desktop page, and one for +AppImages, which shows the AppImages section. Turning either off hides it; installed apps stay installed +and remain in the Linux desktop's menu. + +The Store rail section installs apps from Flathub into the Linux runtime with Flatpak. Only +ARM64 builds are listed. Everything lives in one per-user installation at +`/root/.local/share/flatpak`, so nothing needs Flatpak's system helper or polkit. + +## Putting Flatpak in the runtime + +`bannerlator-flatpak-setup` runs under proot's fake root (`-0`), because pacman and pacman-key +refuse any other uid. The runtime's pacman database lists only the base image. The desktop +and emulator packages were unpacked over it without registering. A plain `pacman -S flatpak` +would therefore reinstall about a hundred packages, Mesa among them, over the runtime's KGSL +Turnip build, and run every hook in the image, mkinitcpio included. So pacman, with a +database of its own under `/var/cache/droiddeck-flatpak`, only downloads the nine packages +Flatpak adds to this image and checks their signatures against the Arch Linux ARM keyring. +The script then unpacks them without hooks and checks what the binaries link. Libraries that +the Desktop package normally brings (PyGObject, json-glib, fuse3 and others) are fetched too, +but only when their files are missing. Flathub is added as a per-user remote from a copy of +its `.flatpakrepo` carried in the script, so that step needs no network. Every store command +writes its output to `Download/DroidDeck/flatpak-.log`, next to the session logs. The +setup script also puts Flatpak's own error text in the failure message the app shows. + +## bubblewrap without namespaces + +Android gives apps no user namespaces, so `bwrap` cannot work. Flatpak is pointed at +`bannerlator-bwrap` instead (`FLATPAK_BWRAP`, exported by `bannerlator-session` and by the +store's commands). It reads bwrap's command line, including `--args` fds and the `--file` and +`--bind-data` payloads, and then does one of two things: + +- A sandbox that remaps nothing runs directly. Flatpak's install triggers (`--ro-bind / /`) and + the D-Bus proxy (every top-level directory bound onto itself) are the cases, and they keep the + file descriptors Flatpak handed them. +- Anything else is described to the app over the abstract socket + `com.droiddeck.launcher.bwrap`, which serves only the app's own uid. `BwrapSpawner` builds the + tree under `cache/bwrap/`: a root directory for top-level links, directories and files, + scratch directories for `--tmpfs` inside a bind, and every `--bind` translated from guest + to host paths through the session's own binds. It then starts it as a proot of its own + beside the session's. Output, the pid (for `--info-fd`) and the exit status flow back, and + the stand-in exiting kills the sandbox. + +A proot nested in the session's proot would also work, but every system call then goes +through two tracers, about thirty times slower at a program's start. + +The spawner also adds what the rootfs gives its own programs: + +- **GPU.** Flathub's Mesa has Turnip only for DRM, and Adreno on Android is KGSL. The runtime's + own `libvulkan_freedreno.so` is bound in with the three libraries the Freedesktop runtime + lacks (`libdisplay-info`, SPIRV-Tools). Vulkan uses it, and Mesa's GL runs on it through Zink + (`MESA_LOADER_DRIVER_OVERRIDE=zink`). This is skipped for an app drawing into a desktop + composited by pixman, where Zink cannot present. +- **Controllers.** The libraries in the rootfs's `/etc/ld.so.preload` (the session shim and the + fake evdev reader) go in through `LD_PRELOAD`, with the session's `dev` directory. +- **Browsers.** Firefox's child sandboxes are switched off (`MOZ_DISABLE_*_SANDBOX`), and + Chromium and Electron apps use zypak's mimic strategy (`ZYPAK_ZYGOTE_STRATEGY_SPAWN=0`). The + Flatpak portal's Spawn refuses a caller whose `/proc//root` holds no `.flatpak-info`, + and a proot sandbox's root is the host's. +- **CPU.** `/proc/cpuinfo` without the cores' part numbers. Snapdragon's ARMv9 cores imply SVE2 + to LLVM, Qualcomm leaves SVE off, and llvmpipe's first shader died with SIGILL. + +## Running apps + +The front end starts an app as a run-mode session of `bannerlator-flatpak-run `, full +screen under gamescope. Under gamescope it passes `--nosocket=wayland --socket=x11` and sets +`XDG_SESSION_TYPE=x11`. Otherwise Flatpak finds the app compositor's `wayland-0` and the window +opens behind gamescope. gamescope's own Wayland socket is no alternative: Chromium on Wayland +asks the render node it names for a DRM version, which KGSL cannot give, and aborts. The +launcher also starts a session bus when there is none. On the Linux desktop, the apps' exported +menu entries appear in the LXQt menu. While the desktop is composited by pixman, games are +wrapped through `droiddeck-gpu` like the rootfs's own. + +## The store + +`FlathubApi` reads flathub.org's public API: collections, search filtered to `aarch64`, and +AppStream details. `bannerlator-flatpak` drives libflatpak through PyGObject and prints one +JSON object per line (`op`, `progress`, `error`, `done`). `FlatpakManager` turns those lines +into the store's progress bar. Store commands run in a proot of their own, and +`OrphanReaper` spares them when a session starts. + +## AppImages + +"Add AppImage" on the Desktop page imports an ARM64, type 2 AppImage from storage. `AppImageManager` +checks its ELF header first, so an x86_64 image is refused with a message saying so. It then copies the +image in, because shared storage is noexec, and extracts it once to `/opt/appimages/user//app`. +proot has no FUSE, and extracting at every start would cost the whole image each time. The name, +comment and icon come from the image's own desktop entry. A menu entry goes to +`/usr/local/share/applications`, where the desktop's GPU wrapper picks it up. + +`bannerlator-appimage-run` starts it: + +- `APPIMAGE` stays unset, so apps don't offer to add themselves to the menu or update in place. +- Firefox forks get the same sandbox switches as Flatpaks. +- Electron apps (a `chrome-sandbox` beside them) get `--no-sandbox` and X11. +- A session bus is started when the session has none. +- The launcher waits while anything from the image still runs, because Electron apps relaunch themselves. + +Known to fail: + +- Obsidian's own AppImage traps (SIGTRAP) after startup. Its Flatpak works. +- Zen's AppImage hangs in Firefox's startup GPU probe. The Firefox Flatpak works. + +## x86_64 apps + +Flathub lists x86_64-only apps (Heroic, Discord, Spotify, Steam), and the store hides them. Running them +would need an x86 emulator in the sandbox. There's no FEX or box64 package in Arch Linux ARM; Valve's +ARM64 Steam can install its FEX compatibility tool, but only through the Steam client. With a FEX binary +available, the path would be: + +1. `flatpak install --arch=x86_64`. Flatpak accepts it, and the x86 runtime is also the x86 root FEX needs. +2. `BwrapSpawner` starts those sandboxes with `proot -q`, through a small wrapper that runs FEXInterpreter. + `-q` passes QEMU-style arguments. +3. GPU drivers would need FEX's thunks, or Mesa runs emulated on the CPU. + +x86 AppImages would also need an x86 rootfs for the libraries they don't bundle. diff --git a/docs/progress-log.md b/docs/progress-log.md index 25db70de0..1b00fee7c 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -7,6 +7,47 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d --- +## 2026-09-29 - `feat/controller-input`: the pad the way SteamOS has it + +Device-tested on the AYN Thor (Katamari under Proton Experimental ARM64). + +- **Why**: the client read the pad as an Xbox 360 controller, and games read the *same* node + wearing Steam Input's virtual identity (28de:11ff), because the client's own virtual pad needs + `/dev/uinput` and never appeared. Steam Input's layouts therefore never reached a game, and the + QAM was a timed Guide+A chord. InputPlumber itself cannot run in the sandbox (root, uinput, uhid, + udev, D-Bus), so what it provides is emulated in the interposer instead. +- **`/dev/uinput` stand-in** (`FAKE_EVDEV_UINPUT=1`, libfakeinput): a gamepad the client makes + becomes `/dev/input/event16+`, backed by a ring of the app's own format, with the client's name, + ids and bits; its writes are that ring's events. Other devices (virtual keyboard and mouse) are + accepted and dropped. `inputudev.c` describes these nodes to Wine's HID bus from a `.uevent` file + beside each one, and while one exists describes the app's pads as the Xbox 360 pad they are, so + Wine reads only Steam Input's output. Verified: the client makes "Microsoft X-Box 360 pad 0", + re-makes it at game launch, and the game binds it (`Controller 0 uses xinput : true`). +- **Steam Deck controller** (`FAKE_EVDEV_DECK=1`, Steam sessions): `/dev/hidraw16`, found through a + sysfs tree `SteamDeckPad.kt` binds in (usb_device → interface 2 → hid 28de:1205 → hidraw), streams + the Deck's 64-byte state report every 4 ms from ring 0 and answers feature reports as InputPlumber + does. systemd 261's libudev refuses a syspath not on sysfs, so `fstatfs`/`statfs` report sysfs + for that tree. The app's evdev nodes are withdrawn while it is on, and only the `steam` process + sees the Deck. QAM is a real button (snapshot bit 11). Verified: the client lists a Steam Deck + Controller (V1 HID protocol) and runs its handshake (0x83, 0xAE, 0x81/0x87 lizard off, 0x8F). +- **Setting**: Steam page → Touch & controls → **Controller**: *Steam Deck controller* (default) or + *Xbox 360 controller* (the pad of earlier versions, QAM by Guide+A; games still get Steam Input's + virtual pad). Debug switches: `droiddeck-no-uinput` (back to the 28de:11ff disguise), + `droiddeck-no-deck-pad` (forces the Xbox 360 pad). +- **Gyro**: `PadMotion.kt` feeds the handheld's own gyro and accelerometer (4 ms sampling, while + the session is on screen) into an IMU block after ring 0's events, turned to the screen and then + to the Deck's axes and units; the Deck report carries them. Verified: Steam's Gyro Calibration + page moves with the Thor (sh5001 IMU); at rest the accelerometer reads 1 g. +- **Deck grips + trackpads on the second screen** (`DeckControlsPanel.kt`, offered while the pad + is a Deck controller): tabs for the four grips (2x2), either trackpad with a click bar, and both + trackpads over the grips in a row; a second finger on a trackpad clicks it. True black for OLED. + Grips, pad positions, touch, click and pressure go through the same block after ring 0's events + as the gyro (`DeckControls.kt`) into the Deck report. +- **Not yet**: a Bluetooth pad's own IMU (DualSense); the + game's rumble on the virtual pad goes straight to the vibrator rather than back through the client. +- **Prior art checked**: WinNative and Bannerlator never emulated uinput or hidraw; Bannerlator's + `-steamdeck` mode stopped at "the virtual pad never arrives", which is this missing uinput. + ## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds) diff --git a/tools/build_local.sh b/tools/build_local.sh index ec3028de3..484e1f47b 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -89,7 +89,17 @@ linuxfs_replaced=1 rm -rf -- "${linuxfs_dir}" mkdir -p "${linuxfs_dir}" -if ! docker image inspect "${image_name}" >/dev/null 2>&1; then +# Docker Desktop's VM restarts now and then, and until its engine has loaded its image store it +# answers "No such image" for images it has. The rebuild that follows hangs on the registry +# (the base image's credentials go through docker-credential-desktop), so give a restarting daemon +# up to a minute before deciding the image is really missing. +image_present=0 +for _ in $(seq 1 30); do + if inspect_error=$(docker image inspect "${image_name}" 2>&1 >/dev/null); then image_present=1; break; fi + sleep 2 +done +if [[ "${image_present}" = 0 ]]; then + echo "Build image ${image_name} not found (${inspect_error:-no error}); building it." >&2 docker build --platform linux/amd64 -t "${image_name}" \ -f "${repo_root}/tools/local-cross.Dockerfile" "${repo_root}" fi @@ -164,20 +174,39 @@ if [[ -z "${github_repo}" ]]; then github_repo=${github_repo%.git} fi +# Pinned downloads are kept between builds, named by their checksum, so a rebuild fetches nothing +# it already has. A cached file is checked again before use; a bad one is fetched anew. +cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"} +mkdir -p "${cache_dir}" +# cached : prints the cached path, running the command (which writes +# to "$out") only when the cache has no file with that checksum. +cached() { + local sha=$1 name=$2 out + shift 2 + out="${cache_dir}/${sha}-${name}" + if [[ -f "${out}" ]] && printf '%s %s\n' "${sha}" "${out}" | shasum -a 256 -c - >/dev/null 2>&1; then + echo "${out}" + return 0 + fi + rm -f "${out}.part" + out="${out}.part" "$@" >&2 + printf '%s %s\n' "${sha}" "${out}.part" | shasum -a 256 -c - >&2 + mv "${out}.part" "${out}" + echo "${out}" +} + if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then . "${repo_root}/tools/gamescope/release.env" - gamescope_archive="${staging_dir}/gamescope.tzst" - gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}" - printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c - + gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \ + bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}") zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}" test -f "${linuxfs_dir}/usr/local/bin/gamescope" fi if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then . "${repo_root}/tools/wlroots/release.env" - wlroots_archive="${staging_dir}/wlroots.tzst" - gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}" - printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c - + wlroots_archive=$(cached "${WLROOTS_SHA256}" wlroots.tzst \ + bash -c 'gh release download "$0" -R "$1" -p wlroots.tzst -O "$out"' "${WLROOTS_TAG}" "${github_repo}") zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}" test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" fi @@ -187,9 +216,8 @@ mango_pkgs="${staging_dir}/mango-pkgs" mkdir -p "${mango_dir}" "${mango_pkgs}" while read -r package_sha256 package_url; do [[ -n "${package_url}" ]] || continue - package_archive="${staging_dir}/$(basename "${package_url}")" - curl -fsSL --retry 3 -o "${package_archive}" "${package_url}" - printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c - + package_archive=$(cached "${package_sha256}" "$(basename "${package_url}")" \ + bash -c 'curl -fsSL --retry 3 -o "$out" "$0"' "${package_url}") zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}" done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt") install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp" @@ -201,11 +229,14 @@ install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/b pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"} if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then - curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \ - https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz + pa_tarball="${cache_dir}/pulseaudio-13.0.tar.gz" + if [[ ! -s "${pa_tarball}" ]] || ! tar -tzf "${pa_tarball}" >/dev/null 2>&1; then + curl -fsSL -o "${pa_tarball}.part" \ + https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz + mv "${pa_tarball}.part" "${pa_tarball}" + fi mkdir -p "${pa_source}" - tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \ - -C "${pa_source}" --strip-components=1 + tar -xzf "${pa_tarball}" -C "${pa_source}" --strip-components=1 fi if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then echo "PulseAudio 13.0 source not found at ${pa_source}; set DROIDDECK_PA13_SOURCE_DIR." >&2 @@ -214,7 +245,17 @@ fi sink_output="${staging_dir}/sink-out" "${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}" -"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a" +# proot is rebuilt only when its sources (source.env, the patches, the build script) changed since +# the libraries in jniLibs were built. +proot_out="${repo_root}/app/src/main/jniLibs/arm64-v8a" +proot_inputs=$(cd "${repo_root}/tools/proot" && find . -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -d' ' -f1) +if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \ + && "$(cat "${proot_out}/.proot-inputs" 2>/dev/null)" = "${proot_inputs}" ]]; then + echo "proot: sources unchanged, keeping ${proot_out}/libproot.so" +else + "${repo_root}/tools/proot/build.sh" "${proot_out}" + echo "${proot_inputs}" > "${proot_out}/.proot-inputs" +fi cp -p "${bundle_asset}" "${bundle_backup}" bundle_dir="${staging_dir}/pulseaudio-bundle" @@ -229,7 +270,7 @@ install -m755 "${sink_output}/module-aaudio-sink.so" \ "${bundle_dir}/modules/arm64/module-aaudio-sink.so" install -m755 "${sink_output}/module-directaudio-sink.so" \ "${bundle_dir}/modules/arm64/module-directaudio-sink.so" -tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst" +tar -cf - -C "${bundle_dir}" . | zstd -19 -T0 -c > "${staging_dir}/pulseaudio.tzst" bundle_replaced=1 mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}" diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index 8fa264904..ad4fc4b34 100644 --- a/tools/gamescope/PATCHES.md +++ b/tools/gamescope/PATCHES.md @@ -14,8 +14,11 @@ library list, before anything is published. - `0020-color-p3-red-is-wide-gamut.patch` - Armada, verbatim. - `0100-realtime-queue-and-gamepad-cursor.patch` - this app, two of Armada's ported by hand onto 3.16.29: realtime-priority Vulkan queues on request (`GAMESCOPE_FORCE_VULKAN_REALTIME=1`) - without CAP_SYS_NICE, which proot can never have; and the gamepad-driven cursor sprite following - the X pointer that XTest moves (it sat frozen). + without CAP_SYS_NICE, which proot can never have (a no-op on KGSL Turnip, which has a single + submit-queue priority); and the gamepad-driven cursor sprite following the X pointer that XTest + moves (it sat frozen). The X pointer is asked for only while a cursor image is drawn - every + vblank while shown, every 50 ms while hidden for inactivity - since each ask is a blocking round + trip to Xwayland on the paint thread; with no image, wlserver's position is used as upstream does. - `0110-wayland-backend-touch.patch` - this app: the nested Wayland backend bound only the host's pointer and keyboard, so a finger on the phone's screen never reached Steam. It now binds `wl_touch` too and hands each finger to wlserver's touch path (`wlserver_touchdown` / `motion` / @@ -27,6 +30,14 @@ library list, before anything is published. down moves nothing - so in the app's touchpad mode the Steam client saw no hover and a click landed wherever the pointer had last been. The motion now always warps the real pointer as well (`bAlwaysWarpCursor`), which the other touch modes did already. +- `0112-restore-iconified-game-on-resume.patch` - this app: the Steam menu is an overlay that + takes input without changing the focus window, and a fullscreen wine game minimizes itself when + it loses input. gamescope only takes a window out of iconic when the focus window changes, and + wine will not activate a window it believes iconic, so after Resume the game stayed minimized: a + black screen with its small caption in the top-left corner (Titanfall 2, GE-Proton 11). The + iconify request is remembered and the window goes back to NormalState before input returns to it, + then focus is handed over again. `GAMESCOPE_RESTORE_FOCUS_WINDOW` on the root window asks for the + same restore from outside (the session script's resume watcher). Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has. diff --git a/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch b/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch index a49a0aa1a..0e390b099 100644 --- a/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch +++ b/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch @@ -7,8 +7,8 @@ index 54e4efc..024af02 100644 float queuePriorities = 1.0f; + // Without CAP_SYS_NICE gamescope never asks for realtime-priority queues. Under proot on -+ // Android there is no capability to have, but the driver may still grant the priority (Turnip -+ // does on Adreno), and the existing fallback below covers a driver that refuses. Opt in with ++ // Android there is no capability to have; the fallback below covers a driver that refuses. KGSL ++ // Turnip accepts but ignores it (one submit-queue priority, contexts created alike). Opt in with + // GAMESCOPE_FORCE_VULKAN_REALTIME=1. (Armada's --force-vulkan-realtime, as an environment switch.) + const char *pszForceRealtime = getenv( "GAMESCOPE_FORCE_VULKAN_REALTIME" ); + const bool useRealtimeVulkanQueue = gamescope::Process::HasCapSysNice() || ( pszForceRealtime && atoi( pszForceRealtime ) != 0 ); @@ -52,27 +52,10 @@ index 54e4efc..024af02 100644 fprintf(stderr, "vkCreateDevice failed with a high-priority queue (compute). Falling back to regular priority (all).\n"); queueCreateInfos[0].pNext = nullptr; diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 18b799e..16f7846 100644 +index 10990b8..08afbc4 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -2020,23 +2020,41 @@ MouseCursor::MouseCursor(xwayland_ctx_t *ctx) - - void MouseCursor::UpdatePosition() - { -+ // The gamepad-driven cursor is moved through XTest, which moves the X pointer and nothing -+ // else: wlserver's copy of the position never changes, so the sprite sat frozen while the -+ // pointer moved. Ask X where the pointer is. (Armada 0001, on 3.16.29.) -+ Window root_ret, child_ret; -+ int root_x = m_x, root_y = m_y, win_x = 0, win_y = 0; -+ unsigned int mask; -+ XQueryPointer( m_ctx->dpy, DefaultRootWindow( m_ctx->dpy ), -+ &root_ret, &child_ret, -+ &root_x, &root_y, -+ &win_x, &win_y, -+ &mask ); -+ - wlserver_lock(); - struct wlr_pointer_constraint_v1 *pConstraint = wlserver.GetCursorConstraint(); +@@ -2025,18 +2025,53 @@ void MouseCursor::UpdatePosition() if ( pConstraint && pConstraint->current.cursor_hint.enabled ) { m_bConstrained = true; @@ -84,23 +67,61 @@ index 18b799e..16f7846 100644 } - else + m_bConstrained = false; -+ wlserver_unlock(); -+ if ( root_x != m_x || root_y != m_y ) ++ // Nothing to draw (the gamepad UI, most games): the position only feeds focus and zoom, and ++ // wlserver's copy is what stock gamescope uses for those. No round trip to X. ++ if ( m_imageEmpty ) { - m_bConstrained = false; - -- m_x = wlserver.mouse_surface_cursorx; -- m_y = wlserver.mouse_surface_cursory; + m_x = wlserver.mouse_surface_cursorx; + m_y = wlserver.mouse_surface_cursory; ++ wlserver_unlock(); ++ return; + } + wlserver_unlock(); ++ ++ // The gamepad-driven cursor is moved through XTest, which moves the X pointer and nothing ++ // else: wlserver's copy of the position never changes, so the sprite sat frozen while the ++ // pointer moved. Ask X where the pointer is. (Armada 0001, on 3.16.29.) That is a blocking ++ // round trip to Xwayland on the paint thread, which waits while Xwayland is busy drawing the ++ // client's interface: once per vblank only while the sprite is shown, and every 50 ms while ++ // it is hidden for inactivity - enough to bring it back within a few frames of a move. ++ if ( wlserver.bCursorHidden ) ++ { ++ const uint64_t now = get_time_in_nanos(); ++ if ( now - m_ulLastPointerQuery < 50'000'000ul ) ++ return; ++ m_ulLastPointerQuery = now; ++ } ++ Window root_ret, child_ret; ++ int root_x = m_x, root_y = m_y, win_x = 0, win_y = 0; ++ unsigned int mask; ++ XQueryPointer( m_ctx->dpy, DefaultRootWindow( m_ctx->dpy ), ++ &root_ret, &child_ret, ++ &root_x, &root_y, ++ &win_x, &win_y, ++ &mask ); ++ ++ if ( root_x != m_x || root_y != m_y ) ++ { + m_x = root_x; + m_y = root_y; -+ if ( !m_imageEmpty ) -+ { -+ wlserver.ulLastMovedCursorTime = get_time_in_nanos(); -+ wlserver.bCursorHidden = !wlserver.bCursorHasImage; -+ hasRepaintNonBasePlane = true; -+ } - } -- wlserver_unlock(); ++ wlserver.ulLastMovedCursorTime = get_time_in_nanos(); ++ wlserver.bCursorHidden = !wlserver.bCursorHasImage; ++ hasRepaintNonBasePlane = true; ++ } } void MouseCursor::checkSuspension() +diff --git a/src/steamcompmgr.hpp b/src/steamcompmgr.hpp +index c428784..bd62fd1 100644 +--- a/src/steamcompmgr.hpp ++++ b/src/steamcompmgr.hpp +@@ -109,6 +109,7 @@ private: + + int m_x = 0, m_y = 0; + bool m_bConstrained = false; ++ uint64_t m_ulLastPointerQuery = 0; + int m_hotspotX = 0, m_hotspotY = 0; + + gamescope::OwningRc m_texture; diff --git a/tools/gamescope/patches/0112-restore-iconified-game-on-resume.patch b/tools/gamescope/patches/0112-restore-iconified-game-on-resume.patch new file mode 100644 index 000000000..2eee045e4 --- /dev/null +++ b/tools/gamescope/patches/0112-restore-iconified-game-on-resume.patch @@ -0,0 +1,108 @@ +From: DroidDeck +Subject: [PATCH] steamcompmgr: restore a game that minimized itself behind the Steam menu + +The Steam menu is an overlay that takes input without changing the focus window. A fullscreen +wine game minimizes itself when it loses input (WM_CHANGE_STATE IconicState, which gamescope +fakes by setting WM_STATE). gamescope only puts a window back to NormalState when the focus +window changes, which never happens here, and wine will not activate a window it believes +iconic - so after Resume the game stayed minimized: a black screen with its 160x24 caption in +the top-left corner, 0 fps (seen with Titanfall 2 on GE-Proton 11). + +Remember the iconify request, restore NormalState before input returns to that window, and hand +focus over again so wine sees the restore before the activation. GAMESCOPE_RESTORE_FOCUS_WINDOW +on the root window asks for the same thing from outside. + +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 08afbc4..372a82c 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -4708,6 +4708,7 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win + /* Some games (e.g. DOOM Eternal) don't react well to being put back as + * iconic, so never do that. Only take them out of iconic. */ + set_wm_state( ctx, id, ICCCM_NORMAL_STATE ); ++ ctx->focus.focusWindow->iconifyRequested = false; + + gpuvis_trace_printf( "determine_and_apply_focus focus %lu", id ); + +@@ -4795,6 +4796,19 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win + if ( keyboardFocusWindow && ctx->currentKeyboardFocusWindow && find_win( ctx, ctx->currentKeyboardFocusWindow ) == keyboardFocusWin ) + keyboardFocusWindow = ctx->currentKeyboardFocusWindow; + ++ // An overlay that takes input (the Steam menu) moves input away without changing the focus window, and a ++ // fullscreen game iconifies itself when it loses input. The focus window never changed, so it is never taken ++ // out of iconic above, and wine will not activate a window it believes iconic: the game stays minimized ++ // (its 160x24 caption in the corner) after Resume. Put it back to NormalState before input returns to it, ++ // then hand focus over again so wine sees the restore first and the activation after it. ++ if ( inputFocus == ctx->focus.focusWindow && ctx->focus.focusWindow->iconifyRequested ) ++ { ++ ctx->focus.focusWindow->iconifyRequested = false; ++ xwm_log.infof( "restoring iconified focus window 0x%lx as input returns to it", ctx->focus.focusWindow->xwayland().id ); ++ set_wm_state( ctx, ctx->focus.focusWindow->xwayland().id, ICCCM_NORMAL_STATE ); ++ ctx->currentKeyboardFocusWindow = None; ++ } ++ + if ( ctx->focus.inputFocusWindow != inputFocus || + ctx->focus.inputFocusMode != inputFocus->inputFocusMode || + ctx->currentKeyboardFocusWindow != keyboardFocusWindow ) +@@ -6559,6 +6573,8 @@ handle_wm_change_state(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, XClientMessag + if (state == ICCCM_ICONIC_STATE) { + xwm_log.debugf("Faking WM_CHANGE_STATE to ICONIC for window 0x%lx", w->xwayland().id); + set_wm_state( ctx, w->xwayland().id, ICCCM_ICONIC_STATE ); ++ w->iconifyRequested = true; ++ MakeFocusDirty(); + } else { + xwm_log.debugf("Unhandled WM_CHANGE_STATE to %ld for window 0x%lx", state, w->xwayland().id); + } +@@ -7539,6 +7555,19 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev) + if ( set_sdr_input_gain( bit_cast(val) ) ) + hasRepaint = true; + } ++ if ( ev->atom == ctx->atoms.gamescopeRestoreFocusWindow ) ++ { ++ // Anything outside (the session's watcher) can ask for the same restore: NormalState on the focus ++ // window and focus handed to it again. Harmless on a window that is not iconic. ++ if ( ctx->focus.focusWindow && get_prop( ctx, ctx->root, ctx->atoms.gamescopeRestoreFocusWindow, 0 ) ) ++ { ++ xwm_log.infof( "restore requested for focus window 0x%lx", ctx->focus.focusWindow->xwayland().id ); ++ ctx->focus.focusWindow->iconifyRequested = false; ++ set_wm_state( ctx, ctx->focus.focusWindow->xwayland().id, ICCCM_NORMAL_STATE ); ++ ctx->currentKeyboardFocusWindow = None; ++ MakeFocusDirty(); ++ } ++ } + if ( ev->atom == ctx->atoms.gamescopeForceWindowsFullscreen ) + { + ctx->force_windows_fullscreen = !!get_prop( ctx, ctx->root, ctx->atoms.gamescopeForceWindowsFullscreen, 0 ); +@@ -9056,6 +9085,7 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_ + ctx->atoms.gamescopeHDRTonemapOperator = XInternAtom( ctx->dpy, "GAMESCOPE_HDR_TONEMAP_OPERATOR", false ); + + ctx->atoms.gamescopeForceWindowsFullscreen = XInternAtom( ctx->dpy, "GAMESCOPE_FORCE_WINDOWS_FULLSCREEN", false ); ++ ctx->atoms.gamescopeRestoreFocusWindow = XInternAtom( ctx->dpy, "GAMESCOPE_RESTORE_FOCUS_WINDOW", false ); + + ctx->atoms.gamescopeColorLut3DOverride = XInternAtom( ctx->dpy, "GAMESCOPE_COLOR_3DLUT_OVERRIDE", false ); + ctx->atoms.gamescopeColorShaperLutOverride = XInternAtom( ctx->dpy, "GAMESCOPE_COLOR_SHAPERLUT_OVERRIDE", false ); +diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp +index 66022d7..af93a23 100644 +--- a/src/steamcompmgr_shared.hpp ++++ b/src/steamcompmgr_shared.hpp +@@ -134,6 +134,8 @@ struct steamcompmgr_win_t { + + bool isFullscreen = false; + bool isSysTrayIcon = false; ++ // The client asked to be iconified (WM_CHANGE_STATE) and has not been put back to NormalState since. ++ bool iconifyRequested = false; + bool sizeHintsSpecified = false; + bool skipTaskbar = false; + bool skipPager = false; +diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp +index 7b39ecb..81910a1 100644 +--- a/src/xwayland_ctx.hpp ++++ b/src/xwayland_ctx.hpp +@@ -228,6 +228,7 @@ struct xwayland_ctx_t final : public gamescope::IWaitable + Atom gamescopeHDRTonemapOperator; + + Atom gamescopeForceWindowsFullscreen; ++ Atom gamescopeRestoreFocusWindow; + + Atom gamescopeColorLut3DOverride; + Atom gamescopeColorShaperLutOverride; diff --git a/tools/gamescope/release.env b/tools/gamescope/release.env index a7a39e459..af715c671 100644 --- a/tools/gamescope/release.env +++ b/tools/gamescope/release.env @@ -1,2 +1,2 @@ -GAMESCOPE_TAG=gamescope-3.16.29-p3 -GAMESCOPE_SHA256=566e3b93931f37c30375d440d15ee76452892b842d2dc854cd3c06b926c8451b +GAMESCOPE_TAG=gamescope-3.16.29-p4 +GAMESCOPE_SHA256=e750fb34864bc2eb1160aeed29593e402bf9c8153fafd9fd18c77450bdd50574 diff --git a/tools/linuxfs/desktop/droiddeck-desktop-gpu b/tools/linuxfs/desktop/droiddeck-desktop-gpu index 08d280aec..8a9a6b108 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop-gpu +++ b/tools/linuxfs/desktop/droiddeck-desktop-gpu @@ -12,6 +12,10 @@ import re HOME = os.environ.get("HOME", "/root") SYSTEM_DIR = "/usr/share/applications" +# The user's own AppImages (the front end's Add AppImage). +LOCAL_DIR = "/usr/local/share/applications" +# Flathub's games, as Flatpak exports them; the copies here win over these as they do over the system's. +FLATPAK_DIR = os.path.join(HOME, ".local/share/flatpak/exports/share/applications") USER_DIR = os.path.join(HOME, ".local/share/applications") MARK = "X-DroidDeck-GPU=1" # The client and its games already hand themselves to the app (bannerlator-steam-launch). @@ -37,6 +41,14 @@ def main_group(text): return lines[:start + 1], lines[start + 1:end], lines[end:] +def gpu_exec(exec_line): + """The Exec that runs a program on the GPU; a Flatpak app goes through its launcher, which + keeps it on gamescope's X server instead of the desktop's Wayland socket.""" + if exec_line.startswith("/usr/bin/flatpak run ") or exec_line.startswith("flatpak run "): + exec_line = "/usr/local/bin/bannerlator-flatpak-run " + exec_line.split(" run ", 1)[1] + return "droiddeck-gpu " + exec_line + + def wrap(text): head, group, rest = main_group(text) if group is None: @@ -59,7 +71,7 @@ def wrap(text): for l in group: k = l.split("=", 1)[0].strip() if "=" in l else None if k == "Exec": - out_group.append("Exec=droiddeck-gpu " + exec_line) + out_group.append("Exec=" + gpu_exec(exec_line)) elif k == "Actions": continue else: @@ -73,7 +85,7 @@ def wrap(text): wrapped_rest = [] for l in rest: if l.startswith("Exec=") and not l[5:].startswith("droiddeck-gpu "): - l = "Exec=droiddeck-gpu " + l[5:] + l = "Exec=" + gpu_exec(l[5:]) wrapped_rest.append(l) if wrapped_rest and wrapped_rest[0].strip(): wrapped_rest.insert(0, "") @@ -92,8 +104,9 @@ def main(): on = enabled() os.makedirs(USER_DIR, exist_ok=True) written = set() - if on and os.path.isdir(SYSTEM_DIR): - for name in sorted(os.listdir(SYSTEM_DIR)): + sources = [d for d in (SYSTEM_DIR, LOCAL_DIR, FLATPAK_DIR) if os.path.isdir(d)] if on else [] + for source in sources: + for name in sorted(os.listdir(source)): if not name.endswith(".desktop") or SKIP.match(name): continue target = os.path.join(USER_DIR, name) @@ -101,7 +114,7 @@ def main(): if os.path.exists(target) and not ours(target): continue try: - with open(os.path.join(SYSTEM_DIR, name), encoding="utf-8", errors="replace") as f: + with open(os.path.join(source, name), encoding="utf-8", errors="replace") as f: text = wrap(f.read()) except OSError: continue diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run new file mode 100755 index 000000000..fbe13caaf --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run @@ -0,0 +1,63 @@ +#!/bin/bash +# An AppImage imported from the front end (AppImageManager), extracted once at import into +# /opt/appimages/user//app +# and started from there: proot has no FUSE to mount the image, and extracting it at every +# start (APPIMAGE_EXTRACT_AND_RUN) would cost the whole image in /tmp each time. +# bannerlator-appimage-run /opt/appimages/user/ [args...] +set -u +dir=${1:?usage: bannerlator-appimage-run [args...]} +shift +export APPDIR=$dir/app +# APPIMAGE is left unset on purpose: with it an app believes it runs from an image file and offers +# to add itself to the menu (DuckStation) or update the file in place - DroidDeck already does the +# one, and there is no image file for the other. +unset APPIMAGE +export ARGV0=$(cat "$dir/name" 2>/dev/null || basename "$dir") +if [ ! -x "$APPDIR/AppRun" ]; then + echo "bannerlator-appimage-run: $APPDIR/AppRun is missing; import the AppImage again" >&2 + exit 127 +fi +# Browsers sandbox their own children with namespaces and a setuid helper, neither of which proot +# can give them (the Flatpak sandboxes get the same; see BwrapSpawner): Firefox and its forks (Zen, +# LibreWolf) by these switches, Electron and Chromium builds by --no-sandbox. Chromium on Wayland +# also asks the render node for a DRM version KGSL cannot give, so with an X server up it stays on +# X11, which it picks from the session type. +export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 +export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1 +flags=() +if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then + flags+=(--no-sandbox) + if [ -n "${DISPLAY:-}" ]; then + export XDG_SESSION_TYPE=x11 + unset WAYLAND_DISPLAY + flags+=(--ozone-platform=x11) + fi +fi +echo "== appimage $ARGV0 ($APPDIR) ${flags[*]}" +cd "$APPDIR" || exit 1 +# Apps expect a session bus; Electron's main process traps (SIGTRAP) on an address it cannot +# parse. A run mode has none, so one is started for the app, as for Flatpak apps. +case ${DBUS_SESSION_BUS_ADDRESS:-} in + unix:*) "$APPDIR/AppRun" "${flags[@]}" "$@" ;; + *) unset DBUS_SESSION_BUS_ADDRESS + if command -v dbus-run-session >/dev/null 2>&1; then + dbus-run-session -- "$APPDIR/AppRun" "${flags[@]}" "$@" + else + "$APPDIR/AppRun" "${flags[@]}" "$@" + fi ;; +esac +rc=$? +# The program a session was started for may hand over to another process and exit: Electron apps +# relaunch themselves (Obsidian on its first start), launchers fork their real program. The +# session ends with this script, and takes the rest with it - so it waits while anything running +# from the image is still up. +image_running() { + local p exe + for p in /proc/[0-9]*; do + exe=$(readlink "$p/exe" 2>/dev/null) || continue + case $exe in "$APPDIR"/*) return 0 ;; esac + done + return 1 +} +while image_running; do sleep 2; done +exit $rc diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap b/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap new file mode 100644 index 000000000..5a3a73951 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap @@ -0,0 +1,318 @@ +#!/usr/bin/python3 +# bubblewrap for Flatpak (FLATPAK_BWRAP), in a runtime that cannot have it. +# +# bwrap builds each sandbox from mount namespaces, which Android gives no app. proot gives the same +# picture - Flatpak's runtime as /usr, the app as /app, host paths bound where Flatpak asks - but a +# proot nested inside the session's proot runs every system call through two tracers, some thirty +# times slower on a program's start. So a sandbox is described to the app instead, over a socket +# only this app's uid may use, and the app starts it as a proot of its own beside the session's +# (BwrapSpawner). This process stands in for the sandbox until it ends: its output, its pid for +# --info-fd, and its exit status come back here. +# +# Sandboxes that remap nothing - Flatpak's install triggers (`--ro-bind / /` and the directory they +# update) and the D-Bus proxy (every top-level directory bound onto itself) - need no proot at all +# and run here directly, with the file descriptors Flatpak handed them. +import base64 +import json +import os +import signal +import socket +import struct +import sys + +SOCKET = "\0com.droiddeck.launcher.bwrap" +VERSION = "bubblewrap 0.11.0 (droiddeck)" + +# Options and how many arguments each takes; bwrap(1). +NO_ARG = { + "--unshare-user", "--unshare-user-try", "--unshare-ipc", "--unshare-pid", "--unshare-net", + "--unshare-uts", "--unshare-cgroup", "--unshare-cgroup-try", "--unshare-all", "--share-net", + "--disable-userns", "--assert-userns-disabled", "--clearenv", "--new-session", + "--die-with-parent", "--as-pid-1", "--level-prefix", "--tmp-overlay", "--ro-overlay", +} +ONE_ARG = { + "--args", "--argv0", "--userns", "--userns2", "--pidns", "--uid", "--gid", "--hostname", + "--chdir", "--unsetenv", "--lock-file", "--sync-fd", "--remount-ro", "--exec-label", + "--file-label", "--proc", "--dev", "--tmpfs", "--mqueue", "--dir", "--seccomp", + "--add-seccomp-fd", "--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd", + "--cap-add", "--cap-drop", "--perms", "--size", "--overlay-src", +} +TWO_ARG = { + "--setenv", "--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind", + "--ro-bind-try", "--bind-fd", "--ro-bind-fd", "--file", "--bind-data", "--ro-bind-data", + "--symlink", "--chmod", +} +THREE_ARG = {"--overlay"} + + +def read_fd(fd): + chunks = [] + while True: + b = os.read(fd, 65536) + if not b: + break + chunks.append(b) + os.close(fd) + return b"".join(chunks) + + +def fd_path(fd): + return os.readlink("/proc/self/fd/%d" % fd) + + +def expand(argv): + """argv with every --args FD replaced by the NUL-separated arguments read from FD.""" + out = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--args" and i + 1 < len(argv): + data = read_fd(int(argv[i + 1])) + # Each argument ends in a NUL; an empty one (--setenv LD_LIBRARY_PATH "") is kept. + parts = data.split(b"\0") + if parts and parts[-1] == b"": + parts.pop() + out.extend(x.decode("utf-8", "surrogateescape") for x in parts) + i += 2 + continue + out.append(a) + i += 1 + return out + + +class Spec: + def __init__(self): + self.ops = [] + self.env = dict(os.environ) + self.cwd = None + self.info_fds = [] + self.json_fds = [] + self.keep_fds = [] + self.command = [] + self.argv0 = None + + +def parse(argv): + s = Spec() + perms = None + i = 0 + while i < len(argv): + a = argv[i] + if a == "--": + s.command = argv[i + 1:] + break + if not a.startswith("--"): + s.command = argv[i:] + break + if a in ("--version",): + print(VERSION) + sys.exit(0) + if a == "--help": + print("usage: bwrap [OPTIONS...] [--] COMMAND [ARGS...]") + sys.exit(0) + n = 0 if a in NO_ARG else 1 if a in ONE_ARG else 2 if a in TWO_ARG else 3 if a in THREE_ARG else -1 + if n < 0: + sys.stderr.write("bwrap: Unknown option %s\n" % a) + sys.exit(1) + args = argv[i + 1:i + 1 + n] + if len(args) < n: + sys.stderr.write("bwrap: %s takes %d arguments\n" % (a, n)) + sys.exit(1) + i += 1 + n + op_perms, perms = perms, None + if a == "--perms": + perms = int(args[0], 8) + elif a == "--clearenv": + s.env = {"PWD": os.getcwd()} if "PWD" in os.environ else {} + elif a == "--setenv": + s.env[args[0]] = args[1] + elif a == "--unsetenv": + s.env.pop(args[0], None) + elif a == "--chdir": + s.cwd = args[0] + elif a == "--argv0": + s.argv0 = args[0] + elif a in ("--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind", "--ro-bind-try"): + s.ops.append({"t": "bind", "src": args[0], "dest": args[1], "try": a.endswith("-try"), + "ro": a.startswith("--ro")}) + elif a in ("--bind-fd", "--ro-bind-fd"): + fd = int(args[0]) + s.ops.append({"t": "bind", "src": fd_path(fd), "dest": args[1], "try": False, + "ro": a.startswith("--ro")}) + os.close(fd) + elif a in ("--file", "--bind-data", "--ro-bind-data"): + s.ops.append({"t": "file", "dest": args[1], "data": read_fd(int(args[0])), + "perms": op_perms if op_perms is not None else 0o666 if a == "--file" else 0o600}) + elif a == "--symlink": + s.ops.append({"t": "symlink", "target": args[0], "dest": args[1]}) + elif a == "--dir": + s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755}) + elif a in ("--tmpfs", "--mqueue"): + s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755, + "empty": True}) + elif a == "--proc": + s.ops.append({"t": "proc", "dest": args[0]}) + elif a == "--dev": + s.ops.append({"t": "dev", "dest": args[0]}) + elif a in ("--seccomp", "--add-seccomp-fd"): + # proot is the sandbox; there is no filter to install under it. + os.close(int(args[0])) + elif a == "--block-fd": + read_fd(int(args[0])) + elif a == "--sync-fd": + s.keep_fds.append(int(args[0])) + elif a == "--info-fd": + s.info_fds.append(int(args[0])) + elif a == "--json-status-fd": + s.json_fds.append(int(args[0])) + # Everything else - namespaces, capabilities, labels, locks, --remount-ro, --chmod - has + # nothing to act on under proot. + if not s.command: + sys.stderr.write("bwrap: No command specified\n") + sys.exit(1) + return s + + +def real(path): + try: + return os.path.realpath(path) + except OSError: + return path + + +def remaps_nothing(s): + """True when every path in the sandbox is what it is out here already.""" + for op in s.ops: + t = op["t"] + if t == "bind": + src = real(op["src"]) + if not os.path.exists(src): + if op["try"]: + continue + return False + if src != real(op["dest"]) and not (op["src"] == "/" and op["dest"] == "/"): + return False + elif t == "symlink": + if not os.path.islink(op["dest"]) or os.readlink(op["dest"]) != op["target"]: + return False + elif t == "file": + # The D-Bus proxy's /.flatpak-info names the app to the bus; nothing here reads it. + if op["dest"] != "/.flatpak-info": + return False + elif t == "dir": + if not os.path.isdir(op["dest"]) or op.get("empty") and op["dest"] not in ("/tmp", "/var/tmp", "/run"): + return False + elif t in ("proc", "dev"): + if op["dest"] != "/" + t: + return False + return True + + +def report_pid(s, pid): + for fd in s.info_fds: + try: + os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n") + os.close(fd) + except OSError: + pass + for fd in s.json_fds: + try: + os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n") + except OSError: + pass + + +def run_here(s): + report_pid(s, os.getpid()) + for fd in s.json_fds: + os.close(fd) + if s.cwd: + os.chdir(s.cwd) + argv = list(s.command) + program = argv[0] + if s.argv0: + argv[0] = s.argv0 + try: + os.execvpe(program, argv, s.env) + except OSError as e: + sys.stderr.write("bwrap: execvp %s: %s\n" % (program, e.strerror)) + sys.exit(1) + + +def send(sock, kind, payload): + sock.sendall(kind + struct.pack(">I", len(payload)) + payload) + + +def recv_exact(sock, n): + buf = b"" + while len(buf) < n: + b = sock.recv(n - len(buf)) + if not b: + return None + buf += b + return buf + + +def run_by_app(s): + ops = [] + for op in s.ops: + op = dict(op) + if op["t"] == "bind": + # The app resolves no guest symlinks; hand it the path they lead to. + op["src"] = real(op["src"]) + if op["t"] == "file": + op["data"] = base64.b64encode(op["data"]).decode() + ops.append(op) + argv = list(s.command) + if s.argv0: + argv[0] = s.argv0 + request = {"argv": argv, "env": s.env, "cwd": s.cwd or "/", "ops": ops} + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(SOCKET) + except OSError as e: + sys.stderr.write("bwrap: DroidDeck is not answering (%s); open DroidDeck and try again\n" % e) + sys.exit(1) + send(sock, b"R", json.dumps(request).encode()) + # This process is the sandbox as far as Flatpak is concerned: ended, it takes the sandbox down. + for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(sig, lambda *_: os._exit(128 + 15)) + status = 1 + out = sys.stdout.buffer + while True: + head = recv_exact(sock, 5) + if head is None: + sys.stderr.write("bwrap: DroidDeck closed the sandbox\n") + break + kind, size = head[:1], struct.unpack(">I", head[1:])[0] + payload = recv_exact(sock, size) if size else b"" + if payload is None: + break + if kind == b"P": + report_pid(s, int(payload)) + elif kind == b"O": + out.write(payload) + out.flush() + elif kind == b"E": + sys.stderr.write("bwrap: %s\n" % payload.decode("utf-8", "replace")) + elif kind == b"X": + status = int(payload) + break + for fd in s.json_fds: + try: + os.write(fd, json.dumps({"exit-code": status}).encode() + b"\n") + except OSError: + pass + sys.exit(status) + + +def main(): + s = parse(expand(sys.argv[1:])) + if remaps_nothing(s): + run_here(s) + else: + run_by_app(s) + + +if __name__ == "__main__": + main() diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak new file mode 100644 index 000000000..3b322539f --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak @@ -0,0 +1,237 @@ +#!/usr/bin/python3 +# The app's store talks to Flatpak through this: one JSON object per line on stdout, so progress +# reaches the front end as numbers rather than a terminal's redrawn bar. Per-user installation, +# Flathub as the remote, this device's architecture. +# +# bannerlator-flatpak install the app and whatever runtimes it needs +# bannerlator-flatpak uninstall the app, then runtimes nothing uses any more +# bannerlator-flatpak update [] one app, or everything with an update +# bannerlator-flatpak updates what has one (a "updates" line) +# +# Lines: {"e":"op","i":1,"n":3,"kind":"install","ref":"..."} as each step starts, +# {"e":"progress","i":1,"n":3,"percent":45,"status":"..."} while it runs, +# {"e":"error","message":"..."} and {"e":"done"} at the end. Exit status 0 on success. +import json +import os +import sys + +os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/bannerlator-bwrap") + +import gi # noqa: E402 + +gi.require_version("Flatpak", "1.0") +from gi.repository import Flatpak, GLib # noqa: E402 + +REMOTE = "flathub" + + +def prepare_rootfs(): + """Some runtimes (KDE 6.10) ship etc/resolv.conf as a link to /run/host/monitor/resolv.conf. + Deploying one, Flatpak removes that link only if GIO reports it existing, and GIO follows it: + dangling here, it was left in place and making Flatpak's own link failed ("File exists"). + With the target present in the rootfs the link resolves and the deploy goes through.""" + target = "/run/host/monitor/resolv.conf" + try: + if not os.path.exists(target): + os.makedirs(os.path.dirname(target), exist_ok=True) + try: + with open("/etc/resolv.conf", "rb") as src: + data = src.read() + except OSError: + data = b"" + with open(target, "wb") as dst: + dst.write(data) + except OSError as err: + emit(e="warning", message="could not prepare %s: %s" % (target, err)) + + +def emit(**kw): + sys.stdout.write(json.dumps(kw) + "\n") + sys.stdout.flush() + + +def installation(): + return Flatpak.Installation.new_user(None) + + +def app_ref(inst, app_id): + """The installed ref of an app, or None.""" + for ref in inst.list_installed_refs_by_kind(Flatpak.RefKind.APP, None): + if ref.get_name() == app_id: + return ref + return None + + +def transaction(inst): + t = Flatpak.Transaction.new_for_installation(inst, None) + t.set_no_interaction(True) + state = {"i": 0, "n": 0, "error": None} + + def on_ready(tr): + state["n"] = len(tr.get_operations()) + return True + + def on_new_operation(tr, op, progress): + state["i"] += 1 + emit(e="op", i=state["i"], n=state["n"], kind=op.get_operation_type().value_nick, ref=op.get_ref()) + progress.set_update_frequency(500) + + def changed(p): + emit(e="progress", i=state["i"], n=state["n"], percent=p.get_progress(), status=p.get_status() or "") + progress.connect("changed", changed) + + def on_operation_error(tr, op, err, details): + # A runtime extension that fails to install (a missing translation, an unneeded GL + # driver) is not worth losing the app over; anything else ends the transaction. + # FLATPAK_TRANSACTION_ERROR_DETAILS_NON_FATAL is 1, but PyGObject does not always hand the + # flags over ("g_value_get_flags: assertion ... failed" and a 0). An extension installed + # alongside an app - its translations, debug data, a GL or codecs runtime - is what Flatpak + # marks non-fatal, as the flatpak CLI does: its failure costs that extension, not the app. + try: + non_fatal = bool(int(details) & 1) + except (TypeError, ValueError): + non_fatal = False + if not non_fatal: + try: + related = bool(op.get_related_to_ops()) + except (AttributeError, TypeError): + related = False + ref_id = (op.get_ref() or "").split("/")[1:2] + ref_id = ref_id[0] if ref_id else "" + non_fatal = related or ref_id.endswith((".Locale", ".Debug")) or ".GL." in ref_id or "codecs" in ref_id + emit(e="warning" if non_fatal else "error", message="%s: %s" % (op.get_ref(), err.message)) + if not non_fatal: + state["error"] = err.message + return non_fatal + + t.connect("ready", on_ready) + t.connect("new-operation", on_new_operation) + t.connect("operation-error", on_operation_error) + # A dependency found on more than one remote comes from the first (Flathub, the only one). + t.connect("choose-remote-for-ref", lambda tr, ref, runtime_ref, remotes: 0) + t.connect("end-of-lifed-with-rebase", lambda *a: False) + t.connect("add-new-remote", lambda *a: False) + return t, state + + +def run(t, state, report_done=True): + try: + t.run(None) + except GLib.Error as err: + if state["error"] is None and not err.matches(Flatpak.error_quark(), Flatpak.Error.ABORTED): + state["error"] = err.message + if state["error"]: + emit(e="error", message=state["error"]) + return 1 + if report_done: + emit(e="done") + return 0 + + +def install(app_id): + inst = installation() + arch = Flatpak.get_default_arch() + if app_ref(inst, app_id) is not None: + emit(e="done", note="already installed") + return 0 + # Flathub publishes apps on "stable"; libflatpak's default branch is "master". + branch, last = None, None + for candidate in ("stable", "master"): + try: + branch = inst.fetch_remote_ref_sync(REMOTE, Flatpak.RefKind.APP, app_id, arch, candidate, None).get_branch() + break + except GLib.Error as err: + last = err + if branch is None: + emit(e="error", message="%s is not on Flathub for %s (%s)" % (app_id, arch, last.message if last else "no branch")) + return 1 + t, state = transaction(inst) + t.add_install(REMOTE, "app/%s/%s/%s" % (app_id, arch, branch), None) + rc = run(t, state, report_done=False) + # A failure the transaction did not report (an error handler that raised, say) must not read + # as an install: the app is there, or it is not. + if rc == 0 and app_ref(installation(), app_id) is None: + emit(e="error", message="%s did not install; see flatpak-install.log" % app_id) + return 1 + if rc == 0: + emit(e="done") + return rc + + +def uninstall(app_id): + inst = installation() + ref = app_ref(inst, app_id) + if ref is None: + emit(e="done", note="not installed") + return 0 + t, state = transaction(inst) + t.add_uninstall(ref.format_ref()) + rc = run(t, state) + if rc == 0: + # Runtimes only this app used; Flatpak's own `uninstall --unused`. + try: + unused = inst.list_unused_refs(None, None) + except GLib.Error: + unused = [] + if unused: + t, state = transaction(inst) + for r in unused: + t.add_uninstall(r.format_ref()) + run(t, state) + return rc + + +def updatable(inst): + try: + return inst.list_installed_refs_for_update(None) + except GLib.Error as err: + emit(e="error", message=err.message) + return None + + +def update(app_id=None): + inst = installation() + refs = updatable(inst) + if refs is None: + return 1 + if app_id: + refs = [r for r in refs if r.get_name() == app_id or r.get_kind() == Flatpak.RefKind.RUNTIME] + if not refs: + emit(e="done", note="up to date") + return 0 + t, state = transaction(inst) + for r in refs: + t.add_update(r.format_ref(), None, None) + return run(t, state) + + +def updates(): + refs = updatable(installation()) + if refs is None: + return 1 + emit(e="updates", apps=sorted({r.get_name() for r in refs if r.get_kind() == Flatpak.RefKind.APP}), + runtimes=len([r for r in refs if r.get_kind() == Flatpak.RefKind.RUNTIME])) + return 0 + + +def main(): + if len(sys.argv) < 2: + sys.stderr.write(__doc__ or "usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n") + return 2 + cmd, args = sys.argv[1], sys.argv[2:] + if cmd in ("install", "update"): + prepare_rootfs() + if cmd == "install" and args: + return install(args[0]) + if cmd == "uninstall" and args: + return uninstall(args[0]) + if cmd == "update": + return update(args[0] if args else None) + if cmd == "updates": + return updates() + sys.stderr.write("usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run new file mode 100644 index 000000000..203769d13 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run @@ -0,0 +1,28 @@ +#!/bin/bash +# A Flatpak app from the front end, full screen under gamescope like an emulator: +# bannerlator-flatpak-run [args...] +set -u +export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +case ":${XDG_DATA_DIRS:-}:" in + *"/flatpak/exports/share:"*) ;; + *) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;; +esac +# Under gamescope - a program's session (BL_INSIDE), or droiddeck-gpu from the desktop +# (GAMESCOPE_WAYLAND_DISPLAY) - windows have to reach gamescope's X server. Flatpak would hand an +# app wayland-0 (it finds it in the runtime directory even with WAYLAND_DISPLAY unset) and the +# window would open behind gamescope. gamescope's own Wayland socket is no way out: Chromium on +# Wayland asks the render node it names for its DRM version, which KGSL cannot answer, and exits. +# The session is X11 as far as an app can tell; Electron picks its platform from that. +# Its own flatpak options (--branch, --command, from a menu entry) come first, then the app id. +socket=() +if [ -n "${BL_INSIDE:-}" ] || [ -n "${GAMESCOPE_WAYLAND_DISPLAY:-}" ]; then + unset WAYLAND_DISPLAY + export XDG_SESSION_TYPE=x11 + socket=(--nosocket=wayland --socket=x11) +fi +echo "== flatpak run ${socket[*]} $*" +# Apps expect a session bus (settings, portals, single-instance checks); a run mode has none. +if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] && command -v dbus-run-session >/dev/null 2>&1; then + exec dbus-run-session -- flatpak run "${socket[@]}" "$@" +fi +exec flatpak run "${socket[@]}" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup new file mode 100644 index 000000000..9020b38fe --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup @@ -0,0 +1,121 @@ +#!/bin/bash +# Puts Flatpak into the runtime, with Flathub as a per-user remote. Run by the app under proot's +# fake root (pacman and pacman-key refuse any other uid), outside a session. +# +# The runtime's pacman database lists only the base image: the desktop and emulator packages were +# unpacked over it without registering. Asked for flatpak the usual way, pacman "reinstalls" about +# a hundred of those - Mesa among them, over the runtime's own Turnip build with its KGSL backend - +# and then runs every hook in the image, mkinitcpio included. So pacman only fetches and checks the +# few packages Flatpak needs on top of this image, verifying each against the Arch Linux ARM +# keyring, and they are unpacked here without hooks or scripts, like the desktop package itself. +# +# bannerlator-flatpak-setup install or repair; prints "== STEP ..." milestones +set -u +export LANG=C.UTF-8 +step() { echo "== STEP $*"; } +fail() { echo "== FAIL $*"; exit 1; } + +# Flatpak, the libraries it and appstream add to this image, and the D-Bus proxy flatpak run puts +# between an app and the session bus. bubblewrap is not among them: Android gives an app no user +# namespaces, and bannerlator-bwrap stands in for it (FLATPAK_BWRAP). +PACKAGES="flatpak ostree appstream libxmlb libmalcontent libfyaml accountsservice composefs xdg-dbus-proxy" +# Libraries Flatpak and the store's helper also need, which the Desktop package brings but a +# runtime without it may lack: each is fetched only when its file is not already there. +while read -r pkg probe; do + [ -n "$pkg" ] || continue + compgen -G "$probe" >/dev/null || PACKAGES="$PACKAGES $pkg" +done <<'NEEDS' +json-glib /usr/lib/libjson-glib-1.0.so.0 +fuse3 /usr/lib/libfuse3.so.4 +fuse-common /etc/fuse.conf +libsodium /usr/lib/libsodium.so.* +libstemmer /usr/lib/libstemmer.so.* +polkit /usr/lib/libpolkit-gobject-1.so.0 +mpdecimal /usr/lib/libmpdec.so.* +python /usr/bin/python3 +libgirepository /usr/lib/libgirepository-2.0.so.0 +gobject-introspection-runtime /usr/lib/girepository-1.0/GObject-2.0.typelib +python-gobject /usr/lib/python3*/site-packages/gi/__init__.py +NEEDS +WORK=/var/cache/droiddeck-flatpak +CONF=$WORK/pacman.conf +mkdir -p "$WORK/pkg" "$WORK/db" || fail "cannot create $WORK" + +# The image's pacman.conf names a download user this rootfs does not have (only root exists) and a +# sandbox proot cannot set up. Its one mirror is plain HTTP; these serve the same repos over TLS. +{ for m in https://ca.us.mirror.archlinuxarm.org https://fl.us.mirror.archlinuxarm.org \ + https://de3.mirror.archlinuxarm.org https://nl.mirror.archlinuxarm.org; do + echo "Server = $m/\$arch/\$repo"; done + grep '^Server' /etc/pacman.d/mirrorlist 2>/dev/null; } > "$WORK/mirrorlist" +sed -e 's/^DownloadUser/#DownloadUser/' \ + -e 's/^\[options\]/[options]\nDisableSandbox/' \ + -e "s#^Include *= */etc/pacman.d/mirrorlist#Include = $WORK/mirrorlist#" \ + /etc/pacman.conf > "$CONF" || fail "no /etc/pacman.conf" + +if [ ! -f /etc/pacman.d/gnupg/pubring.gpg ] && [ ! -f /etc/pacman.d/gnupg/pubring.kbx ]; then + step "Preparing the package keyring" + pacman-key --init >/dev/null 2>&1 || fail "pacman-key --init failed" + pacman-key --populate archlinuxarm >/dev/null 2>&1 || fail "pacman-key --populate failed" +fi + +# A database of our own, so the image's stays exactly as it was built: -Sy on it would leave the +# rest of the image a partial upgrade behind the repos. +step "Reading the package lists" +pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy >/dev/null 2>&1 \ + || pacman --config "$CONF" --dbpath "$WORK/db" --noconfirm -Sy 2>&1 | tail -3 \ + || fail "could not reach the Arch Linux ARM mirrors" +[ -f "$WORK/db/sync/extra.db" ] || fail "could not reach the Arch Linux ARM mirrors" + +step "Downloading Flatpak" +# -w only downloads; pacman still checks each package's signature against the keyring. -dd: the +# dependencies are already in the image (and checked below by what the binaries actually link). +out=$(pacman --config "$CONF" --dbpath "$WORK/db" --cachedir "$WORK/pkg" --noconfirm -Sw -dd $PACKAGES 2>&1) +rc=$? +echo "$out" | grep -vE '^\s*$' | tail -4 +[ $rc -eq 0 ] || fail "download failed" + +step "Installing Flatpak" +for name in $PACKAGES; do + ver=$(pacman --config "$CONF" --dbpath "$WORK/db" -Sp --print-format '%v' -dd "$name" 2>/dev/null | tail -1) + file=$(ls "$WORK/pkg/$name-$ver-"*.pkg.tar.* 2>/dev/null | grep -v '\.sig$' | head -1) + [ -n "$file" ] || fail "no package file for $name $ver" + bsdtar -xpf "$file" -C / --exclude .PKGINFO --exclude .BUILDINFO --exclude .MTREE --exclude .INSTALL \ + || fail "could not unpack $name" + echo "$name $ver" >> "$WORK/installed.new" +done +mv -f "$WORK/installed.new" "$WORK/installed" +# Only the newest file of each package is worth keeping for a repair. +find "$WORK/pkg" -type f -mtime +1 -delete 2>/dev/null + +# What the binaries link is the real dependency list: anything this image lacks shows here. +missing=$(for f in /usr/bin/flatpak /usr/lib/libflatpak.so.0 /usr/lib/libostree-1.so.1 /usr/bin/xdg-dbus-proxy; do + ldd "$f" 2>&1 | grep -E 'not found|version .* not found' +done | sort -u) +[ -z "$missing" ] || { echo "$missing"; fail "Flatpak is missing libraries"; } +flatpak --version >/dev/null 2>&1 || fail "flatpak does not start" +python3 -c 'import gi; gi.require_version("Flatpak", "1.0"); from gi.repository import Flatpak' 2>/dev/null \ + || fail "the store helper cannot load libflatpak from Python" + +step "Adding Flathub" +export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +# Flathub's descriptor - its URL and the key its commits are signed with - is carried here rather +# than fetched: adding the remote then needs no network, and a descriptor that did not arrive +# whole cannot be what failed. Flathub's own copy: https://dl.flathub.org/repo/flathub.flatpakrepo +cat > "$WORK/flathub.flatpakrepo" <<'REPO' +[Flatpak Repo] +Title=Flathub +Url=https://dl.flathub.org/repo/ +Homepage=https://flathub.org/ +Comment=Central repository of Flatpak applications +Description=Central repository of Flatpak applications +Icon=https://dl.flathub.org/repo/logo.svg +GPGKey=mQINBFlD2sABEADsiUZUOYBg1UdDaWkEdJYkTSZD68214m8Q1fbrP5AptaUfCl8KYKFMNoAJRBXn9FbE6q6VBzghHXj/rSnA8WPnkbaEWR7xltOqzB1yHpCQ1l8xSfH5N02DMUBSRtD/rOYsBKbaJcOgW0K21sX+BecMY/AI2yADvCJEjhVKrjR9yfRX+NQEhDcbXUFRGt9ZT+TI5yT4xcwbvvTu7aFUR/dH7+wjrQ7lzoGlZGFFrQXSs2WI0WaYHWDeCwymtohXryF8lcWQkhH8UhfNJVBJFgCY8Q6UHkZG0FxMu8xnIDBMjBmSZKwKQn0nwzwM2afskZEnmNPYDI8nuNsSZBZSAw+ThhkdCZHZZRwzmjzyRuLLVFpOj3XryXwZcSefNMPDkZAuWWzPYjxS80cm2hG1WfqrG0Gl8+iX69cbQchb7gbEb0RtqNskTo9DDmO0bNKNnMbzmIJ3/rTbSahKSwtewklqSP/01o0WKZiy+n/RAkUKOFBprjJtWOZkc8SPXV/rnoS2dWsJWQZhuPPtv3tefdDiEyp7ePrfgfKxuHpZES0IZRiFI4J/nAUP5bix+srcIxOVqAam68CbAlPvWTivRUMRVbKjJiGXIOJ78wAMjqPg3QIC0GQ0EPAWwAOzzpdgbnG7TCQetaVV8rSYCuirlPYN+bJIwBtkOC9SWLoPMVZTwQARAQABtC5GbGF0aHViIFJlcG8gU2lnbmluZyBLZXkgPGZsYXRodWJAZmxhdGh1Yi5vcmc+iQJUBBMBCAA+FiEEblwF2XnHba+TwIE1QYTdTZB6fK4FAllD2sACGwMFCRLMAwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQQYTdTZB6fK5RJQ/+Ptd4sWxaiAW91FFk7+wmYOkEe1NY2UDNJjEEz34PNP/1RoxveHDt43kYJQ23OWaPJuZAbu+fWtjRYcMBzOsMCaFcRSHFiDIC9aTp4ux/mo+IEeyarYt/oyKb5t5lta6xaAqg7rwt65jW5/aQjnS4h7eFZ+dAKta7Y/fljNrOznUp81/SMcx4QA5G2Pw0hs4Xrxg59oONOTFGBgA6FF8WQghrpR7SnEe0FSEOVsAjwQ13Cfkfa7b70omXSWp7GWfUzgBKyoWxKTqzMN3RQHjjhPJcsQnrqH5enUu4Pcb2LcMFpzimHnUgb9ft72DP5wxfzHGAWOUiUXHbAekfq5iFks8cha/RST6wkxG3Rf44Zn09aOxh1btMcGL+5xb1G0BuCQnA0fP/kDYIPwh9z22EqwRQOspIcvGeLVkFeIfubxpcMdOfQqQnZtHMCabV5Q/Rk9K1ZGc8M2hlg8gHbXMFch2xJ0Wu72eXbA/UY5MskEeBgawTQnQOK/vNm7t0AJMpWK26Qg6178UmRghmeZDj9uNRc3EI1nSbgvmGlpDmCxaAGqaGL1zW4KPW5yN25/qeqXcgCvUjZLI9PNq3Kvizp1lUrbx7heRiSoazCucvHQ1VHUzcPVLUKKTkoTP8okThnRRRsBcZ1+jI4yMWIDLOCT7IW3FePr+3xyuy5eEo9a25Ag0EWUPa7AEQALT/CmSyZ8LWlRYQZKYw417p7Z2hxqd6TjwkwM3IQ1irumkWcTZBZIbBgrSOg6CcXD2oWydCQHWi9qaxhuhEl2bJL5LskmBcMxVdQeD0LLHd8QUnbnnIby8ocvWN1alPfvJFjCUTrmD22U1ycOzRw2lIe4kiQONbOZtdWrVImQQSndjFlisitbmlWHvHm2lOOYy8+GJB7YffVV193hmnBSJffCy4bvkuLxsI+n1DhOzc7MPV3z6HGk4HiEcF0yyt9tCYhpsxHFdBoq2h771HfAcS0s98EVAqYMFnf9em+4cnYpdI6mhIfS1FQiKl6DBAYA8tT3ggla00DurPo0JwX/zN+PaO5h/6O9aCZwV7G6rbkgMuqMergXaf8oP38gr0z+MqWnkfM63Bodq68GP4l4hd02BoFBbDf38TMuGQB14+twJMdfbAxo2MbgluvQgfwHfZ2ca6gyEY+9s/YD1gugLjV+S6CB51WkFNe1z4tAPgJZNxUcKCbeaHNbthl8Hks/pY9RCEseX/EdfzF18epbSjJMPh4DPQXbUoFwmyuYcoBOPmvZHNl9hK7B/1RP8w1ZrXk8qdupC0SNbafX7270B7lMMVImzZetGsM9ypXJ6llhp3FwW09iseNyGJGPsr/dvTMGDXqOPfU/9SAS1LSTY4K9PbRtdrBE318YX8mIk5ABEBAAGJBHIEGAEIACYWIQRuXAXZecdtr5PAgTVBhN1NkHp8rgUCWUPa7AIbAgUJEswDAAJACRBBhN1NkHp8rsF0IAQZAQgAHRYhBFSmzd2JGfsgQgDYrFYnAunj7X7oBQJZQ9rsAAoJEFYnAunj7X7oR6AP/0KYmiAFeqx14Z43/6s2gt3VhxlSd8bmcVV7oJFbMhdHBIeWBp2BvsUf00I0Zl14ZkwCKfLwbbORC2eIxvzJ+QWjGfPhDmS4XUSmhlXxWnYEveSek5Tde+fmu6lqKM8CHg5BNx4GWIX/vdLi1wWJZyhrUwwICAxkuhKxuP2Z1An48930eslTD2GGcjByc27+9cIZjHKa07I/aLffo04V+oMT9/tgzoquzgpVV4jwekADo2MJjhkkPveSNI420bgT+Q7Fi1l0X1aFUniBvQMsaBa27PngWm6xE2ZYvh7nWCdd5g0c0eLIHxWwzV1lZ4Ryx4ITO/VL25ItECcjhTRdYa64sA62MYSaB0x3eR+SihpgP3wSNPFu3MJo6FKTFdi4CBAEmpWHFW7FcRmd+cQXeFrHLN3iNVWryy0HK/CUEJmiZEmpNiXecl4vPIIuyF0zgSCztQtKoMr+injpmQGC/rF/ELBVZTUSLNB350S0Ztvw0FKWDAJSxFmoxt3xycqvvt47rxTrhi78nkk6jATKGyvP55sO+K7Q7Wh0DXA69hvPrYW2eu8jGCdVGxi6HX7L1qcfEd0378S71dZ3g9o6KKl1OsDWWQ6MJ6FGBZedl/ibRfs8p5+sbCX3lQSjEFy3rx6n0rUrXx8U2qb+RCLzJlmC5MNBOTDJwHPcX6gKsUcXZrEQALmRHoo3SrewO41RCr+5nUlqiqV3AohBMhnQbGzyHf2+drutIaoh7Rj80XRh2bkkuPLwlNPf+bTXwNVGse4bej7B3oV6Ae1N7lTNVF4Qh+1OowtGjmfJPWo0z1s6HFJVxoIof9z58Msvgao0zrKGqaMWaNQ6LUeC9g9Aj/9Uqjbo8X54aLiYs8Z1WNc06jKP+gv8AWLtv6CR+l2kLez1YMDucjm7v6iuCMVAmZdmxhg5I/X2+OM3vBsqPDdQpr2TPDLX3rCrSBiS0gOQ6DwN5N5QeTkxmY/7QO8bgLo/Wzu1iilH4vMKW6LBKCaRx5UEJxKpL4wkgITsYKneIt3NTHo5EOuaYk+y2+Dvt6EQFiuMsdbfUjs3seIHsghX/cbPJa4YUqZAL8C4OtVHaijwGo0ymt9MWvS9yNKMyT0JhN2/BdeOVWrHk7wXXJn/ZjpXilicXKPx4udCF76meE+6N2u/T+RYZ7fP1QMEtNZNmYDOfA6sViuPDfQSHLNbauJBo/n1sRYAsL5mcG22UDchJrlKvmK3EOADCQg+myrm8006LltubNB4wWNzHDJ0Ls2JGzQZCd/xGyVmUiidCBUrD537WdknOYE4FD7P0cHaM9brKJ/M8LkEH0zUlo73bY4XagbnCqve6PvQb5G2Z55qhWphd6f4B6DGed86zJEa/RhS +REPO +if ! flatpak remote-list --user --columns=name 2>/dev/null | grep -qx flathub; then + out=$(flatpak remote-add --user --if-not-exists flathub "$WORK/flathub.flatpakrepo" 2>&1) + rc=$? + echo "$out" + # What Flatpak said goes up with the failure, so the app can show it rather than a guess. + [ $rc -eq 0 ] || fail "could not add the Flathub remote: $(echo "$out" | grep -v '^[[:space:]]*$' | tail -1)" +fi +echo "== DONE $(flatpak --version)" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index d50029b0e..72a88e2a1 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -73,14 +73,29 @@ if [ -n "${BL_VK_DRIVER:-}" ]; then fi unset bl_icd_lib fi -# The app's gamescope build (tools/gamescope) takes realtime-priority Vulkan queues on this +# The app's gamescope build (tools/gamescope) asks for realtime-priority Vulkan queues on this # switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority -# where the driver refuses, and the runtime's own build ignores it. -export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1} +# where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it +# changes nothing yet: the driver reports one submit-queue priority and creates every KGSL context +# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it, and off +# unless the app's Performance page turns it on (BL_GAMESCOPE_REALTIME=1). +export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0} + +# A sleep that starts no process. Under proot every exec is a traced execve plus the loader's +# opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60 +# execs a minute each. read -t on a pipe nothing ever writes to waits in bash itself. +nap() { + [ -n "${bl_nap_fd:-}" ] || exec {bl_nap_fd}<> <(:) + read -r -t "$1" -u "$bl_nap_fd" _ 2>/dev/null || : +} export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache} mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" +# Flatpak (the store's apps): its sandboxes through the app instead of bubblewrap, which needs the +# user namespaces Android keeps from apps, and its apps' menu entries and icons on the desktop. +export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +export XDG_DATA_DIRS=$XDG_DATA_HOME/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} # The cores a program runs on outside Steam (the desktop and everything started from it, a # program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the @@ -275,7 +290,7 @@ case $mode in for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true done - sleep 5 + nap 5 done ) & fi @@ -383,8 +398,9 @@ case $mode in [ -z "${BL_DESKTOP:-}" ] && ( last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-1} while :; do - sleep 1 - want=$(cat "$HOME/.droiddeck-fill" 2>/dev/null) || continue + nap 1 + want= + { read -r want || [ -n "$want" ]; } < "$HOME/.droiddeck-fill" 2>/dev/null || continue case $want in 0|1) ;; *) continue ;; esac [ "$want" = "$last" ] && continue if xprop -root -f GAMESCOPE_FORCE_WINDOWS_FULLSCREEN 32c -set GAMESCOPE_FORCE_WINDOWS_FULLSCREEN "$want" 2>/dev/null; then @@ -393,10 +409,30 @@ case $mode in fi done ) & + # Resume from the Steam menu. The menu is an overlay that takes input, and a fullscreen game that + # loses input minimizes itself; gamescope only takes a window out of that state when the focused + # window changes, which the menu never does, so the game stayed minimized after Resume (a black + # screen with its small caption in the top-left corner). gamescope-3.16.29-p4 restores it itself; + # this asks it again a moment after input comes back from the client (app 769) to anything else, + # which covers a game that minimizes late. Harmless when nothing is minimized. + # BL_RESTORE_ON_RESUME=0 turns it off. + [ -z "${BL_DESKTOP:-}" ] && [ "${BL_RESTORE_ON_RESUME:-1}" = 1 ] && ( + prev= + xprop -spy -root GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do + app=${line##*= } + case $app in ''|*[!0-9]*) prev=; continue ;; esac + if [ "$prev" = 769 ] && [ "$app" != 769 ]; then + nap 1 + xprop -root -f GAMESCOPE_RESTORE_FOCUS_WINDOW 32c -set GAMESCOPE_RESTORE_FOCUS_WINDOW 1 2>/dev/null \ + && echo "== resume: asked gamescope to restore the game window (app $app)" + fi + prev=$app + done + ) & ( beat=0 for _ in $(seq 1 720); do - sleep 5 + nap 5 bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true beat=$((beat + 1)) if [ "$((beat % 3))" -eq 0 ]; then @@ -567,7 +603,7 @@ BUSCONF "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true exit 0 fi - sleep 0.5 + nap 0.5 done ) & stop_watcher=$! diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 7040f2dde..28bb1f89d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -146,6 +146,26 @@ bl_game_cpus() { # game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these # titles are told to stay windowed at the session's own size - unless the user's launch options # already say something about the video mode, which are left as they are. +# The session puts the client's own libraries (steamrtarm64, its panorama/) ahead of everything for +# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against +# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime +# container that path never reaches Proton either. (WinNative's winnative-proton-launch.) +BL_LIBPATH_SETUP = """ +bl_libpath() { + [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 + local kept= dir old_ifs=$IFS + IFS=: + for dir in $LD_LIBRARY_PATH; do + case $dir in + "" | */steamrtarm64 | */steamrtarm64/*) ;; + *) kept=${kept:+$kept:}$dir ;; + esac + done + IFS=$old_ifs + if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi +} +""" + BL_IDTECH3_SETUP = """ bl_idtech3() { BL_EXTRA_ARGS=() @@ -233,6 +253,7 @@ for name in %s; do bl_directaudio "$depot" bl_game_cpus bl_idtech3 "$@" + bl_libpath exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" fi done @@ -244,7 +265,7 @@ exit 1 # something else. LAUNCHER_SH = LAUNCHER_SH.replace( "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP, 1) + + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) # A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of # that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot @@ -280,6 +301,7 @@ bl_components "$here" bl_directaudio "$here" bl_game_cpus bl_idtech3 "$@" +bl_libpath # Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be # started from its own tree. exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" @@ -369,6 +391,28 @@ def serialize(tokens): return "\n".join(out) + "\n" +def write_changed(path, text, mode=None): + """Writes `text` to `path` only when it differs, through a rename so a reader never sees half a + file. The session runs this script every 15 s for as long as the client does; rewriting files + that have not changed cost a python start's worth of writes each time, and config.vdf is the + client's own, which it may be writing at that moment. Returns whether anything was written.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + same = f.read() == text + except OSError: + same = False + if not same: + tmp = path + ".bannerlator" + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode: + os.chmod(path, mode) + return not same + + def first_key(path, section): """The single key naming a block inside `section`, or None. Used to keep a third-party tool's own internal name, so a mapping the user already made against it keeps working.""" @@ -442,14 +486,10 @@ def adopt_extras(tools): body = (EXTRA_WRAPPER_SH % entry_point).replace( 'here=$(dirname "$0")', 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP, 1) - with open(wrapper, "w", encoding="utf-8") as f: - f.write(body) - os.chmod(wrapper, 0o755) - with open(manifest, "w", encoding="utf-8") as f: - f.write(EXTRA_MANIFEST_VDF) - with open(compat_vdf, "w", encoding="utf-8") as f: - f.write(EXTRA_COMPAT_TOOL_VDF % (name, entry)) + + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) + write_changed(wrapper, body, 0o755) + write_changed(manifest, EXTRA_MANIFEST_VDF) + write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry)) except OSError as e: print("bannerlator-steam-compat: could not adopt %s (%s)" % (entry, e)) continue @@ -494,7 +534,8 @@ def register_default(config_path, apps=(), name=None, protect=()): # Tool names the user may deliberately have chosen (see adopt_extras); never repointed. keep = {n.lower() for n in protect} with open(config_path, encoding="utf-8", errors="replace") as f: - tokens = tokenize(f.read()) + original = f.read() + tokens = tokenize(original) steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"]) if steam is None: return @@ -521,10 +562,10 @@ def register_default(config_path, apps=(), name=None, protect=()): continue tokens[mapping + 1:mapping + 1] = [ '"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"250"', "}"] - tmp = config_path + ".bannerlator" - with open(tmp, "w", encoding="utf-8") as f: - f.write(serialize(tokens)) - os.replace(tmp, config_path) + # Nothing to change is the usual case on the session's beat: the client's own file is left alone. + text = serialize(tokens) + if text != serialize(tokenize(original)): + write_changed(config_path, text) def find_source(steam_root): @@ -542,18 +583,14 @@ def build_tool(tool_dir): """Our own manifests and the launcher. The depot is found at launch time, not here.""" if os.path.islink(tool_dir): os.unlink(tool_dir) - elif os.path.isdir(tool_dir): - # Earlier versions mirrored the depot in here as symlinks; start clean. + elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES): + # Earlier versions mirrored the depot in here as symlinks; start clean. A directory holding + # only our own files is kept, and each file rewritten only when it changed. shutil.rmtree(tool_dir) - os.makedirs(tool_dir) - with open(os.path.join(tool_dir, "toolmanifest.vdf"), "w", encoding="utf-8") as f: - f.write(TOOL_MANIFEST_VDF) - with open(os.path.join(tool_dir, "compatibilitytool.vdf"), "w", encoding="utf-8") as f: - f.write(COMPAT_TOOL_VDF % TOOL) - launcher = os.path.join(tool_dir, LAUNCHER) - with open(launcher, "w", encoding="utf-8") as f: - f.write(LAUNCHER_SH) - os.chmod(launcher, 0o755) + os.makedirs(tool_dir, exist_ok=True) + write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF) + write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % TOOL) + write_changed(os.path.join(tool_dir, LAUNCHER), LAUNCHER_SH, 0o755) def main(): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games index 1e6f2a1fd..3ab3e9844 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -135,6 +135,10 @@ def watch(steam_root, listing): candidates, sources = {}, {} next_lookup = 0 last_status = None + # The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership + # only moves with a purchase or a new match, so it runs when the matches or their sources + # change, after a failed probe, and otherwise every five minutes - not on every 30 s beat. + probed, next_probe = None, 0 while True: now = time.monotonic() for game in games: @@ -150,13 +154,16 @@ def watch(steam_root, listing): if now >= next_lookup: next_lookup = now + 300 status = "waiting for a matching Steam title" - if candidates: + key = (dict(candidates), dict(sources)) + if candidates and (key != probed or now >= next_probe): + probed, next_probe = key, now + 300 try: result = subprocess.run([sys.executable, __file__, "probe", steam_root, *map(str, sorted(set(candidates.values())))], capture_output=True, text=True, timeout=15) if result.returncode != 0: status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode)) + probed = None else: line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP=")) snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP=")) @@ -168,6 +175,9 @@ def watch(steam_root, listing): status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values())) except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error: status = "ownership check unavailable: " + str(error) + probed = None + elif candidates: + status = last_status if status != last_status: print("bannerlator-steam-games: " + status, flush=True) last_status = status diff --git a/tools/linuxfs/preload/inputudev.c b/tools/linuxfs/preload/inputudev.c index 5600538ea..c1d04f029 100644 --- a/tools/linuxfs/preload/inputudev.c +++ b/tools/linuxfs/preload/inputudev.c @@ -20,6 +20,14 @@ * Only Wine's device host is answered, and only for the session's own nodes: the Steam client * enumerates the same nodes for itself and would take a pad wearing Steam Input's identity for one * of its own and ignore it. Every other caller, path and device object goes straight through. + * + * With libfakeinput's /dev/uinput stand-in (FAKE_EVDEV_UINPUT=1) Steam Input's own virtual pads + * exist too, as event16 and up, and those are what Wine is meant to read: each is described with + * the identity the client gave it, from the .uevent file libfakeinput writes beside the node. + * While one exists the app's pads are described as what they are, an Xbox 360 controller, which + * Wine's HID bus leaves alone - the game reads Steam Input's output, not the pad under it, as on + * a Steam Deck. With none (Steam Input off for the game) they keep the virtual identity, so the + * game still has a controller. */ #define _GNU_SOURCE #include @@ -31,8 +39,11 @@ #include #include -/* The app opens one ring per configured controller; four is what its launcher offers. */ -#define PAD_MAX 4 +/* The app opens one ring per configured controller; four is what its launcher offers. Nodes from + * UINPUT_BASE up are the virtual pads made through libfakeinput's /dev/uinput stand-in. */ +#define RING_MAX 4 +#define UINPUT_BASE 16 +#define PAD_MAX 64 #define EVENT_MINOR_BASE 64 /* BUS_VIRTUAL, and the identity Steam Input gives the gamepad it presents to a game. */ @@ -41,16 +52,23 @@ "NAME=\"Generic HID Gamepad %d\"\n" \ "PHYS=\"usb-fakeinput/input%d\"\n" +/* The identity libfakeinput gives the pad itself (FAKE_EVDEV_IDENTITY=xbox360). */ +#define PAD_PHYSICAL_UEVENT_FORMAT \ + "PRODUCT=3/45e/28e/110\n" \ + "NAME=\"Xbox 360 Controller (%d)\"\n" \ + "PHYS=\"usb-fakeinput/input%d\"\n" + struct pad { char syspath[64]; char devnode[32]; char sysname[16]; - char uevent[128]; + char uevent[256]; dev_t devnum; }; static struct pad pads[PAD_MAX]; static int pad_count; +static pthread_mutex_t virtual_lock = PTHREAD_MUTEX_INITIALIZER; /* * The slots the app published rings for, named in FAKE_EVDEV_MEMFD_PATHS as "=" @@ -66,7 +84,7 @@ static void discover_pads(void) { char *end; long slot = strtol(entry, &end, 10); - if (end != entry && *end == '=' && slot >= 0 && slot < PAD_MAX) { + if (end != entry && *end == '=' && slot >= 0 && slot < RING_MAX) { struct pad *pad = &pads[slot]; if (pad->devnode[0] == '\0') { @@ -90,6 +108,66 @@ static void ensure_pads(void) { pthread_once(&once, discover_pads); } +static int uinput_enabled(void) { + const char *enabled = getenv("FAKE_EVDEV_UINPUT"); + return enabled != NULL && atoi(enabled) != 0; +} + +/* Reads the .uevent libfakeinput wrote for virtual pad into buf; 0 when there is none. */ +static int read_virtual_uevent(int node, char *buf, size_t size) { + const char *dir = getenv("FAKE_EVDEV_DIR"); + char path[PATH_MAX]; + FILE *file; + size_t length; + + if (dir == NULL) return 0; + snprintf(path, sizeof(path), "%s/.uinput-event%d.uevent", dir, node); + if ((file = fopen(path, "re")) == NULL) return 0; + length = fread(buf, 1, size - 1, file); + fclose(file); + buf[length] = '\0'; + return length > 0; +} + +static int any_virtual_pad(void) { + char uevent[256]; + int node; + + for (node = UINPUT_BASE; node < PAD_MAX; node++) + if (read_virtual_uevent(node, uevent, sizeof(uevent))) return 1; + return 0; +} + +/* + * Brings the entry for event up to date before Wine resolves it: a virtual pad comes and + * goes with the client, and the app's pads change identity with it. + */ +static void refresh_pad(int node) { + struct pad *pad; + + if (node < 0 || node >= PAD_MAX) return; + pad = &pads[node]; + pthread_mutex_lock(&virtual_lock); + if (node >= UINPUT_BASE) { + char uevent[sizeof(pad->uevent)]; + + if (read_virtual_uevent(node, uevent, sizeof(uevent))) { + snprintf(pad->sysname, sizeof(pad->sysname), "event%d", node); + snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/bannerlator%d", node); + memcpy(pad->uevent, uevent, sizeof(uevent)); + pad->devnum = makedev(13, EVENT_MINOR_BASE + (unsigned int)node); + snprintf(pad->devnode, sizeof(pad->devnode), "/dev/input/event%d", node); + if (node >= pad_count) pad_count = node + 1; + } else { + pad->devnode[0] = '\0'; + } + } else if (pad->devnode[0] != '\0' && uinput_enabled()) { + snprintf(pad->uevent, sizeof(pad->uevent), + any_virtual_pad() ? PAD_PHYSICAL_UEVENT_FORMAT : PAD_UEVENT_FORMAT, node, node); + } + pthread_mutex_unlock(&virtual_lock); +} + /* The pad a synthetic syspath names, or NULL when the path is not one of ours. */ static struct pad *pad_for_syspath(const char *syspath) { int i; @@ -123,6 +201,7 @@ static struct pad *pad_for_syslink(const char *path) { if (path == NULL || strncmp(path, prefix, sizeof(prefix) - 1) != 0) return NULL; name = path + sizeof(prefix) - 1; ensure_pads(); + if (strncmp(name, "event", 5) == 0 && name[5] >= '0' && name[5] <= '9') refresh_pad(atoi(name + 5)); for (i = 0; i < pad_count; i++) { if (pads[i].devnode[0] != '\0' && strcmp(pads[i].sysname, name) == 0) return &pads[i]; } diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index 503e4fbed..ef781dc8a 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -45,6 +45,16 @@ Ported from WinNative (`main`, 53836ca9, "Fix/performance and vac"): with no children or ptracees no longer walks every tracee; the per-stop memory collector is emptied instead of freed and reallocated. +Added for Flatpak: + +- `0010-new-mount-api-enosys.patch` - `open_tree`, `move_mount`, `fspick` and `mount_setattr` + answer `ENOSYS`. They take paths proot never translated, so libglnx's `open_tree(AT_FDCWD, "/")` + handed Flatpak a descriptor for the host's root and it tried to create directories there + (`mkdirat(root): Operation not permitted`); unsupported, libglnx falls back to `openat`. +- `0012-android-hardlink-denial.patch` - Android's SELinux policy denies apps hard links, so + `linkat` fails with `EACCES` and Flatpak could not create its repo (`Creating repo: linkat: + Permission denied`). `O_TMPFILE` answers `EOPNOTSUPP`, so libglnx writes a named temporary file + and renames it, and a denied link answers `EPERM`, on which ostree's checkout copies instead. Added by DroidDeck: - `0011-kompat-utsname-only.patch` - `--kernel-release` (the guest's `DroidDeck` hostname) loads @@ -55,3 +65,18 @@ Added by DroidDeck: and `setdomainname` and leaves the auxv as the kernel wrote it. On an SD 8 Gen 2 guest this took a futex ping-pong from 467 to 97 us, `epoll_pwait` from 60 to 0.8 us and `fcntl` from 40-107 to 0.4 us, and `clock_gettime` back to the vDSO. +- `0012-fake_id0-identity-only.patch` - `-i uid:gid` (for Xwayland's setgid/setuid before it runs + xkbcomp) loads fake_id0, whose filter traps every `fstat`/`newfstatat`/`stat` (entry and exit), + every `sendmsg` (all Wayland, X11, Chromium and PulseAudio traffic), `socket`, `getsockopt`, the + `get*id` family and the chown/chmod family. When the ids given are the ones proot really has and + are not 0, every one of those handlers is a no-op; fake_id0 now traces only the `set*id` family + and the xattr permission fixups, and leaves set-user-ID bits alone on `execve` (Android mounts the + app's data `nosuid`, so the kernel would not honour them either), which keeps the ids fixed. On an + x86_64 host build under the same options: `fstat` 41.7 -> 1.3 us, `sendmsg` 18.0 -> 2.5 us. +- `0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch` - every `ioctl` stopped on entry and + exit, which is every GPU submit and wait. The filter now traces only the requests enter.c and + exit.c act on (`TCSETSF`, the four termios2 requests, `FICLONE`) and allows the rest at once, and + the ioctl block is emitted first (after WinNative 53836ca9, which traces only the termios2 ones). + The `faccessat2` exit stop (termux 5ba8b95, for glibc's ENOSYS fallback on kernels before 5.8) and + the `statx` one (emulation on kernels before 4.11) are dropped when the running kernel is newer. + Host build: `ioctl` 31.6 -> 0.5 us; `stat`/`statx`/`faccessat2` about 44 -> 28 us (one stop). diff --git a/tools/proot/patches/0010-new-mount-api-enosys.patch b/tools/proot/patches/0010-new-mount-api-enosys.patch new file mode 100644 index 000000000..87fd43d1f --- /dev/null +++ b/tools/proot/patches/0010-new-mount-api-enosys.patch @@ -0,0 +1,105 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 08:00:00 -0400 +Subject: [PATCH] syscall: answer the new mount API with ENOSYS + +open_tree(AT_FDCWD, "/") and its relatives take paths proot never +translates, so libglnx (Flatpak) got a descriptor for the host root and +created directories there. Unsupported, callers fall back to openat. +--- +diff --git a/src/syscall/enter.c b/src/syscall/enter.c +index 1d3b3fd..32d2503 100644 +--- a/src/syscall/enter.c ++++ b/src/syscall/enter.c +@@ -465,6 +465,15 @@ int translate_syscall_enter(Tracee *tracee) + status = -ENOSYS; + break; + ++ /* The new mount API opens and moves paths proot never sees: open_tree(AT_FDCWD, "/") ++ * hands back the host's root. Callers (libglnx, systemd) fall back to openat. */ ++ case PR_open_tree: ++ case PR_move_mount: ++ case PR_fspick: ++ case PR_mount_setattr: ++ status = -ENOSYS; ++ break; ++ + case PR_inotify_add_watch: + flags = peek_reg(tracee, CURRENT, SYSARG_3); + +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 0b381bb..6ba7b67 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -457,6 +457,10 @@ static FilteredSysnum proot_sysnums[] = { + { PR_open, 0 }, + { PR_openat, 0 }, + { PR_openat2, 0 }, ++ { PR_open_tree, 0 }, ++ { PR_move_mount, 0 }, ++ { PR_fspick, 0 }, ++ { PR_mount_setattr, 0 }, + { PR_pivot_root, 0 }, + { PR_prctl, 0 }, + { PR_prlimit64, FILTER_SYSEXIT }, +diff --git a/src/syscall/sysnums-arm.h b/src/syscall/sysnums-arm.h +index e3f6f6b..ebf0afc 100644 +--- a/src/syscall/sysnums-arm.h ++++ b/src/syscall/sysnums-arm.h +@@ -358,6 +358,10 @@ static const Sysnum sysnums_arm[] = { + [ 397 ] = PR_statx, + [ 439 ] = PR_faccessat2, + [ 435 ] = PR_clone3, ++ [ 428 ] = PR_open_tree, ++ [ 429 ] = PR_move_mount, ++ [ 433 ] = PR_fspick, + [ 437 ] = PR_openat2, ++ [ 442 ] = PR_mount_setattr, + [ 452 ] = PR_fchmodat2, + }; +diff --git a/src/syscall/sysnums-arm64.h b/src/syscall/sysnums-arm64.h +index b257339..c68a1f8 100644 +--- a/src/syscall/sysnums-arm64.h ++++ b/src/syscall/sysnums-arm64.h +@@ -278,8 +278,12 @@ static const Sysnum sysnums_arm64[] = { + [ 289 ] = PR_pkey_alloc, + [ 290 ] = PR_pkey_free, + [ 291 ] = PR_statx, ++ [ 428 ] = PR_open_tree, ++ [ 429 ] = PR_move_mount, ++ [ 433 ] = PR_fspick, + [ 435 ] = PR_clone3, + [ 437 ] = PR_openat2, ++ [ 442 ] = PR_mount_setattr, + [ 439 ] = PR_faccessat2, + [ 452 ] = PR_fchmodat2, + }; +diff --git a/src/syscall/sysnums.list b/src/syscall/sysnums.list +index c221ddd..93ea2e2 100644 +--- a/src/syscall/sysnums.list ++++ b/src/syscall/sysnums.list +@@ -86,6 +86,7 @@ SYSNUM(flock) + SYSNUM(fork) + SYSNUM(fremovexattr) + SYSNUM(fsetxattr) ++SYSNUM(fspick) + SYSNUM(fstat) + SYSNUM(fstat64) + SYSNUM(fstatat64) +@@ -191,6 +192,8 @@ SYSNUM(mmap) + SYSNUM(mmap2) + SYSNUM(modify_ldt) + SYSNUM(mount) ++SYSNUM(mount_setattr) ++SYSNUM(move_mount) + SYSNUM(move_pages) + SYSNUM(mprotect) + SYSNUM(mpx) +@@ -221,6 +224,7 @@ SYSNUM(oldstat) + SYSNUM(olduname) + SYSNUM(open) + SYSNUM(open_by_handle_at) ++SYSNUM(open_tree) + SYSNUM(openat) + SYSNUM(openat2) + SYSNUM(pause) diff --git a/tools/proot/patches/0012-android-hardlink-denial.patch b/tools/proot/patches/0012-android-hardlink-denial.patch new file mode 100644 index 000000000..e2d089fb3 --- /dev/null +++ b/tools/proot/patches/0012-android-hardlink-denial.patch @@ -0,0 +1,82 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 15:00:00 -0400 +Subject: [PATCH] syscall: work around Android's denial of hard links + +SELinux denies apps the file link permission, so linkat fails with +EACCES. libostree names its O_TMPFILE files with linkat and failed to +create the Flathub repo ("Creating repo: linkat: Permission denied"). +O_TMPFILE now answers EOPNOTSUPP, so libglnx falls back to a named +temporary file and a rename, and a denied link answers EPERM, which +ostree's checkout takes as a filesystem without hard links and copies. +--- +diff --git a/src/syscall/enter.c b/src/syscall/enter.c +index 8c3eb2e..4c28e10 100644 +--- a/src/syscall/enter.c ++++ b/src/syscall/enter.c +@@ -400,6 +400,13 @@ int translate_syscall_enter(Tracee *tracee) + + case PR_open: + flags = peek_reg(tracee, CURRENT, SYSARG_2); ++#ifdef __ANDROID__ ++ /* An O_TMPFILE file can only be named by linkat, which Android denies apps. */ ++ if ((flags & __O_TMPFILE) != 0) { ++ status = -EOPNOTSUPP; ++ break; ++ } ++#endif + + if ( ((flags & O_NOFOLLOW) != 0) + || ((flags & O_EXCL) != 0 && (flags & O_CREAT) != 0)) +@@ -518,6 +525,12 @@ int translate_syscall_enter(Tracee *tracee) + case PR_openat: + dirfd = peek_reg(tracee, CURRENT, SYSARG_1); + flags = peek_reg(tracee, CURRENT, SYSARG_3); ++#ifdef __ANDROID__ ++ if ((flags & __O_TMPFILE) != 0) { ++ status = -EOPNOTSUPP; ++ break; ++ } ++#endif + + status = get_sysarg_path(tracee, path, SYSARG_2); + if (status < 0) +diff --git a/src/syscall/exit.c b/src/syscall/exit.c +index df41084..5d981db 100644 +--- a/src/syscall/exit.c ++++ b/src/syscall/exit.c +@@ -544,6 +544,16 @@ void translate_syscall_exit(Tracee *tracee) + status = handle_statx_syscall(tracee, false); + break; + ++#ifdef __ANDROID__ ++ /* Android denies apps hard links with EACCES; EPERM is what callers such as ++ * libostree take as "no hard links here" and copy instead. */ ++ case PR_link: ++ case PR_linkat: ++ if ((int) syscall_result == -EACCES) ++ poke_reg(tracee, SYSARG_RESULT, -EPERM); ++ goto end; ++#endif ++ + case PR_ioctl: + if (peek_reg(tracee, ORIGINAL, SYSARG_2) == _IOW(0x94, 9, int) /* FICLONE */ && + (int) peek_reg(tracee, CURRENT, SYSARG_RESULT) == -EACCES) { +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 6d1ba47..8db9f45 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -362,8 +362,13 @@ static FilteredSysnum proot_sysnums[] = { + { PR_lchown, 0 }, + { PR_lchown32, 0 }, + { PR_lgetxattr, 0 }, ++#ifdef __ANDROID__ ++ { PR_link, FILTER_SYSEXIT }, ++ { PR_linkat, FILTER_SYSEXIT }, ++#else + { PR_link, 0 }, + { PR_linkat, 0 }, ++#endif + { PR_listxattr, 0 }, + { PR_llistxattr, 0 }, + { PR_lremovexattr, 0 }, diff --git a/tools/proot/patches/0012-fake_id0-identity-only.patch b/tools/proot/patches/0012-fake_id0-identity-only.patch new file mode 100644 index 000000000..155b62b8e --- /dev/null +++ b/tools/proot/patches/0012-fake_id0-identity-only.patch @@ -0,0 +1,103 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 00:00:00 +0000 +Subject: [PATCH] fake_id0: only trace the set*id and xattr calls when the ids + are PRoot's own + +--- + src/extension/fake_id0/config.h | 5 ++++ + src/extension/fake_id0/fake_id0.c | 44 ++++++++++++++++++++++++++++++- + 2 files changed, 48 insertions(+), 1 deletion(-) + +diff --git a/src/extension/fake_id0/config.h b/src/extension/fake_id0/config.h +index 2e78783..5227ced 100644 +--- a/src/extension/fake_id0/config.h ++++ b/src/extension/fake_id0/config.h +@@ -2,6 +2,7 @@ + #define FAKE_ID0_CONFIG_H + + #include /* uid_t, gid_t */ ++#include /* bool, */ + + typedef struct { + uid_t ruid; +@@ -15,6 +16,10 @@ typedef struct { + gid_t fsgid; + + mode_t umask; ++ ++ /* The emulated ids are the ones PRoot really has: see ++ * identity_sysnums in fake_id0.c. */ ++ bool identity; + } Config; + + #endif /* FAKE_ID0_CONFIG_H */ +diff --git a/src/extension/fake_id0/fake_id0.c b/src/extension/fake_id0/fake_id0.c +index 9477f1c..fe08372 100644 +--- a/src/extension/fake_id0/fake_id0.c ++++ b/src/extension/fake_id0/fake_id0.c +@@ -352,6 +352,40 @@ static FilteredSysnum filtered_sysnums[] = { + FILTERED_SYSNUM_END, + }; + ++/* When the emulated ids are the ones PRoot really has, and the tracee ++ * is not root, every handler for the syscalls above but these is a ++ * no-op: stat and get*id report the real ids already, SCM_CREDENTIALS ++ * and SO_PEERCRED carry them, chown swaps an id for itself, and the ++ * permission and chroot fixups only apply to euid 0. The set*id ++ * family is still emulated (Android's app seccomp policy traps it) and ++ * so are the xattr permission fixups, which apply to any euid. An ++ * unprivileged set*id can only take an id the tracee already holds, ++ * and set-user-ID bits are not honoured in this mode (Android mounts ++ * the app's data nosuid, so the kernel would not honour them either), ++ * so the ids never change and the list stays sufficient. */ ++static FilteredSysnum identity_sysnums[] = { ++ { PR_fsetxattr, FILTER_SYSEXIT }, ++ { PR_lsetxattr, FILTER_SYSEXIT }, ++ { PR_setfsgid, FILTER_SYSEXIT }, ++ { PR_setfsgid32, FILTER_SYSEXIT }, ++ { PR_setfsuid, FILTER_SYSEXIT }, ++ { PR_setfsuid32, FILTER_SYSEXIT }, ++ { PR_setgid, FILTER_SYSEXIT }, ++ { PR_setgid32, FILTER_SYSEXIT }, ++ { PR_setregid, FILTER_SYSEXIT }, ++ { PR_setregid32, FILTER_SYSEXIT }, ++ { PR_setresgid, FILTER_SYSEXIT }, ++ { PR_setresgid32, FILTER_SYSEXIT }, ++ { PR_setresuid, FILTER_SYSEXIT }, ++ { PR_setresuid32, FILTER_SYSEXIT }, ++ { PR_setreuid, FILTER_SYSEXIT }, ++ { PR_setreuid32, FILTER_SYSEXIT }, ++ { PR_setuid, FILTER_SYSEXIT }, ++ { PR_setuid32, FILTER_SYSEXIT }, ++ { PR_setxattr, FILTER_SYSEXIT }, ++ FILTERED_SYSNUM_END, ++}; ++ + /** + * Restore the @node->mode for the given @node->path. + * +@@ -1051,6 +1085,11 @@ static int handle_sysexit_start(Tracee *tracee, Config *config) { + if ((int) result < 0 || tracee->status < 0 || sysnum != PR_execve) + return 0; + ++ /* The auxv already carries the real ids, and set-user-ID bits ++ * are not honoured in this mode: see identity_sysnums. */ ++ if (config->identity) ++ return 0; ++ + /* This has to be done before PRoot pushes the load + * script into tracee's stack. */ + if (!tracee->skip_proot_loader) +@@ -1119,7 +1158,10 @@ int fake_id0_callback(Extension *extension, ExtensionEvent event, intptr_t data1 + /* Set the umask to the typical linux value. */ + config->umask = 022; + +- extension->filtered_sysnums = filtered_sysnums; ++ config->identity = (uid != 0 && uid == (int) getuid() && gid == (int) getgid()); ++ extension->filtered_sysnums = config->identity ++ ? identity_sysnums ++ : filtered_sysnums; + return 0; + } + diff --git a/tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch b/tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch new file mode 100644 index 000000000..eaf62c565 --- /dev/null +++ b/tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch @@ -0,0 +1,169 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 00:00:00 +0000 +Subject: [PATCH] seccomp: trace ioctl by request, drop exit stops the kernel + does not need + +--- + src/syscall/seccomp.c | 98 ++++++++++++++++++++++++++++++++++++++++++- + 1 file changed, 97 insertions(+), 1 deletion(-) + +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 0b381bb..1116ab2 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -39,6 +39,11 @@ + #include /* uint*_t, UINT*_MAX, */ + #include + #include ++#include /* uname(2), */ ++#include /* sscanf(3), */ ++#include /* TCGETS2, TCSETS*2, */ ++#include /* struct termios2, */ ++#include /* FICLONE, */ + #include /* assert(3), */ + + #include "syscall/seccomp.h" +@@ -166,6 +171,46 @@ static int add_trace_prlimit_stack(struct sock_fprog *program, word_t syscall, i + return add_statements(program, LENGTH_TRACE_PRLIMIT_STACK, statements); + } + ++/** ++ * Append to @program->filter the statements that trace @syscall ++ * (ioctl) only for the requests enter.c and exit.c act on - the ++ * termios requests Android's policy denies an app on a pty, rewritten ++ * to ones it allows, and FICLONE, whose EACCES is turned into ++ * EOPNOTSUPP - and allow any other request at once. Every GPU submit ++ * and wait is an ioctl, so they no longer stop in the tracer, and the ++ * block is emitted first so they are also spared the rest of the list. ++ * (After WinNative, maxjivi05, 53836ca9.) ++ */ ++static int add_trace_ioctl_requests(struct sock_fprog *program, word_t syscall, int flag) ++{ ++ const size_t request_offset = offsetof(struct seccomp_data, args[1]); ++ ++ #define LENGTH_TRACE_IOCTL 10 ++ struct sock_filter statements[LENGTH_TRACE_IOCTL] = { ++ /* Not this syscall: skip the block, the accumulator ++ * still holds the syscall number. */ ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, syscall, 0, 9), ++ ++ /* Load the request's low word into the accumulator. */ ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, request_offset), ++ ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSF, 5, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCGETS2, 4, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETS2, 3, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSW2, 2, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSF2, 1, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, (uint32_t) FICLONE, 0, 1), ++ ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRACE + flag), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW) ++ }; ++ ++ DEBUG_FILTER("FILTER: trace if syscall == %ld and a termios2/TCSETSF/FICLONE request\n", ++ syscall); ++ ++ return add_statements(program, LENGTH_TRACE_IOCTL, statements); ++} ++ + static size_t trace_length(Sysnum sysnum) + { + switch (sysnum) { +@@ -174,6 +219,8 @@ static size_t trace_length(Sysnum sysnum) + return LENGTH_TRACE_SYSCALL_IF_ARG; + case PR_prlimit64: + return LENGTH_TRACE_PRLIMIT_STACK; ++ case PR_ioctl: ++ return LENGTH_TRACE_IOCTL; + default: + return LENGTH_TRACE_SYSCALL; + } +@@ -191,6 +238,8 @@ static int add_trace(struct sock_fprog *program, Sysnum sysnum, word_t syscall, + return add_trace_syscall_if_arg(program, syscall, 0, RLIMIT_STACK, flag); + case PR_prlimit64: + return add_trace_prlimit_stack(program, syscall, flag); ++ case PR_ioctl: ++ return add_trace_ioctl_requests(program, syscall, flag); + default: + return add_trace_syscall(program, syscall, flag); + } +@@ -329,7 +378,7 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + + struct sock_fprog program = { .len = 0, .filter = NULL }; + size_t section_length; +- size_t i, j, k; ++ size_t i, j, k, pass; + int status; + + status = new_program_filter(&program); +@@ -355,8 +404,14 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + if (status < 0) + goto end; + ++ /* Two passes: ioctl first, it is by far the most frequent ++ * syscall and almost every one of them is allowed. */ ++ for (pass = 0; pass < 2; pass++) + for (j = 0; j < seccomp_archs[i].nb_abis; j++) { + for (k = 0; sysnums[k].value != PR_void; k++) { ++ if ((sysnums[k].value == PR_ioctl) != (pass == 0)) ++ continue; ++ + /* Get the architecture specific syscall number. */ + syscall = detranslate_sysnum(seccomp_archs[i].abis[j], sysnums[k].value); + if (syscall == SYSCALL_AVOIDER) +@@ -572,12 +627,53 @@ int enable_syscall_filtering(const Tracee *tracee) + return 0; + } + ++/** ++ * Drop the exit stops PRoot only needs on kernels older than the one it ++ * runs on. The faccessat2 exit stop restores the arguments for glibc's ++ * fallback to faccessat on ENOSYS (kernels before 5.8, termux 5ba8b95); ++ * the statx one emulates the call when it fails, which only a kernel ++ * before 4.11 does for want of it - a statx Android's policy traps is ++ * emulated from SIGSYS instead. Both stops re-read and re-translate ++ * the path, and are paid on every call. ++ */ ++static void drop_unneeded_exit_stops(void) ++{ ++ static bool done = false; ++ struct utsname utsname; ++ int major, minor; ++ size_t i; ++ ++ if (done) ++ return; ++ done = true; ++ ++ if (uname(&utsname) < 0 || sscanf(utsname.release, "%d.%d", &major, &minor) != 2) ++ return; ++ ++ for (i = 0; proot_sysnums[i].value != PR_void; i++) { ++ switch (proot_sysnums[i].value) { ++ case PR_faccessat2: ++ if (major > 5 || (major == 5 && minor >= 8)) ++ proot_sysnums[i].flags &= ~FILTER_SYSEXIT; ++ break; ++ case PR_statx: ++ if (major > 4 || (major == 4 && minor >= 11)) ++ proot_sysnums[i].flags &= ~FILTER_SYSEXIT; ++ break; ++ default: ++ break; ++ } ++ } ++} ++ + static int collect_filtered_sysnums(TALLOC_CTX *context, const Tracee *tracee, + FilteredSysnum **filtered_sysnums) + { + Extension *extension; + int status; + ++ drop_unneeded_exit_stops(); ++ + /* Add the sysnums required by PRoot to the list of filtered + * sysnums. TODO: only if path translation is required. */ + status = merge_filtered_sysnums(context, filtered_sysnums, proot_sysnums);