From cc37cc8a4d6546fd3b4c9280e7dad7d5028e3e50 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:33:33 -0400 Subject: [PATCH 01/14] perf: stop proot's fake_id0 and ioctl traps from stopping every stat, sendmsg and GPU ioctl fake_id0 (loaded by -i for Xwayland's set*id) now traces only the set*id family and the xattr fixups when the ids given are proot's own, and the seccomp filter traces ioctl only for the termios and FICLONE requests proot rewrites. The faccessat2 and statx exit stops are dropped on kernels that have the calls. --- tools/proot/PATCHES.md | 15 ++ .../patches/0012-fake_id0-identity-only.patch | 103 +++++++++++ ...ctl-by-request-and-kernel-exit-stops.patch | 169 ++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 tools/proot/patches/0012-fake_id0-identity-only.patch create mode 100644 tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index 503e4fbed..f2dc70854 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -55,3 +55,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/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); From 62bb7a1e80e572997fb3e7d171f057f52daa2702 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 02/14] perf: pace buffer releases to the screen, read the requested refresh, drop sustained mode and ADPF A replaced buffer went straight back to a game with no frame limit, so a MAILBOX/IMMEDIATE swapchain drew frames that were never shown; it now comes back once per refresh as in WinNative. gamescope's -r and the compositor read the rate of the mode the activity asked for rather than the one the panel was still in, and the zero-copy layer votes it. Sustained performance mode, which caps clocks on most power HALs, and the ADPF session, which reported frame intervals as work, are gone. --- app/src/main/cpp/waylandcomp/src/compositor.c | 13 ++- .../com/droiddeck/launcher/SessionActivity.kt | 17 ++-- .../droiddeck/launcher/session/PerfHints.kt | 87 ------------------- .../droiddeck/launcher/session/PerfMode.kt | 17 ++-- .../launcher/wayland/CompositorHost.kt | 4 + .../launcher/wayland/WaylandCompositor.java | 3 +- 6 files changed, 30 insertions(+), 111 deletions(-) delete mode 100644 app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt 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/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 82d9f86e6..71f52be82 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 @@ -203,8 +202,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 @@ -761,9 +760,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 +845,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 } 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/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 From 1066ff5730e8d21693f2a2b4434ba61aae352760 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 03/14] perf: default to Turnip sysmem, compact Zink descriptors and the Performance + TSO FEX preset WinNative's defaults for every Linux session. Choices already saved by a user are kept. --- .../launcher/session/SessionPrefs.kt | 25 +++++++++++++++---- .../launcher/session/SessionService.kt | 6 ++++- .../launcher/ui/PerformanceDialog.kt | 4 +-- .../session/GameEnvironmentStoreTest.kt | 9 +++++++ 4 files changed, 36 insertions(+), 8 deletions(-) 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 ec0d09559..820c6e401 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -227,8 +227,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() @@ -252,7 +257,10 @@ object SessionPrefs { 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) { @@ -309,8 +317,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 590dd834e..913328d5b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -513,7 +513,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). 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..16f7a0e50 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -86,7 +86,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( @@ -98,7 +98,7 @@ fun PerformancePage( 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( 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) From 2fd209be83cf64f6a26223986df2163d25e157a1 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 04/14] perf: poll without starting processes, and stop rewriting unchanged Steam files The session's watchers slept with sleep(1) and read files with cat, an exec each under proot; they now wait in bash itself. steam-compat writes its tool, the adopted wrappers and config.vdf only when they change, the games watcher probes ownership when its matches change or every five minutes instead of every 30 s, and Proton no longer inherits the client's steamrtarm64 library path. --- .../overlay/usr/local/bin/bannerlator-session | 25 ++++-- .../usr/local/bin/bannerlator-steam-compat | 87 +++++++++++++------ .../usr/local/bin/bannerlator-steam-games | 12 ++- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 68b10d670..de4c6918b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -73,10 +73,20 @@ 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. +# 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. export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1} + +# 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} @@ -266,7 +276,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 @@ -374,8 +384,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 @@ -387,7 +398,7 @@ case $mode in ( 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 @@ -558,7 +569,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 From 72e41474492c13ae07921ea424abc7aec87c989d Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 05/14] perf(gamescope): ask X for the pointer only while a cursor is drawn The gamepad-cursor port queried the X pointer on every vblank, a blocking round trip to Xwayland on the paint thread. With no cursor image the position comes from wlserver as upstream; while the cursor is hidden for inactivity X is asked every 50 ms. The realtime-queue comment now says it is a no-op on KGSL Turnip. --- tools/gamescope/PATCHES.md | 7 +- ...00-realtime-queue-and-gamepad-cursor.patch | 87 ++++++++++++------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index 8fa264904..fdc158aa7 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` / 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; From 7d1e1707be51f5b3ea1592f7d35daec1ab016269 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:00:10 -0400 Subject: [PATCH 06/14] perf: faster local build, skip xalia and gamescope realtime by default, mic off by default, show the saved Deck mode (#85) * perf: cache pinned downloads, skip unchanged proot rebuilds, and wait out a Docker Desktop restart in the local build * perf: skip xalia and gamescope realtime queues by default, add a realtime toggle, default the mic off * fix: show the saved Steam Deck mode on the Steam page before Performance has been opened --- .gitignore | 1 + .../com/droiddeck/launcher/MainActivity.kt | 12 ++- .../droiddeck/launcher/core/DeviceReport.kt | 1 + .../launcher/session/SessionPrefs.kt | 22 +++++- .../launcher/session/SessionService.kt | 7 +- .../launcher/ui/PerformanceDialog.kt | 9 ++- tools/build_local.sh | 73 +++++++++++++++---- .../overlay/usr/local/bin/bannerlator-session | 5 +- 8 files changed, 102 insertions(+), 28 deletions(-) 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/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e40c575c9..c35d2c20b 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -126,7 +126,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) @@ -503,8 +504,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) @@ -893,6 +895,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 }, @@ -900,6 +903,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 -> @@ -944,6 +948,7 @@ class MainActivity : ComponentActivity() { customResolution = SessionPrefs.customResolution(this, mode) fexPreset = SessionPrefs.fexPreset(this) steamChannel = SessionPrefs.steamChannel(this) + steamDeckMode = SessionPrefs.steamDeckMode(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) addedGamesDirs = SessionPrefs.addedGamesDirs(this) refreshAddedGames() @@ -993,6 +998,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() 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 80550be44..944b752a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -146,6 +146,7 @@ object DeviceReport { k("Steam Deck mode", SessionPrefs.steamDeckMode(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/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 820c6e401..07b2e3fba 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -143,8 +143,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) @@ -188,14 +188,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). * 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 913328d5b..13400e511 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -530,9 +530,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, 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 16f7a0e50..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, @@ -94,6 +96,11 @@ 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( @@ -103,7 +110,7 @@ fun PerformancePage( ) 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/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/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index de4c6918b..f46fbca1d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -77,8 +77,9 @@ fi # 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. 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. -export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1} +# 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 From f50d775912b3b964960ae3b4aee49d1734bb4d54 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 29 Sep 2026 21:52:34 -0400 Subject: [PATCH 07/14] Restore a game that minimized itself behind the Steam menu The Steam menu is an overlay that takes input without changing gamescope's focus window. A fullscreen wine game minimizes itself when it loses input, and gamescope only takes a window out of iconic when the focus window changes, so after Resume the game stayed minimized: a black screen with its caption in the top-left corner (Titanfall 2 on GE-Proton 11, proven on device). gamescope patch 0112 remembers the iconify request and puts the window back to NormalState before input returns to it, then hands focus over again so wine restores and activates it. The session script also asks gamescope to restore the game once input comes back from the client (GAMESCOPE_RESTORE_FOCUS_WINDOW); BL_RESTORE_ON_RESUME=0 turns that off. --- tools/gamescope/PATCHES.md | 8 ++ ...112-restore-iconified-game-on-resume.patch | 108 ++++++++++++++++++ .../overlay/usr/local/bin/bannerlator-session | 20 ++++ 3 files changed, 136 insertions(+) create mode 100644 tools/gamescope/patches/0112-restore-iconified-game-on-resume.patch diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index fdc158aa7..ad4fc4b34 100644 --- a/tools/gamescope/PATCHES.md +++ b/tools/gamescope/PATCHES.md @@ -30,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/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/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index f46fbca1d..b375de315 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -396,6 +396,26 @@ 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 From d1dcf2974c2f70390ae7ff0970ab266f15443815 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 29 Sep 2026 21:56:42 -0400 Subject: [PATCH 08/14] gamescope-3.16.29-p4: restore a game minimized behind the Steam menu (patch 0112) --- tools/gamescope/release.env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 8fbcf11fc84a85fb053a118c487587f6e84d99f9 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:57:52 -0400 Subject: [PATCH 09/14] feat: the pad as SteamOS has it - Steam Input's virtual pads for games, a Steam Deck controller for the client (#86) * feat: stand in for /dev/uinput so Steam Input's virtual pads reach games * feat: present the pad to the Steam client as a Steam Deck controller over hidraw * docs: log the uinput stand-in and the Steam Deck controller * feat: feed the handheld's gyro and accelerometer to the Steam Deck controller * feat: let the Steam page choose between the Steam Deck controller and the Xbox 360 pad * feat: Deck back grips and trackpads on the second screen * fix: serialize ring teardown with its writers, fall back to the Xbox pad when the Deck cannot be set up, and read motion only for a Deck * fix: keep the second screen's choice through sleep and bring it back on wake --- app/src/main/cpp/fakeinput_steam.cpp | 976 +++++++++++++++++- .../com/droiddeck/launcher/MainActivity.kt | 4 + .../com/droiddeck/launcher/SessionActivity.kt | 51 +- .../droiddeck/launcher/core/DeviceReport.kt | 1 + .../droiddeck/launcher/input/DeckControls.kt | 69 ++ .../launcher/input/FakeInputWriter.java | 131 ++- .../droiddeck/launcher/input/PadBridge.java | 48 +- .../com/droiddeck/launcher/input/PadMotion.kt | 103 ++ .../com/droiddeck/launcher/input/PadState.kt | 2 + .../launcher/input/SecondScreenMode.kt | 2 + .../launcher/session/SessionPrefs.kt | 11 + .../launcher/session/SessionService.kt | 47 +- .../launcher/session/SessionState.kt | 21 + .../launcher/session/SteamDeckPad.kt | 105 ++ .../launcher/ui/DeckControlsPanel.kt | 351 +++++++ .../launcher/ui/ModeSettingsDialog.kt | 13 + .../launcher/ui/SecondScreenPresentation.kt | 1 + .../droiddeck/launcher/ui/SessionOverlay.kt | 4 +- docs/progress-log.md | 41 + tools/linuxfs/preload/inputudev.c | 87 +- 20 files changed, 2003 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt 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/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index c35d2c20b..bd606a0d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -104,6 +104,7 @@ 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). @@ -808,6 +809,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(), @@ -864,6 +866,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 @@ -949,6 +952,7 @@ class MainActivity : ComponentActivity() { 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() diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 71f52be82..141c0a3c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -91,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 @@ -165,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 { @@ -223,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 { @@ -1274,11 +1292,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) } @@ -1300,8 +1321,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 @@ -1333,6 +1355,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) @@ -1352,7 +1376,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. */ @@ -1465,15 +1492,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() } @@ -1510,6 +1541,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) onScreenControls?.reload() updateOnScreenControls() + resumed = true + updatePadMotion() readPrefs() if (CompositorHost.isStarted) applyFrameGen() } @@ -1523,6 +1556,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() } @@ -1576,6 +1611,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 944b752a6..84c1d038b 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -144,6 +144,7 @@ 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)) 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/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 07b2e3fba..0b89121c7 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" @@ -267,6 +269,15 @@ 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() } 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 13400e511..3e4f76399 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 @@ -605,6 +609,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") @@ -612,14 +622,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") } @@ -630,6 +660,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 @@ -1145,6 +1176,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/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/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 00bb92839..37b4dac97 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -70,6 +70,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. */ @@ -126,6 +128,7 @@ class ModeSettingsActions( val onForceFullscreen: (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 = {}, @@ -259,6 +262,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/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/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/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]; } From 174321c0ec85b5370c12a406ea9ae0471d88a147 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 29 Sep 2026 23:01:21 -0400 Subject: [PATCH 10/14] Game saves: import and export save zips per game A floppy disk beside the Steam page's cog opens a Game saves page, laid out like Components: the Proton at the top (from the client's compatibility tools, with bannerlator-proton-arm64 shown as Proton Experimental), the zip type beside it (GameHub or Winlator, LB / RB turn it), and the games the client runs with that Proton (CompatToolMapping, its default for the rest), each with Import and Export. Import picks a zip in the app's file picker, backs up the game's steamuser profile to Download/DroidDeck/Saves/backups, then unzips drive_c with any user folder but Public renamed to steamuser, so GameHub and Winlator zips both land. Export finds the save folders by name under the usual roots (Bannerlator's SaveLocator), or takes the whole profile, and writes _.zip to a folder picked the same way, under steamuser (GameHub) or xuser (Winlator). Imports wait while a session runs. Ported from Bannerlator's GameSaveBackup and SaveLocator. --- .../com/droiddeck/launcher/MainActivity.kt | 117 +++++- .../droiddeck/launcher/session/GameSaves.kt | 335 ++++++++++++++++++ .../droiddeck/launcher/ui/ComponentsPage.kt | 2 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 4 +- .../droiddeck/launcher/ui/FrontEndWidgets.kt | 16 + .../droiddeck/launcher/ui/GameSavesPage.kt | 198 +++++++++++ .../com/droiddeck/launcher/ui/SteamHome.kt | 1 + 7 files changed, 665 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index bd606a0d9..a5da8b357 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -38,6 +38,8 @@ 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.ui.GameSavesPage +import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.ProtonPage @@ -111,6 +113,14 @@ class MainActivity : ComponentActivity() { private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) private var showMapping by mutableStateOf(false) + // Game saves page: import / export save zips per game (the floppy beside the Steam cog). + private var showSaves by mutableStateOf(false) + private var saveTools by mutableStateOf?>(null) + private var saveGames by mutableStateOf>(emptyList()) + private var saveTool by mutableStateOf(null) + private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB) + private var saveBusy by mutableStateOf(null) + private var pendingSaveGame: GameSaves.Game? = null private var controllerSettings by mutableStateOf(null) private var catalog by mutableStateOf?>(null) private var catalogLoading by mutableStateOf(false) @@ -151,6 +161,26 @@ 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 game = pendingSaveGame ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { zip -> + saveAction("Importing into ${game.game.name}") { + val (written, backup) = GameSaves.import(game, zip) + val kind = GameSaves.layoutOf(zip)?.label ?: "zip" + "Imported $written files from the $kind into ${game.game.name}" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "") + } + } + } + private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val game = pendingSaveGame ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { dir -> + val layout = saveLayout + saveAction("Exporting ${game.game.name}") { + val (zip, count) = GameSaves.export(game, layout, dir) + "Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}" + } + } + } private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path) @@ -275,6 +305,7 @@ class MainActivity : ComponentActivity() { showPerformance -> { { PerformanceHost() } } showProtons -> { { ProtonHost() } } showComponents -> { { ComponentsHost() } } + showSaves -> { { SavesHost() } } showMapping -> { { MappingHost() } } else -> null } @@ -289,7 +320,7 @@ class MainActivity : ComponentActivity() { steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), lsfgReady = LsfgNative.isInstalled(this), - pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, + pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showSaves) "game-saves" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, @@ -348,7 +379,8 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, + onGameSaves = { openSaves() }, + onPerformance = { refreshCores(); showProtons = false; showComponents = false; showSaves = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onBrowseFiles = { dir -> @@ -373,7 +405,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onCredits = { showCredits = true }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, + onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLauncherFullscreen = { on -> SessionPrefs.setLauncherFullscreen(this, on) @@ -452,7 +484,7 @@ class MainActivity : ComponentActivity() { onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, - onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, + onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -556,6 +588,14 @@ class MainActivity : ComponentActivity() { /** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // On the Game saves page LB / RB flip the zip type exports are written in. + if (showSaves && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + val all = GameSaves.Layout.entries + saveLayout = all[(saveLayout.ordinal + 1) % all.size] + } + return true + } if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { val all = ComponentsManager.COMPONENTS @@ -659,10 +699,74 @@ class MainActivity : ComponentActivity() { }, "catalog-desktop").start() } + private fun openSaves() { + settingsMode = null + showPerformance = false + showProtons = false + showComponents = false + showMapping = false + showSaves = true + refreshSaves() + } + + /** Protons, which game runs with which, and the saves found in each prefix: files only, off the main thread. */ + private fun refreshSaves() { + val all = steamGames + Thread({ + val tools = runCatching { GameSaves.tools(this) }.getOrDefault(emptyList()) + val games = runCatching { GameSaves.games(this, all) }.getOrDefault(emptyList()) + ui.post { + saveTools = tools + saveGames = games + // Open on the Proton the most launched games use, unless one was picked already. + if (saveTool == null || tools.none { it.name == saveTool }) saveTool = + tools.maxByOrNull { t -> games.count { it.tool == t.name && it.launched } }?.name + } + }, "game-saves").start() + } + + 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() + refreshSaves() + } + }, "game-saves-action").start() + } + + @Composable + private fun SavesHost() { + GameSavesPage( + tools = saveTools, + games = saveGames, + toolName = saveTool, + layout = saveLayout, + busy = saveBusy, + sessionRunning = SessionState.running, + onTool = { saveTool = it }, + onLayout = { saveLayout = it }, + onImport = { g -> + pendingSaveGame = g + pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for ${g.game.name}", + GameSaves.savesDir().parentFile?.parentFile?.path)) + }, + onExport = { g -> + pendingSaveGame = g + GameSaves.savesDir().mkdirs() + pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save ${g.game.name}", GameSaves.savesDir().path)) + }, + onBack = { showSaves = false }, + ) + } + private fun openProtons() { settingsMode = null showPerformance = false - showComponents = false + showComponents = false; showSaves = false showMapping = false showProtons = true protons.refreshProtons() @@ -689,6 +793,7 @@ class MainActivity : ComponentActivity() { showPerformance = false showProtons = false showMapping = false + showSaves = false showComponents = true components.refreshComponents(snapshotFirst = true) } @@ -944,7 +1049,7 @@ class MainActivity : ComponentActivity() { private fun openModeSettings(mode: String) { showPerformance = false showProtons = false - showComponents = false + showComponents = false; showSaves = false showMapping = false drivers.refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) 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..5c33d4275 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt @@ -0,0 +1,335 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Environment +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.runtime.LinuxRuntime +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), not to a Proton: the Proton a game is + * set to (the client's CompatToolMapping) only decides where the page lists it. 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 compatibility tool the client can run a game with, by the name it records per game. */ + data class Tool(val name: String, val label: String) + + /** A game as the page lists it: its Proton, and where its saves are. */ + data class Game(val game: Library.SteamGame, val tool: String?, 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) + + /** DroidDeck's launcher around Valve's ARM64 Proton Experimental (bannerlator-steam-compat). */ + private const val WRAPPER_TOOL = "bannerlator-proton-arm64" + private const val USER = "steamuser" + + private fun steam(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + + fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves") + private fun backupsDir(): File = File(savesDir(), "backups") + + // ------------------------------------------------------------------ Protons and games + + /** The compatibility tools installed for the client, each by its registered name and shown name. */ + fun tools(context: Context): List { + val out = LinkedHashMap() + File(steam(context), "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir -> + val vdf = File(dir, "compatibilitytool.vdf").takeIf { it.isFile } ?: return@forEach + val tools = runCatching { Vdf.parse(vdf.readText()) }.getOrNull() + ?.child("compatibilitytools")?.child("compat_tools") ?: return@forEach + for ((name, v) in tools.children) { + val shown = (v as? Vdf.Node)?.string("display_name") ?: name + out[name] = Tool(name, if (name == WRAPPER_TOOL) "Proton Experimental (ARM64)" else shown) + } + } + return out.values.toList() + } + + /** The client's per-game compatibility tool, by app id; "0" is its default for every other game. */ + fun mapping(context: Context): Map { + val config = File(steam(context), "config/config.vdf").takeIf { it.isFile } ?: return emptyMap() + val root = runCatching { Vdf.parse(config.readText()) }.getOrNull() ?: return emptyMap() + val map = root.find("CompatToolMapping") ?: return emptyMap() + val out = HashMap() + for ((id, v) in map.children) (v as? Vdf.Node)?.string("name")?.takeIf { it.isNotEmpty() }?.let { out[id] = it } + return out + } + + /** Every game with the tool it runs with (null = none set) and the saves found in its prefix. */ + fun games(context: Context, all: List): List { + val mapping = mapping(context) + val fallback = mapping["0"] + return all.map { g -> + val unsigned = (g.appId.toLong() and 0xffffffffL).toString() + val tool = mapping[unsigned] ?: mapping[g.appId.toString()] ?: mapping[g.gameId.toString()] ?: fallback + val prefix = g.protonPrefix + Game(g, tool, prefix, prefix?.let { locate(it, g) } ?: 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() + } + + // ------------------------------------------------------------------ Valve KeyValues + + /** The client's text KeyValues (VDF): quoted keys, quoted values or { nested blocks }. */ + internal object Vdf { + class Node(val children: LinkedHashMap = LinkedHashMap()) { + fun child(key: String): Node? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? Node + fun string(key: String): String? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? String + /** The first block named [key] anywhere below. */ + fun find(key: String): Node? { + child(key)?.let { return it } + for (v in children.values) (v as? Node)?.find(key)?.let { return it } + return null + } + } + + fun parse(text: String): Node { + var i = 0 + fun token(): String? { + while (i < text.length) { + val c = text[i] + when { + c.isWhitespace() -> i++ + c == '/' && i + 1 < text.length && text[i + 1] == '/' -> { while (i < text.length && text[i] != '\n') i++ } + c == '{' || c == '}' -> { i++; return c.toString() } + c == '"' -> { + val sb = StringBuilder(); i++ + while (i < text.length && text[i] != '"') { + if (text[i] == '\\' && i + 1 < text.length) { sb.append(text[i + 1]); i += 2 } else sb.append(text[i++]) + } + i++ + return "\"" + sb + } + else -> { val s = i; while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}') i++; return "\"" + text.substring(s, i) } + } + } + return null + } + fun block(into: Node) { + while (true) { + val k = token() ?: return + if (k == "}") return + if (k == "{") continue + val key = k.drop(1) + val v = token() ?: return + if (v == "{") { val n = Node(); block(n); into.children[key] = n } else into.children[key] = v.drop(1) + } + } + return Node().also { block(it) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 7822ef44e..eaca8c603 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -318,7 +318,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal * fill with an outline in the text colour - unmistakable in a column of identical buttons. */ @Composable -private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { +internal fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } 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..88d1f3b62 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -133,6 +133,8 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, + /** The Game saves page: import and export save zips per game (the floppy beside the Steam cog). */ + val onGameSaves: () -> Unit = {}, val onPerformance: () -> Unit, val onRoms: () -> Unit, val onFiles: () -> Unit, @@ -358,7 +360,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railSelection = when { s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true -> "steam" + s.pageKey?.startsWith("settings:steam") == true || s.pageKey == "game-saves" -> "steam" s.pageKey?.startsWith("settings:") == true -> "desktop" selected.startsWith("app:") -> "games" selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 7d034a138..ac4491c80 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.ui.graphics.vector.ImageVector @@ -235,3 +236,18 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } + +/** Game saves: a floppy disk beside the cog, 70% of its size, drawn the same way. */ +@Composable +internal fun SaveDisk(size: androidx.compose.ui.unit.Dp = 38.dp, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pal = LocalPalette.current + val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "saveEdge") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem("saves").downToFirstTile().size(size).clip(Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, Shape12) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), + ) { Icon(Icons.Outlined.Save, "Game saves", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(16.dp)) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt new file mode 100644 index 000000000..2cbac137f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt @@ -0,0 +1,198 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +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.mutableStateOf +import androidx.compose.runtime.remember +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.session.GameSaves + +/** + * Game saves, opened from the floppy disk beside the Steam page's cog. A page like Components: + * the Proton at the top, the save zip type beside it (LB / RB turn it), and the games set to that + * Proton, each with Import (pick a zip in the app's file picker) and Export (pick a folder there). + * A zip of either type imports; the type only decides how an export is written. + */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +fun GameSavesPage( + tools: List?, + games: List, + toolName: String?, + layout: GameSaves.Layout, + busy: String?, + sessionRunning: Boolean, + onTool: (String) -> Unit, + onLayout: (GameSaves.Layout) -> Unit, + onImport: (GameSaves.Game) -> Unit, + onExport: (GameSaves.Game) -> Unit, + onBack: () -> Unit, + requestInitialFocus: Boolean = true, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + BackHandler(onBack = onBack) + var toolMenu by remember { mutableStateOf(false) } + + val list = tools ?: emptyList() + val tool = list.firstOrNull { it.name == toolName } ?: list.firstOrNull() + val shown = games.filter { it.tool == tool?.name } + .sortedWith(compareByDescending { it.launched }.thenBy { it.game.name.lowercase() }) + val layouts = GameSaves.Layout.entries + + Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text( + "Game saves", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + maxLines = 1, modifier = Modifier.weight(1f), + ) + if (tools != null) Text( + if (shown.size == 1) "1 game on this Proton" else "${shown.size} games on this Proton", + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, + ) + } + + // ---- which Proton, which zip type ----------------------------------------------------------- + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { + if (tool != null) Box { + ValueChip(tool.label, toolMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { toolMenu = !toolMenu } + AnchoredMenu(toolMenu, onDismiss = { toolMenu = false }, title = "Proton", + note = "Saves belong to the game, not the Proton: switch a game to another Proton and its saves go with it.") { first -> + list.forEachIndexed { i, t -> + val n = games.count { it.tool == t.name } + MenuItem( + t.label, checked = t.name == tool.name, + detail = t.name + " · " + if (n == 1) "1 game" else if (n == 0) "no games yet" else "$n games", + focusRequester = if (i == 0) first else null, + ) { onTool(t.name); toolMenu = false } + } + } + } + TabStrip(layouts.map { it.label }, layouts.indexOf(layout), { onLayout(layouts[it]) }) + } + + // ---- what the type means, or what is happening ----------------------------------------------- + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) + .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), + ) { + if (busy != null) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) + else Box(Modifier.size(8.dp).background(if (sessionRunning) Color(0xFFFFB86B) else pal.good, CircleShape)) + Text( + when { + busy != null -> "$busy…" + sessionRunning -> "Steam is running: close the session before importing, or the game may save over it." + layout == GameSaves.Layout.GAMEHUB -> "Exports keep saves under steamuser, as GameHub does. Imports take either type and back up the saves already there first." + else -> "Exports keep saves under xuser, as Winlator does. Imports take either type and back up the saves already there first." + }, + fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + } + + // ---- the games ------------------------------------------------------------------------------ + when { + tools == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(12.dp)) + Text("Looking for games and their saves…", fontSize = 14.sp, color = colors.onSurfaceVariant) + } + tool == null -> Text( + "No Proton is installed in the Linux runtime yet. Start the Steam client once so it sets one up.", + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), + ) + else -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { + val first = remember { FocusRequester() } + LaunchedEffect(tool.name, requestInitialFocus) { if (requestInitialFocus) runCatching { first.requestFocus() } } + SettingsGroup("Games on this Proton · ${shown.size}") { + if (shown.isEmpty()) Text( + "No game is set to ${tool.label}. Pick it for a game in Steam (Properties › Compatibility) and launch the game once.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + ) + var firstSet = false + shown.forEach { g -> + val canImport = g.launched && busy == null && !sessionRunning + val canExport = g.launched && busy == null + val mod = if (!firstSet && canExport) { firstSet = true; Modifier.focusRequester(first) } else Modifier + GameSaveLine(g, canImport, canExport, mod, { onImport(g) }, { onExport(g) }) + } + } + } + } + } +} + +@Composable +private fun GameSaveLine( + g: GameSaves.Game, canImport: Boolean, canExport: Boolean, firstButton: Modifier, + onImport: () -> Unit, onExport: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + val detail = when { + !g.launched -> "Launch it once first: its save folder is made on the first launch" + g.saves.isEmpty() -> (if (g.game.library == "added") "Added game" else "Steam game") + " · no save folder found, Export takes the whole user folder" + else -> { + val files = g.saves.sumOf { it.files } + val mb = g.saves.sumOf { it.bytes } / 1048576.0 + (if (g.game.library == "added") "Added game" else "Steam game") + " · " + + (if (g.saves.size == 1) g.saves[0].relPath else "${g.saves.size} save folders") + + " · $files files · " + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb)) + } + } + val text: @Composable (Modifier) -> Unit = { m -> + Column(m) { + Text(g.game.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = 12.sp, color = if (g.launched) colors.onSurfaceVariant else Color(0xFFFFB86B), maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + val buttons: @Composable () -> Unit = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton("Import", enabled = canImport, modifier = if (canImport) firstButton else Modifier, onClick = onImport) + SmallButton("Export", enabled = canExport, modifier = if (!canImport) firstButton else Modifier, onClick = onExport) + } + } + if (narrow) Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + text(Modifier.fillMaxWidth()); buttons() + } else Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + ) { text(Modifier.weight(1f)); buttons() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 7be01984c..0cb88356e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -105,6 +105,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) // Enabled without a runtime: the session's loading screen installs it first. PrimaryButton("Play Steam", enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay) Cog(size = 54.dp, onClick = a.onSteamSettings) + SaveDisk(size = 38.dp, onClick = a.onGameSaves) } } } From f22b8253fda704d2bb720c22bf94643baf2c9d9b Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 29 Sep 2026 23:21:40 -0400 Subject: [PATCH 11/14] Game saves: Manage saves on each game's page instead of a separate page The floppy disk beside the Steam cog and its Game saves page are gone. A game's page now has Manage saves beside Proton prefix, a menu with Import saves (a GameHub or Winlator zip, picked in the app's file picker; the saves there now are backed up first), Export as GameHub zip, Export as Winlator zip (a folder picked the same way) and Open save folder. The game's saves are looked for when the menu opens. The Proton list and config.vdf reader only that page needed are removed. --- .../com/droiddeck/launcher/MainActivity.kt | 133 ++++-------- .../droiddeck/launcher/session/GameSaves.kt | 109 +--------- .../droiddeck/launcher/ui/ComponentsPage.kt | 2 +- .../droiddeck/launcher/ui/FrontEndGames.kt | 44 ++++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 7 +- .../droiddeck/launcher/ui/FrontEndWidgets.kt | 16 -- .../droiddeck/launcher/ui/GameSavesPage.kt | 198 ------------------ .../com/droiddeck/launcher/ui/SteamHome.kt | 1 - 8 files changed, 97 insertions(+), 413 deletions(-) delete mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index a5da8b357..004bf540d 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -38,7 +38,6 @@ 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.ui.GameSavesPage import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs @@ -113,14 +112,9 @@ class MainActivity : ComponentActivity() { private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) private var showMapping by mutableStateOf(false) - // Game saves page: import / export save zips per game (the floppy beside the Steam cog). - private var showSaves by mutableStateOf(false) - private var saveTools by mutableStateOf?>(null) - private var saveGames by mutableStateOf>(emptyList()) - private var saveTool by mutableStateOf(null) - private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB) - private var saveBusy by mutableStateOf(null) - private var pendingSaveGame: GameSaves.Game? = null + 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) @@ -162,24 +156,40 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> - val game = pendingSaveGame ?: return@registerForActivityResult - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { zip -> - saveAction("Importing into ${game.game.name}") { - val (written, backup) = GameSaves.import(game, zip) - val kind = GameSaves.layoutOf(zip)?.label ?: "zip" - "Imported $written files from the $kind into ${game.game.name}" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "") - } - } + 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 game = pendingSaveGame ?: return@registerForActivityResult - if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { dir -> - val layout = saveLayout - saveAction("Exporting ${game.game.name}") { - val (zip, count) = GameSaves.export(game, layout, dir) + 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 -> @@ -305,7 +315,6 @@ class MainActivity : ComponentActivity() { showPerformance -> { { PerformanceHost() } } showProtons -> { { ProtonHost() } } showComponents -> { { ComponentsHost() } } - showSaves -> { { SavesHost() } } showMapping -> { { MappingHost() } } else -> null } @@ -320,7 +329,7 @@ class MainActivity : ComponentActivity() { steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), lsfgReady = LsfgNative.isInstalled(this), - pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showSaves) "game-saves" else if (showMapping) "controller-mapping" else null, + pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, @@ -379,8 +388,10 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, - onGameSaves = { openSaves() }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showSaves = false; showMapping = false; showPerformance = true }, + // 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)) }, onBrowseFiles = { dir -> @@ -405,7 +416,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onCredits = { showCredits = true }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = false }, + onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLauncherFullscreen = { on -> SessionPrefs.setLauncherFullscreen(this, on) @@ -484,7 +495,7 @@ class MainActivity : ComponentActivity() { onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, - onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = true }, + onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -588,14 +599,6 @@ class MainActivity : ComponentActivity() { /** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { - // On the Game saves page LB / RB flip the zip type exports are written in. - if (showSaves && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - val all = GameSaves.Layout.entries - saveLayout = all[(saveLayout.ordinal + 1) % all.size] - } - return true - } if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { val all = ComponentsManager.COMPONENTS @@ -699,32 +702,7 @@ class MainActivity : ComponentActivity() { }, "catalog-desktop").start() } - private fun openSaves() { - settingsMode = null - showPerformance = false - showProtons = false - showComponents = false - showMapping = false - showSaves = true - refreshSaves() - } - - /** Protons, which game runs with which, and the saves found in each prefix: files only, off the main thread. */ - private fun refreshSaves() { - val all = steamGames - Thread({ - val tools = runCatching { GameSaves.tools(this) }.getOrDefault(emptyList()) - val games = runCatching { GameSaves.games(this, all) }.getOrDefault(emptyList()) - ui.post { - saveTools = tools - saveGames = games - // Open on the Proton the most launched games use, unless one was picked already. - if (saveTool == null || tools.none { it.name == saveTool }) saveTool = - tools.maxByOrNull { t -> games.count { it.tool == t.name && it.launched } }?.name - } - }, "game-saves").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 @@ -733,40 +711,14 @@ class MainActivity : ComponentActivity() { ui.post { saveBusy = null android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() - refreshSaves() } }, "game-saves-action").start() } - @Composable - private fun SavesHost() { - GameSavesPage( - tools = saveTools, - games = saveGames, - toolName = saveTool, - layout = saveLayout, - busy = saveBusy, - sessionRunning = SessionState.running, - onTool = { saveTool = it }, - onLayout = { saveLayout = it }, - onImport = { g -> - pendingSaveGame = g - pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for ${g.game.name}", - GameSaves.savesDir().parentFile?.parentFile?.path)) - }, - onExport = { g -> - pendingSaveGame = g - GameSaves.savesDir().mkdirs() - pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save ${g.game.name}", GameSaves.savesDir().path)) - }, - onBack = { showSaves = false }, - ) - } - private fun openProtons() { settingsMode = null showPerformance = false - showComponents = false; showSaves = false + showComponents = false showMapping = false showProtons = true protons.refreshProtons() @@ -793,7 +745,6 @@ class MainActivity : ComponentActivity() { showPerformance = false showProtons = false showMapping = false - showSaves = false showComponents = true components.refreshComponents(snapshotFirst = true) } @@ -1049,7 +1000,7 @@ class MainActivity : ComponentActivity() { private fun openModeSettings(mode: String) { showPerformance = false showProtons = false - showComponents = false; showSaves = false + showComponents = false showMapping = false drivers.refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt index 5c33d4275..e73668468 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt @@ -1,9 +1,7 @@ package com.droiddeck.launcher.session -import android.content.Context import android.os.Environment import com.droiddeck.launcher.frontend.Library -import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -23,9 +21,8 @@ import java.util.zip.ZipOutputStream * "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), not to a Proton: the Proton a game is - * set to (the client's CompatToolMapping) only decides where the page lists it. Ported from - * Bannerlator's GameSaveBackup and SaveLocator. + * 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) { @@ -33,64 +30,21 @@ object GameSaves { WINLATOR("Winlator zip", "drive_c/", "xuser"), } - /** A compatibility tool the client can run a game with, by the name it records per game. */ - data class Tool(val name: String, val label: String) - - /** A game as the page lists it: its Proton, and where its saves are. */ - data class Game(val game: Library.SteamGame, val tool: String?, val prefix: File?, val saves: List) { + /** 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) - /** DroidDeck's launcher around Valve's ARM64 Proton Experimental (bannerlator-steam-compat). */ - private const val WRAPPER_TOOL = "bannerlator-proton-arm64" private const val USER = "steamuser" - private fun steam(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") - fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves") private fun backupsDir(): File = File(savesDir(), "backups") - // ------------------------------------------------------------------ Protons and games - - /** The compatibility tools installed for the client, each by its registered name and shown name. */ - fun tools(context: Context): List { - val out = LinkedHashMap() - File(steam(context), "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir -> - val vdf = File(dir, "compatibilitytool.vdf").takeIf { it.isFile } ?: return@forEach - val tools = runCatching { Vdf.parse(vdf.readText()) }.getOrNull() - ?.child("compatibilitytools")?.child("compat_tools") ?: return@forEach - for ((name, v) in tools.children) { - val shown = (v as? Vdf.Node)?.string("display_name") ?: name - out[name] = Tool(name, if (name == WRAPPER_TOOL) "Proton Experimental (ARM64)" else shown) - } - } - return out.values.toList() - } - - /** The client's per-game compatibility tool, by app id; "0" is its default for every other game. */ - fun mapping(context: Context): Map { - val config = File(steam(context), "config/config.vdf").takeIf { it.isFile } ?: return emptyMap() - val root = runCatching { Vdf.parse(config.readText()) }.getOrNull() ?: return emptyMap() - val map = root.find("CompatToolMapping") ?: return emptyMap() - val out = HashMap() - for ((id, v) in map.children) (v as? Vdf.Node)?.string("name")?.takeIf { it.isNotEmpty() }?.let { out[id] = it } - return out - } - - /** Every game with the tool it runs with (null = none set) and the saves found in its prefix. */ - fun games(context: Context, all: List): List { - val mapping = mapping(context) - val fallback = mapping["0"] - return all.map { g -> - val unsigned = (g.appId.toLong() and 0xffffffffL).toString() - val tool = mapping[unsigned] ?: mapping[g.appId.toString()] ?: mapping[g.gameId.toString()] ?: fallback - val prefix = g.protonPrefix - Game(g, tool, prefix, prefix?.let { locate(it, g) } ?: emptyList()) - } - } + /** [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 @@ -281,55 +235,4 @@ object GameSaves { if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null return segs.drop(1).toMutableList() } - - // ------------------------------------------------------------------ Valve KeyValues - - /** The client's text KeyValues (VDF): quoted keys, quoted values or { nested blocks }. */ - internal object Vdf { - class Node(val children: LinkedHashMap = LinkedHashMap()) { - fun child(key: String): Node? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? Node - fun string(key: String): String? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? String - /** The first block named [key] anywhere below. */ - fun find(key: String): Node? { - child(key)?.let { return it } - for (v in children.values) (v as? Node)?.find(key)?.let { return it } - return null - } - } - - fun parse(text: String): Node { - var i = 0 - fun token(): String? { - while (i < text.length) { - val c = text[i] - when { - c.isWhitespace() -> i++ - c == '/' && i + 1 < text.length && text[i + 1] == '/' -> { while (i < text.length && text[i] != '\n') i++ } - c == '{' || c == '}' -> { i++; return c.toString() } - c == '"' -> { - val sb = StringBuilder(); i++ - while (i < text.length && text[i] != '"') { - if (text[i] == '\\' && i + 1 < text.length) { sb.append(text[i + 1]); i += 2 } else sb.append(text[i++]) - } - i++ - return "\"" + sb - } - else -> { val s = i; while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}') i++; return "\"" + text.substring(s, i) } - } - } - return null - } - fun block(into: Node) { - while (true) { - val k = token() ?: return - if (k == "}") return - if (k == "{") continue - val key = k.drop(1) - val v = token() ?: return - if (v == "{") { val n = Node(); block(n); into.children[key] = n } else into.children[key] = v.drop(1) - } - } - return Node().also { block(it) } - } - } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index eaca8c603..7822ef44e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -318,7 +318,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal * fill with an outline in the text colour - unmistakable in a column of identical buttons. */ @Composable -internal fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { +private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } 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..e75946f1a 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,55 @@ 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) } + 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) { 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 88d1f3b62..e701b1a9e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -133,8 +133,9 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, - /** The Game saves page: import and export save zips per game (the floppy beside the Steam cog). */ - val onGameSaves: () -> 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, @@ -360,7 +361,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railSelection = when { s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true || s.pageKey == "game-saves" -> "steam" + s.pageKey?.startsWith("settings:steam") == true -> "steam" s.pageKey?.startsWith("settings:") == true -> "desktop" selected.startsWith("app:") -> "games" selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index ac4491c80..7d034a138 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.ui.graphics.vector.ImageVector @@ -236,18 +235,3 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } - -/** Game saves: a floppy disk beside the cog, 70% of its size, drawn the same way. */ -@Composable -internal fun SaveDisk(size: androidx.compose.ui.unit.Dp = 38.dp, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "saveEdge") - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("saves").downToFirstTile().size(size).clip(Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, Shape12) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), - ) { Icon(Icons.Outlined.Save, "Game saves", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(16.dp)) } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt deleted file mode 100644 index 2cbac137f..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.droiddeck.launcher.ui - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator -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.mutableStateOf -import androidx.compose.runtime.remember -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.droiddeck.launcher.session.GameSaves - -/** - * Game saves, opened from the floppy disk beside the Steam page's cog. A page like Components: - * the Proton at the top, the save zip type beside it (LB / RB turn it), and the games set to that - * Proton, each with Import (pick a zip in the app's file picker) and Export (pick a folder there). - * A zip of either type imports; the type only decides how an export is written. - */ -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) -@Composable -fun GameSavesPage( - tools: List?, - games: List, - toolName: String?, - layout: GameSaves.Layout, - busy: String?, - sessionRunning: Boolean, - onTool: (String) -> Unit, - onLayout: (GameSaves.Layout) -> Unit, - onImport: (GameSaves.Game) -> Unit, - onExport: (GameSaves.Game) -> Unit, - onBack: () -> Unit, - requestInitialFocus: Boolean = true, -) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val narrow = LocalNarrowPane.current - BackHandler(onBack = onBack) - var toolMenu by remember { mutableStateOf(false) } - - val list = tools ?: emptyList() - val tool = list.firstOrNull { it.name == toolName } ?: list.firstOrNull() - val shown = games.filter { it.tool == tool?.name } - .sortedWith(compareByDescending { it.launched }.thenBy { it.game.name.lowercase() }) - val layouts = GameSaves.Layout.entries - - Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - Text( - "Game saves", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 1, modifier = Modifier.weight(1f), - ) - if (tools != null) Text( - if (shown.size == 1) "1 game on this Proton" else "${shown.size} games on this Proton", - fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, - ) - } - - // ---- which Proton, which zip type ----------------------------------------------------------- - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - ) { - if (tool != null) Box { - ValueChip(tool.label, toolMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { toolMenu = !toolMenu } - AnchoredMenu(toolMenu, onDismiss = { toolMenu = false }, title = "Proton", - note = "Saves belong to the game, not the Proton: switch a game to another Proton and its saves go with it.") { first -> - list.forEachIndexed { i, t -> - val n = games.count { it.tool == t.name } - MenuItem( - t.label, checked = t.name == tool.name, - detail = t.name + " · " + if (n == 1) "1 game" else if (n == 0) "no games yet" else "$n games", - focusRequester = if (i == 0) first else null, - ) { onTool(t.name); toolMenu = false } - } - } - } - TabStrip(layouts.map { it.label }, layouts.indexOf(layout), { onLayout(layouts[it]) }) - } - - // ---- what the type means, or what is happening ----------------------------------------------- - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) - .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), - ) { - if (busy != null) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) - else Box(Modifier.size(8.dp).background(if (sessionRunning) Color(0xFFFFB86B) else pal.good, CircleShape)) - Text( - when { - busy != null -> "$busy…" - sessionRunning -> "Steam is running: close the session before importing, or the game may save over it." - layout == GameSaves.Layout.GAMEHUB -> "Exports keep saves under steamuser, as GameHub does. Imports take either type and back up the saves already there first." - else -> "Exports keep saves under xuser, as Winlator does. Imports take either type and back up the saves already there first." - }, - fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - } - - // ---- the games ------------------------------------------------------------------------------ - when { - tools == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(12.dp)) - Text("Looking for games and their saves…", fontSize = 14.sp, color = colors.onSurfaceVariant) - } - tool == null -> Text( - "No Proton is installed in the Linux runtime yet. Start the Steam client once so it sets one up.", - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), - ) - else -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { - val first = remember { FocusRequester() } - LaunchedEffect(tool.name, requestInitialFocus) { if (requestInitialFocus) runCatching { first.requestFocus() } } - SettingsGroup("Games on this Proton · ${shown.size}") { - if (shown.isEmpty()) Text( - "No game is set to ${tool.label}. Pick it for a game in Steam (Properties › Compatibility) and launch the game once.", - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - ) - var firstSet = false - shown.forEach { g -> - val canImport = g.launched && busy == null && !sessionRunning - val canExport = g.launched && busy == null - val mod = if (!firstSet && canExport) { firstSet = true; Modifier.focusRequester(first) } else Modifier - GameSaveLine(g, canImport, canExport, mod, { onImport(g) }, { onExport(g) }) - } - } - } - } - } -} - -@Composable -private fun GameSaveLine( - g: GameSaves.Game, canImport: Boolean, canExport: Boolean, firstButton: Modifier, - onImport: () -> Unit, onExport: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - val narrow = LocalNarrowPane.current - val detail = when { - !g.launched -> "Launch it once first: its save folder is made on the first launch" - g.saves.isEmpty() -> (if (g.game.library == "added") "Added game" else "Steam game") + " · no save folder found, Export takes the whole user folder" - else -> { - val files = g.saves.sumOf { it.files } - val mb = g.saves.sumOf { it.bytes } / 1048576.0 - (if (g.game.library == "added") "Added game" else "Steam game") + " · " + - (if (g.saves.size == 1) g.saves[0].relPath else "${g.saves.size} save folders") + - " · $files files · " + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb)) - } - } - val text: @Composable (Modifier) -> Unit = { m -> - Column(m) { - Text(g.game.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = 12.sp, color = if (g.launched) colors.onSurfaceVariant else Color(0xFFFFB86B), maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - val buttons: @Composable () -> Unit = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SmallButton("Import", enabled = canImport, modifier = if (canImport) firstButton else Modifier, onClick = onImport) - SmallButton("Export", enabled = canExport, modifier = if (!canImport) firstButton else Modifier, onClick = onExport) - } - } - if (narrow) Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - text(Modifier.fillMaxWidth()); buttons() - } else Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), - ) { text(Modifier.weight(1f)); buttons() } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 0cb88356e..7be01984c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -105,7 +105,6 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) // Enabled without a runtime: the session's loading screen installs it first. PrimaryButton("Play Steam", enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay) Cog(size = 54.dp, onClick = a.onSteamSettings) - SaveDisk(size = 38.dp, onClick = a.onGameSaves) } } } From 2805c94f9f41d1aa09f94855d878acad4280d0d1 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 29 Sep 2026 23:36:49 -0400 Subject: [PATCH 12/14] Game saves: show Manage saves only on added games --- app/src/main/java/com/droiddeck/launcher/MainActivity.kt | 2 +- app/src/main/java/com/droiddeck/launcher/frontend/Library.kt | 3 +++ app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 004bf540d..0fad67845 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1084,7 +1084,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/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 29191c37e..82348f177 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). */ 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 e75946f1a..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,7 +120,8 @@ 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) } - ManageSaves(g, dir, a) + // 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) } @@ -278,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 } From 970fc3a94697a2dbb3ae25961e4505985d4ee566 Mon Sep 17 00:00:00 2001 From: The412Banner <205237651+The412Banner@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:11:55 -0400 Subject: [PATCH 13/14] ci: say what each DroidDeck-CI build is at the top of its release notes (#92) --- .github/workflows/build.yml | 33 +++++++++++++++++++++++- .github/workflows/pr-release-publish.yml | 25 +++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) 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 From 4a234b2824659d233bdb401f27980a7bcbc95d8a Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:35:49 -0400 Subject: [PATCH 14/14] feat: Flatpak apps and a Flathub store (#77) * feat: run Flatpak apps through a proot bwrap stand-in and add a Flathub store * feat: give Flatpak apps the KGSL GPU, the controllers and a CPU LLVM can target * feat: Flatpak games on the desktop, Store apps on the Desktop page, safer remove * test: cover the sandbox planner and store helpers; docs: explain Flatpak on DroidDeck * fix: keep controller focus through Store tabs and app pages; show progress per Flatpak step * fix: let Chromium, Electron and Firefox Flatpaks start without their own sandboxes * fix: fetch Flatpak's supporting libraries when the runtime lacks them * fix: add Flathub without a download, log store commands, keep Electron apps on X11 under gamescope * feat: import ARM64 AppImages from storage and run them from the Desktop page * fix: report failed Flatpak installs as failures and deploy runtimes that ship a resolv.conf link * fix: let Flatpak create its repo where Android denies hard links * fix: point store commands at the current network's DNS * fix: keep failed Flatpak extensions non-fatal, give Electron AppImages a bus, X11 and time to relaunch * docs: AppImage import and what x86 apps would take * feat: put the Flathub Store and AppImage import behind beta toggles in Setup --------- Co-authored-by: MaxsTechReview --- README.md | 2 +- .../main/java/com/droiddeck/launcher/App.kt | 7 +- .../com/droiddeck/launcher/MainActivity.kt | 33 +- .../com/droiddeck/launcher/SessionActivity.kt | 7 +- .../droiddeck/launcher/core/FileUtils.java | 1 + .../droiddeck/launcher/core/HostProcess.kt | 3 +- .../droiddeck/launcher/frontend/Library.kt | 8 +- .../launcher/runtime/AppImageManager.kt | 181 ++++++ .../launcher/runtime/BwrapSpawner.kt | 424 +++++++++++++ .../launcher/runtime/FlatpakManager.kt | 204 +++++++ .../launcher/runtime/GuestCommand.kt | 69 +++ .../launcher/runtime/LinuxRuntime.java | 58 +- .../launcher/session/OrphanReaper.kt | 32 +- .../launcher/session/SessionFiles.kt | 7 + .../launcher/session/SessionPrefs.kt | 14 + .../droiddeck/launcher/store/AppImageState.kt | 63 ++ .../droiddeck/launcher/store/FlathubApi.kt | 158 +++++ .../droiddeck/launcher/store/StoreState.kt | 142 +++++ .../droiddeck/launcher/ui/FrontEndContent.kt | 7 + .../com/droiddeck/launcher/ui/FrontEndRail.kt | 2 + .../droiddeck/launcher/ui/FrontEndScreen.kt | 12 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 14 + .../com/droiddeck/launcher/ui/StorePage.kt | 556 ++++++++++++++++++ .../launcher/ui/WirelessAdbFixPage.kt | 2 +- .../launcher/runtime/AppImageManagerTest.kt | 51 ++ .../launcher/runtime/BwrapSpawnerTest.kt | 127 ++++ .../launcher/store/FlathubApiTest.kt | 20 + docs/development/flatpak.md | 118 ++++ tools/linuxfs/desktop/droiddeck-desktop-gpu | 23 +- .../usr/local/bin/bannerlator-appimage-run | 63 ++ .../overlay/usr/local/bin/bannerlator-bwrap | 318 ++++++++++ .../overlay/usr/local/bin/bannerlator-flatpak | 237 ++++++++ .../usr/local/bin/bannerlator-flatpak-run | 28 + .../usr/local/bin/bannerlator-flatpak-setup | 121 ++++ .../overlay/usr/local/bin/bannerlator-session | 4 + tools/proot/PATCHES.md | 10 + .../patches/0010-new-mount-api-enosys.patch | 105 ++++ .../0012-android-hardlink-denial.patch | 82 +++ 38 files changed, 3293 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/store/AppImageState.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/store/FlathubApi.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/store/StoreState.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/store/FlathubApiTest.kt create mode 100644 docs/development/flatpak.md create mode 100755 tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run create mode 100644 tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup create mode 100644 tools/proot/patches/0010-new-mount-api-enosys.patch create mode 100644 tools/proot/patches/0012-android-hardlink-denial.patch 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/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 0fad67845..7686e699e 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -142,6 +142,8 @@ class MainActivity : ComponentActivity() { private var clientDirectAudio by mutableStateOf(false) private var forceFullscreen by mutableStateOf(true) 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 @@ -214,6 +216,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) @@ -306,6 +311,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) { @@ -349,6 +356,8 @@ class MainActivity : ComponentActivity() { phantomProcessStatus = phantomProcessStatus, showPhantomGate = showPhantomGate, launcherFullscreen = launcherFullscreen, + storeEnabled = storeEnabled, + appImagesEnabled = appImagesEnabled, ), FrontEndActions( onPlay = { startSteamSession() }, @@ -368,6 +377,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) @@ -423,6 +451,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) @@ -1076,7 +1106,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. diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 141c0a3c4..2023616d3 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -949,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() 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 82348f177..80b8091be 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -166,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/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/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/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 0b89121c7..0b52c9830 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -30,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) { 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/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/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 e701b1a9e..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, @@ -147,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 = { _, _ -> }, @@ -353,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/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/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/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/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 b375de315..d579dc73b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -92,6 +92,10 @@ 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 diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index f2dc70854..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 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 },