From be86fd953a78833bd8a801313caad06fe6c64e19 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 07:53:12 -0400 Subject: [PATCH 01/28] fix: report Adreno 840v2 as the A840 the runtime's Turnip knows --- tools/linuxfs/preload/kgslid.c | 58 ++++++++++++++++++++++++++++++++++ tools/linuxfs/preload/netif.c | 3 ++ 2 files changed, 61 insertions(+) create mode 100644 tools/linuxfs/preload/kgslid.c diff --git a/tools/linuxfs/preload/kgslid.c b/tools/linuxfs/preload/kgslid.c new file mode 100644 index 000000000..3dee629d1 --- /dev/null +++ b/tools/linuxfs/preload/kgslid.c @@ -0,0 +1,58 @@ +/* + * Adreno revisions the runtime's Turnip does not know, reported as the revision it does. + * + * Retail SM8850 phones report their A840 as chip_id 0x44050A21 (KGSL gpu_model "Adreno840v2"). + * Mesa only lists 0x44050A31, so the runtime's glibc Turnip refuses the device outright + * ("device (chip_id = 44050A21, gpu_id = 6860) is unsupported") and gamescope exits before Steam + * starts. Banners-Turnip fixes its own builds by registering the v2 id on the same A840 GPU info + * (patches/a840v2.py); answering KGSL's device-info query with the known id has the same effect + * on any Turnip in the session, including the one the runtime was built with. A driver that + * already knows the v2 id resolves the rewritten one to the same entry. + * + * Only the KGSL device-info property is touched. Its layout is the kernel's uapi (msm_kgsl.h). + */ +#define _GNU_SOURCE 1 +#include +#include + +struct kgsl_devinfo { + unsigned int device_id; + unsigned int chip_id; + unsigned int mmu_enabled; + unsigned long gmem_gpubaseaddr; + unsigned int gpu_id; + size_t gmem_sizebytes; +}; + +struct kgsl_device_getproperty { + unsigned int type; + void *value; + size_t sizebytes; +}; + +#define KGSL_IOC_TYPE 0x09 +#define IOCTL_KGSL_DEVICE_GETPROPERTY _IOWR(KGSL_IOC_TYPE, 0x2, struct kgsl_device_getproperty) +#define KGSL_PROP_DEVICE_INFO 0x1 + +static const struct { + unsigned int reported; + unsigned int known; +} CHIP_IDS[] = { + {0x44050A21, 0x44050A31}, /* Adreno 840v2 -> Adreno 840 */ +}; + +void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc) { + if (rc != 0 || request != IOCTL_KGSL_DEVICE_GETPROPERTY || arg == NULL) return; + struct kgsl_device_getproperty *prop = arg; + if (prop->type != KGSL_PROP_DEVICE_INFO || prop->value == NULL + || prop->sizebytes < sizeof(struct kgsl_devinfo)) { + return; + } + struct kgsl_devinfo *info = prop->value; + for (size_t i = 0; i < sizeof(CHIP_IDS) / sizeof(CHIP_IDS[0]); i++) { + if (info->chip_id == CHIP_IDS[i].reported) { + info->chip_id = CHIP_IDS[i].known; + return; + } + } +} diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 4b79c3f3f..f4de289d9 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -349,6 +349,8 @@ static int is_link_query(unsigned long request) { || request == SIOCGIFBRDADDR; } +void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc); /* kgslid.c */ + /* Every DRM and evdev call comes through here, so the lookup is done once. Two threads racing * to store it store the same pointer. */ int ioctl(int fd, unsigned long request, ...) { @@ -361,6 +363,7 @@ int ioctl(int fd, unsigned long request, ...) { if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); int rc = real(fd, request, arg); + bl_kgsl_chip_id_fixup(request, arg, rc); if (arg == NULL || !is_link_query(request)) return rc; int saved = errno; if (answer_ifreq(request, (struct ifreq *) arg, rc)) return 0; From a81363d743b341a5dcf0d374df20db07b8deef67 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:06:12 -0400 Subject: [PATCH 02/28] feat: turn Deck mode on for new installs and add a performance overlay toggle (#101) * feat: turn Deck mode on for new installs and add a performance overlay toggle * feat: hide the session fps HUD where Deck mode's performance overlay runs --- .../main/java/com/droiddeck/launcher/App.kt | 2 + .../com/droiddeck/launcher/MainActivity.kt | 4 ++ .../launcher/session/SessionPrefs.kt | 39 +++++++++++++++++-- .../launcher/session/SessionService.kt | 1 + .../launcher/ui/ModeSettingsDialog.kt | 8 ++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 8caeae182..19e13cc88 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -12,6 +12,8 @@ class App : Application() { override fun onCreate() { super.onCreate() CrashHandler.install(this) + // Before anything else touches the session prefs: a new install's first read decides it. + com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(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 5026b97c4..ea1bad720 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -105,6 +105,7 @@ class MainActivity : ComponentActivity() { private var glThread by mutableStateOf(true) private var noGlError by mutableStateOf(true) private var steamDeckMode by mutableStateOf(false) + private var mangoapp by mutableStateOf(true) private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var showProtons by mutableStateOf(false) // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). @@ -899,6 +900,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, + mangoapp = mangoapp, steamController = if (mode == SessionService.MODE_STEAM) steamController else null, runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup, addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, @@ -956,6 +958,7 @@ class MainActivity : ComponentActivity() { steamDeckMode = on steamChannel = SessionPrefs.steamChannel(this) }, + onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on }, onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id }, onRunSteamAtStartup = { on -> SessionPrefs.setRunSteamAtStartup(this, on) @@ -1042,6 +1045,7 @@ class MainActivity : ComponentActivity() { fexPreset = SessionPrefs.fexPreset(this) steamChannel = SessionPrefs.steamChannel(this) steamDeckMode = SessionPrefs.steamDeckMode(this) + mangoapp = SessionPrefs.mangoapp(this) steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) addedGamesDirs = SessionPrefs.addedGamesDirs(this) 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 ae382bd5b..4a0312cad 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -44,12 +44,23 @@ object SessionPrefs { prefs(context).edit().putBoolean("appImagesEnabled", on).apply() } - fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true) + /** + * The session's performance HUD (the fps box). A Deck-mode Steam session with the performance + * overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on + * in one - a choice kept apart from every other session's, where it stays on by default. + */ + fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context)) fun setHudEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("hud", on).apply() + prefs(context).edit().putBoolean(hudKey(context), on).apply() } + private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud" + + /** A Steam session that runs Deck mode with its performance overlay (mangoapp). */ + private fun mangoappSession(context: Context) = + SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context) + /** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */ fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false) @@ -292,10 +303,30 @@ object SessionPrefs { 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) + /** Runs the SteamOS gamepad client with its Quick Access performance controls. On for new installs (settleDeckModeDefault). */ + fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true) fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() } + /** + * Deck mode became the default for new installs; an install from before keeps what it ran with + * (off), so an update never changes its interface or restarts the client on its own. Run once at + * process start, before anything reads or writes these prefs: a new install has neither prefs + * nor a runtime yet. The answer is written down, so it is decided once. + */ + fun settleDeckModeDefault(context: Context) { + val p = prefs(context) + if (p.contains("steamDeckMode")) return + val existing = p.all.isNotEmpty() || java.io.File(context.filesDir, "linuxfs").exists() + p.edit().putBoolean("steamDeckMode", !existing).apply() + } + + /** + * Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws + * through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did). + */ + fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true) + fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() } + /** * 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. 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 3e4f76399..27dc9ac5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -528,6 +528,7 @@ class SessionService : Service() { if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) + if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) if (SessionState.mode == MODE_STEAM) { guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0")) 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 62ae9ea1d..fc41cd81e 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, Deck mode: the QAM's performance overlay (mangoapp) is started. */ + val mangoapp: Boolean = true, /** 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. */ @@ -128,6 +130,7 @@ class ModeSettingsActions( val onForceFullscreen: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, val onSteamDeckMode: (Boolean) -> Unit = {}, + val onMangoapp: (Boolean) -> Unit = {}, val onSteamController: (String) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, val onPickAddedGamesDir: () -> Unit = {}, @@ -359,6 +362,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { "Enables Steam's Deck interface and Quick Access performance overlay controls. Applies next session.", s.steamDeckMode, onChange = a.onSteamDeckMode, ) + if (s.steamDeckMode) ToggleRow( + host, "mangoapp", "Performance overlay", + "The Quick Access Menu's frame-rate and usage overlay. Turn off if games crash or go black with Deck mode on. Applies next session.", + s.mangoapp, onChange = a.onMangoapp, + ) // Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off. if (s.steamDeckMode) SettingsRow("Client branch", "Steam Deck public beta: Deck mode needs it, so Steam doesn't keep offering an update") {} else ChoiceRow( From 98f5da262bec58ae3e4e3f852bb874d4abb6145d Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 13:36:14 -0400 Subject: [PATCH 03/28] fix: give games the Xbox 360 axis layout when Steam Input is off --- app/src/main/cpp/fakeinput_steam.cpp | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 41d6cfe75..0b12b3249 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -649,31 +649,43 @@ open_fake_input_ring(const char *event, int flags) { // does not exist here, so the game was left with no controller at all. With // FAKE_EVDEV_STEAM_VIRTUAL set the pad carries the virtual gamepad's identity for every process // but the client's own, which must keep seeing the pad as the physical one it reads. -__attribute__((visibility("hidden"))) static bool presents_steam_virtual() { +// Whether this process is the Steam client itself. Asked once: a process never becomes another. +__attribute__((visibility("hidden"))) static bool is_steam_client() { static int known; if (known == 0) { - const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL"); char exe[PATH_MAX]; - ssize_t length = enabled && atoi(enabled) ? readlink("/proc/self/exe", exe, sizeof(exe) - 1) : -1; + ssize_t length = readlink("/proc/self/exe", exe, sizeof(exe) - 1); bool client = false; if (length > 0) { exe[length] = '\0'; const char *name = strrchr(exe, '/'); client = !strncmp(name ? name + 1 : exe, "steam", 5); } - known = length > 0 && !client ? 1 : -1; + known = client ? 1 : -1; } return known == 1; } -// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine places a -// gamepad's axes by their position among the ones it advertises, so the triggers have to sit +__attribute__((visibility("hidden"))) static bool presents_steam_virtual() { + static int known; + if (known == 0) { + const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL"); + known = enabled && atoi(enabled) && !is_steam_client() ? 1 : -1; + } + return known == 1; +} + +// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine and SDL place +// a gamepad's axes by their position among the ones it advertises, so the triggers have to sit // where that pad has them: as ABS_GAS and ABS_BRAKE they follow the sticks, the right stick -// lands on the left trigger, and a released right trigger reads as the right stick held up. -// A pad made through /dev/uinput is that pad for real, whoever reads it. +// lands on the left trigger, and a released right trigger reads as the right stick held up. That +// is every reader but the Steam client, which maps the pad from what it finds (controller.txt) +// and has always been shown ABS_GAS/ABS_BRAKE: a game Steam Input is off for reads this pad +// itself (the Xbox 360 setting), and so do the desktop's programs. A pad made through +// /dev/uinput is that pad for real, whoever reads it. __attribute__((visibility("hidden"))) static bool presents_xbox_triggers(const FakeController &fake) { - return fake.uinput || presents_steam_virtual(); + return fake.uinput || !is_steam_client(); } __attribute__((visibility("hidden"))) static uint16_t From a303e5c8a674dca57221ec422ee3cc319d0cd2bf Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:54:35 -0400 Subject: [PATCH 04/28] docs: add a join-the-Discord badge and community section to the README (#104) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a71461045..dd0abc25f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton. +

Join the DroidDeck Discord

+

Tapping DroidDeck on the Android home screen and landing in Steam Big Picture

## Requirements and install @@ -15,6 +17,10 @@ Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options. +## Community + +Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, nightly builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it. + ## Build Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`. From aac7e15f2b83b9589b1dc238315d25d1bc879e95 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:26:39 -0400 Subject: [PATCH 05/28] Feat/step out stop confirm (#105) --- .../com/droiddeck/launcher/MainActivity.kt | 3 + .../com/droiddeck/launcher/SessionActivity.kt | 3 + .../droiddeck/launcher/ui/ComponentsPage.kt | 8 +- .../com/droiddeck/launcher/ui/DriverPage.kt | 8 +- .../com/droiddeck/launcher/ui/FocusGlide.kt | 253 +++++++++++++++ .../com/droiddeck/launcher/ui/FrontEndArt.kt | 3 +- .../droiddeck/launcher/ui/FrontEndContent.kt | 2 +- .../droiddeck/launcher/ui/FrontEndGames.kt | 2 +- .../com/droiddeck/launcher/ui/FrontEndRail.kt | 5 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 5 +- .../droiddeck/launcher/ui/FrontEndWidgets.kt | 10 +- .../com/droiddeck/launcher/ui/PcKeyboard.kt | 5 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 307 +++++++++++++++--- .../droiddeck/launcher/ui/SettingsWidgets.kt | 8 +- .../com/droiddeck/launcher/ui/StorePage.kt | 8 +- .../com/droiddeck/launcher/ui/UpdatesPage.kt | 7 +- .../launcher/ui/WirelessAdbFixPage.kt | 2 +- 17 files changed, 551 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ea1bad720..ae962bb76 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.Manifest import android.app.ActivityOptions import android.content.Intent @@ -317,6 +318,7 @@ class MainActivity : ComponentActivity() { updates.start() setContent { DroidDeckTheme(theme) { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { val sm = settingsMode val page: (@Composable () -> Unit)? = when { sm != null -> { { ModeSettingsHost(sm) } } @@ -566,6 +568,7 @@ class MainActivity : ComponentActivity() { } } } + } if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 2023616d3..1c58332a7 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.hardware.input.InputManager import android.hardware.display.DisplayManager import android.content.Intent @@ -315,6 +316,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isFocusableInTouchMode = true setContent { DroidDeckTheme { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) @@ -445,6 +447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } } + } } private fun refreshHomeApp() { 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 d0b4a4fa6..566f66b4e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -304,7 +304,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp)) + .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick), ) { @@ -327,7 +327,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) - .border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), @@ -369,7 +369,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE) + .glideBorder(hot, ROW_SHAPE, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) .controllerConfirm(onClick = onSelect) .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), @@ -398,7 +398,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE) + .glideBorder(delHot, ROW_SHAPE, colors.error) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index df76a1e81..6a8809088 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -92,7 +92,7 @@ fun DriverPage( contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp)) + .glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line) .focusRequester(refreshFocus) .hoverable(refreshSrc) .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), @@ -173,7 +173,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape), + .glideBorder(hot, shape, pal.signal), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -209,7 +209,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp)) + .glideBorder(delHot, RoundedCornerShape(10.dp), colors.error) .focusRequester(delFocus) .focusProperties { left = mainFocus } .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete), @@ -229,7 +229,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color, modifier = modifier.clip(shape) .background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (hot) color else Color.Transparent, shape) + .glideBorder(hot, shape, color) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .padding(horizontal = 12.dp, vertical = 8.dp), ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt new file mode 100644 index 000000000..6779032e1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -0,0 +1,253 @@ +package com.droiddeck.launcher.ui + +import android.view.View +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.abs + +// One focus ring for a whole screen that slides from control to control. When focus moves, the +// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so +// the ring stretches like a drop and snaps into the new control's shape. + +/** A control that can show the ring: where it is and what its ring looks like there. */ +internal class GlideSource { + var coords: LayoutCoordinates? = null + var shape: Shape by mutableStateOf(androidx.compose.ui.graphics.RectangleShape) + var color by mutableStateOf(Color.Transparent) + var width by mutableStateOf(2.dp) +} + +internal class FocusGlide(val view: View) { + var host: LayoutCoordinates? = null + /** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */ + val hot = mutableStateListOf() + /** Bumped when the current control moves (a scroll, a relayout). */ + var moves by mutableIntStateOf(0) + + val current: GlideSource? get() = hot.lastOrNull() + + fun claim(s: GlideSource) { hot.remove(s); hot.add(s) } + fun release(s: GlideSource) { hot.remove(s) } + fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ } + + /** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */ + fun boundsOf(s: GlideSource): Rect? { + val h = host ?: return null + val c = s.coords ?: return null + if (!h.isAttached || !c.isAttached) return null + val b = h.localBoundingBoxOf(c, clipBounds = true) + return if (b.width < 1f || b.height < 1f) null else b + } +} + +internal val LocalFocusGlide = staticCompositionLocalOf { null } + +private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float = + when (val o = shape.createOutline(size, dir, density)) { + is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x + is Outline.Rectangle -> 0f + is Outline.Generic -> minOf(size.width, size.height) / 2f + } + +/** + * A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot]. + * Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a + * popup, a screen with no host) it is drawn here, as a plain border. + */ +@Composable +internal fun Modifier.glideBorder( + hot: Boolean, + shape: Shape, + hotColor: Color, + restColor: Color = Color.Transparent, + hotWidth: Dp = 2.dp, + restWidth: Dp = 1.dp, +): Modifier { + val glide = LocalFocusGlide.current + if (glide == null || glide.view !== LocalView.current) { + return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape) + } + val src = remember { GlideSource() } + src.shape = shape + src.color = hotColor + src.width = hotWidth + DisposableEffect(glide, hot) { + if (hot) glide.claim(src) + onDispose { glide.release(src) } + } + val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this + return base.onGloballyPositioned { src.coords = it; glide.moved(src) } +} + +/** Leading edge on a focus move: quick and a little loose. */ +private fun lead(): AnimationSpec = Motion.sp(0.62f, 700f) +/** Trailing edge: slower, settles without a bounce to speak of. */ +private fun trail(): AnimationSpec = Motion.sp(0.78f, 360f) +/** Following a control that moves under the ring (a scroll): tight, no wobble. */ +private fun follow(): AnimationSpec = Motion.sp(1f, 2400f) + +/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */ +private const val SETTLE_MS = 450L + +/** + * Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the + * host's space, so put the host inside anything that moves as a whole (a sheet that slides in). + */ +@Composable +internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val view = LocalView.current + val density = LocalDensity.current + val dir = LocalLayoutDirection.current + val glide = remember(view) { FocusGlide(view) } + val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom + val corner = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val jobs = remember { arrayOfNulls(5) } + val cur = glide.current + var lastColor by remember { mutableStateOf(Color.Transparent) } + if (cur != null) lastColor = cur.color + val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor") + var width by remember { mutableStateOf(2.dp) } + if (cur != null) width = cur.width + + LaunchedEffect(glide) { + var shown: GlideSource? = null + var movedAt = 0L + var dirSpecs: List> = List(4) { follow() } + var delays = LongArray(4) + fun go(i: Int, v: Float, spec: AnimationSpec, wait: Long) { + jobs[i]?.cancel() + jobs[i] = scope.launch { + if (wait > 0) delay(Motion.ms(wait.toInt()).toLong()) + edges[i].animateTo(v, spec) + } + } + fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) { + val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size + val r = cornerOf(s.shape, size, dir, density) + jobs[4]?.cancel() + jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) } + } + snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) -> + if (src == null) { + // Focus passing between two controls can let go of one a moment before the next + // takes it: wait a beat, so that reads as a move and not a fade out and back in. + delay(60) + scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + shown = null + return@collectLatest + } + var first = src !== shown + val start = System.nanoTime() + var last: Rect? = null + while (true) { + val b = glide.boundsOf(src) + if (b == null) { + if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + } else if (first) { + val prev = shown + val wasShowing = prev != null && alpha.targetValue > 0f + shown = src + first = false + if (!wasShowing) { + // Appearing: a hair bigger, pulled in onto the control as it fades up. + val out = with(density) { 3.dp.toPx() } + val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out) + jobs.forEach { it?.cancel() } + scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join() + dirSpecs = List(4) { Motion.sp(0.6f, 700f) } + delays = LongArray(4) + cornerTo(src, b, snap = true) + scope.launch { alpha.animateTo(1f, Motion.tw(140)) } + } else { + // Moving: the edges on the side it is heading lead. + val dx = b.center.x - (edges[0].value + edges[2].value) / 2f + val dy = b.center.y - (edges[1].value + edges[3].value) / 2f + val horizontal = abs(dx) >= abs(dy) + val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1) + val trailIdx = (leadIdx + 2) % 4 + dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } } + delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L } + cornerTo(src, b, snap = false) + scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + } + movedAt = System.nanoTime() + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) } + last = b + } else if (b != last) { + // It moved under the ring: keep the stretch while the move is fresh, else follow. + val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) } + if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + last = b + } + // Layer animations (a tile growing, a row sliding) move it without a relayout, so + // look again each frame for a moment after anything changes. + if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break + withFrameNanos { } + } + } + } + + Box( + modifier + .onGloballyPositioned { glide.host = it } + .drawWithContent { + drawContent() + val a = alpha.value + if (a <= 0.01f) return@drawWithContent + val w = width.toPx() + val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value + val sw = (r - l - w).coerceAtLeast(0f) + val sh = (b - t - w).coerceAtLeast(0f) + val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f) + drawRoundRect( + color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), + cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a, + ) + }, + ) { + CompositionLocalProvider(LocalFocusGlide provides glide) { content() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index a49df3698..64e1bfc60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift") val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev") val pal = LocalPalette.current - val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing") Column( modifier = track .graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) } .clip(Shape12) .background(colors.surface) - .border(2.5.dp, ring, Shape12) + .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) .alpha(if (t.dim && !hot) 0.55f else 1f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), ) { 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 400c4b4b3..ccfe357ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { 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 3c279aa87..ea67f9cd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") .clip(Shape12) .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12) + .glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) .controllerConfirm(onClick = onLaunch) .padding(horizontal = 10.dp, vertical = 6.dp), 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 e1cd31996..18630a028 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -201,7 +201,6 @@ private fun RailItem( if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, Motion.tw(220), label = "railFill", ) - val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing") val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") Box( contentAlignment = Alignment.Center, @@ -211,7 +210,7 @@ private fun RailItem( .size(width = if (iconOnly) 52.dp else 80.dp, height = height) .clip(Shape14) .background(fill) - .border(2.dp, ring, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), @@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on .width(if (iconOnly) 52.dp else 80.dp) .clip(Shape14) .background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f)) - .border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume) .semantics { contentDescription = "Resume $name" } 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 048a86aac..bc51f4036 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -370,7 +370,7 @@ private fun ToolCard(t: ToolSpec, modifier: 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) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) .controllerConfirm(onClick = t.onClick) .padding(horizontal = 14.dp, vertical = 12.dp), @@ -425,14 +425,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi 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 = "cardScale") - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge") Column( verticalArrangement = Arrangement.spacedBy(3.dp), modifier = modifier.paneItem(id) .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, edge, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 12.dp), 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..8f1f8b3e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp) .clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) + .glideBorder(hot, shape, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 8.dp, end = 16.dp), @@ -180,7 +180,7 @@ internal fun PrimaryButton( .shine(hot, 0.45f) // The grow and shine alone barely show on the light fill: outline it when a // controller is on it, as the other controls are. - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) { placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) } onClick() @@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") Box( contentAlignment = Alignment.Center, - modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) + modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) val hot = rememberHot(src) val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge") Box( contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12) + modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2) .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)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index ad3b34405..3f7a03558 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -202,12 +202,11 @@ private fun RowScope.KeyCap( else -> Color.White.copy(alpha = 0.07f) }, label = "keyFill", ) - val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f) Box( contentAlignment = Alignment.Center, modifier = modifier.weight(weight).fillMaxHeight() .background(fill, RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume() @@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) { contentAlignment = Alignment.Center, modifier = Modifier.fillMaxHeight() .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f)) .clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .padding(horizontal = 12.dp), ) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } 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 f02b05134..501dd69b9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,6 +1,28 @@ package com.droiddeck.launcher.ui import androidx.compose.ui.draw.alpha +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset @@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -195,6 +215,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } + var sheetCoords by remember { mutableStateOf(null) } + var stopCoords by remember { mutableStateOf(null) } val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } @@ -240,10 +262,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), ) { + FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) { Column( modifier = Modifier - .fillMaxHeight() - .width(sheetWidth) + .fillMaxSize() .background(pal.background.copy(alpha = 0.97f)) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} .focusGroup() @@ -255,7 +277,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) { Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true } + StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true } } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } @@ -437,6 +459,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } } + StopConfirm( + open = confirmStop, + pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } }, + controllerActive = controllerActive, + onCancel = { confirmStop = false }, + onStop = { onScreen -> + confirmStop = false + val from = a.onStopFrom + if (from != null) from(onScreen) else a.onStop() + }, + ) + } } } } @@ -457,44 +491,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha onDismiss = { appToChooseDisplay = null }, ) } - if (confirmStop) { - val cancelFocus = remember { FocusRequester() } - val cancel = { confirmStop = false } - // Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken - // against the screen and the session's overlay converts them back. - var stopOnScreen by remember { mutableStateOf(null) } - val stop = { - confirmStop = false - val from = a.onStopFrom - if (from != null) from(stopOnScreen) else a.onStop() - } - LaunchedEffect(controllerActive) { - if (controllerActive) { - androidx.compose.runtime.withFrameNanos { } - runCatching { cancelFocus.requestFocus() } - } - } - AlertDialog( - onDismissRequest = cancel, - modifier = Modifier.controllerBack(onBack = cancel), - title = { Text("Stop session?") }, - confirmButton = { - val dialogView = LocalView.current - TextButton( - onClick = stop, - modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c -> - val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) } - stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat()) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colors.error), - ) { Text("Stop") } - }, - dismissButton = { - TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus) - .controllerConfirm(onClick = cancel)) { Text("Cancel") } - }, - ) - } } @Composable @@ -524,7 +520,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" } - .clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape) + .clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f)) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 12.dp, end = 14.dp), @@ -549,7 +545,7 @@ private fun QuickAction( val shape = RoundedCornerShape(12.dp) val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) .clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onConfirm ?: onClick) @@ -580,7 +576,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier Box( contentAlignment = Alignment.Center, modifier = modifier.size(44.dp).clip(ring) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ring) + .glideBorder(hot, ring, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .controllerConfirm(onClick = onClick) .semantics { contentDescription = description }, @@ -639,7 +635,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent), ), ) - .border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp)) + .glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground) .semantics { contentDescription = "${drawerPageTitles[index]} page" } .hoverable(source) .clickable(interactionSource = source, indication = LocalIndication.current, onClick = select) @@ -788,3 +784,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f private const val DRAWER_TAB_SIDE_SCALE = 0.5f private const val DRAWER_TAB_SIDE_ALPHA = 0.7f + +/** Room between the Stop pill and the box that steps out under it: the pull. */ +private val StepPull = 6.dp +private val StepBoxCorner = 16.dp +private val StepFillet = 10.dp +private val StepBoxMaxWidth = 264.dp + +/** + * The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge + * and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and + * the inside corner where the pull meets the box is rounded the other way. While the box is still + * no wider than the pill it is just the pill, stretched down. + */ +private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) { + path.reset() + val r = p.right; val tl = p.left; val tt = p.top + val pb = maxOf(bottom, p.bottom) + val l = minOf(left, tl) + val pt = minOf(pullTop, pb) + val sw = tl - l + val h = (pb - pt).coerceAtLeast(0f) + val rt = minOf(p.height / 2f, p.width / 2f) + val rbr = minOf(box, (pb - tt) / 2f) + val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f) + val rpl = minOf(box, sw / 2f, h / 2f) + val f = minOf(fillet, sw / 2f) + val yl = minOf(pt + rpl, pb - rbl) + fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) { + if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false) + } + path.moveTo(tl + rt, tt) + path.lineTo(r - rt, tt) + corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt) + path.lineTo(r, pb - rbr) + corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb) + path.lineTo(l + rbl, pb) + corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl) + path.lineTo(l, yl) + corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt) + path.lineTo(tl - f, pt) + corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f) + path.lineTo(tl, tt + rt) + corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt) + path.close() +} + +/** + * Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width + * drops out of it and a wider box steps out to the left under it for the choices, right edges + * flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill, + * width first. [onStop] gets Stop's bounds on screen for the flood. + */ +@Composable +private fun StopConfirm( + open: Boolean, + pill: Rect?, + controllerActive: Boolean, + onCancel: () -> Unit, + onStop: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val left = remember { Animatable(0f) } + val bottom = remember { Animatable(0f) } + val tint = remember { Animatable(0f) } + val dim = remember { Animatable(0f) } + val items = remember { List(3) { Animatable(0f) } } + // Composed from the first open until it has folded back into the pill. + var shown by remember { mutableStateOf(false) } + var grown by remember { mutableStateOf(false) } + var content by remember { mutableStateOf(null) } + val cancelFocus = remember { FocusRequester() } + var stopFocused by remember { mutableStateOf(false) } + var cancelFocused by remember { mutableStateOf(false) } + var stopCoords by remember { mutableStateOf(null) } + val view = LocalView.current + if (!open && !shown) return + val pull = with(density) { StepPull.toPx() } + + LaunchedEffect(open, pill, content) { + val p = pill ?: return@LaunchedEffect + val c = content ?: return@LaunchedEffect + val boxLeft = (p.right - c.width).coerceAtLeast(0f) + val boxBottom = p.bottom + pull + c.height + if (open) { + shown = true + if (!grown) { + left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f) + items.forEach { it.snapTo(0f) } + grown = true + } + coroutineScope { + launch { dim.animateTo(1f, Motion.tw(260)) } + launch { tint.animateTo(1f, Motion.tw(200)) } + launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) } + launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) } + items.forEachIndexed { i, a -> + launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) } + } + if (controllerActive) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { cancelFocus.requestFocus() } + } + } + } else if (grown) { + coroutineScope { + items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) } + launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) } + launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) } + } + grown = false + shown = false + content = null + } + } + val p = pill ?: return + + Box(Modifier.fillMaxSize()) { + // The rest of the sheet dims behind it, and a tap there cancels. + Box( + Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel), + ) + val path = remember { Path() } + val boxCorner = with(density) { StepBoxCorner.toPx() } + val fillet = with(density) { StepFillet.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val restFill = colors.surface + val hotFill = lerp(colors.surface, colors.error, 0.22f) + Canvas(Modifier.fillMaxSize()) { + if (!grown) return@Canvas + stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet) + drawPath(path, lerp(hotFill, restFill, tint.value)) + drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim)) + } + // The pill's label rides on top, as the pull's handle. + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) } + .size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() }) + .padding(start = 12.dp, end = 14.dp), + ) { + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) + Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + } + val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right) + Column( + modifier = Modifier + .offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) } + .width(with(density) { maxW.toDp() }) + .onSizeChanged { if (content != it) content = it } + .controllerBack(onCancel) + .onPreviewKeyEvent { e -> + // Keep a controller in here, as the dialog's own window used to. + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key) { + Key.DirectionUp, Key.DirectionDown -> true + Key.DirectionLeft -> cancelFocused + Key.DirectionRight -> stopFocused + else -> false + } + } + .padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp), + ) { + Text( + "Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) { + ConfirmChoice( + "Cancel", danger = false, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() } + .focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }, + onClick = onCancel, + ) + ConfirmChoice( + "Stop", danger = true, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() } + .onFocusChanged { stopFocused = it.isFocused } + .onGloballyPositioned { stopCoords = it }, + ) { + val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) } + onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat())) + } + } + } + } +} + +/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */ +@Composable +private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(20.dp) + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = modifier.heightIn(min = 40.dp).clip(shape) + .background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent) + .glideBorder(hot, shape, accent) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 1b36b1c4b..b332958d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -148,7 +148,7 @@ internal fun TabStrip( .then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier) .clip(tabShape) .background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape) + .glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick) .controllerConfirm(onClick = pick) .padding(horizontal = 16.dp, vertical = 12.dp), @@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val pal = LocalPalette.current - val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge") val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret") Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo .heightIn(min = 44.dp) .clip(RoundedCornerShape(10.dp)) .background(colors.surfaceVariant) - .border(1.dp, edge, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack") - val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge") val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob") val flip = { onChange(!checked) } val shape = RoundedCornerShape(99.dp) @@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul ) { Box( Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track) - .border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape), + .glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2), ) { Box( Modifier.padding(4.dp).size(22.dp) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index da057cb72..5c6002f68 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo .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) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { 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) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 9.dp), @@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) { 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) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) { 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) + .glideBorder(hot, Shape14, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 936ce1555..6d824a604 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -213,7 +213,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) { Text( if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more") - .clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .clip(Shape12).glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) .controllerConfirm(onClick = toggle) .padding(horizontal = 6.dp, vertical = 4.dp), @@ -273,13 +273,12 @@ private fun ChannelCard( val src = remember { MutableInteractionSource() } val hot = rememberHot(src) // Focus shows at once, as on the Components page: no ripple, no fade. - val edge = if (hot || selected) pal.signal else pal.line val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().paneItem("channel:$title") - .clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14) + .clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line) .alpha(if (enabled) 1f else 0.55f) .hoverable(src) .clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick) @@ -316,7 +315,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) { .heightIn(min = 52.dp) .clip(Shape12) .background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12) + .glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line) .hoverable(src) .clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) .controllerConfirm(onClick = onClick) 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 52cde55e8..a067ef020 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -381,7 +381,7 @@ internal fun AdbTextField( .fillMaxWidth() .height(inputHeight) .background(colors.surfaceVariant, shape) - .border(1.dp, if (focused) palette.signal else palette.line2, shape) + .glideBorder(focused, shape, palette.signal, palette.line2) .padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart, ) { From b20f77f988fc96d7c7200cdbabf7637f65eae9c8 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Wed, 30 Sep 2026 16:40:46 -0400 Subject: [PATCH 06/28] Added LSFG/Import/License Checking (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lsfg): take Lossless Scaling from Steam by itself, import it once owned, rebuild on update - Lossless.kt owns the DLL and its shader cache. The copy the Steam client installs (runtime library or the card's) is taken by itself and taken again when the client updates it (size/mtime stamp). The DLL is copied in and built into a cache named after its SHA-256, so a new DLL hands the compositor a new path; a build that fails keeps the working cache. Builds are serialized. - The Steam session asks the signed-in client once per session whether the account owns app 993090 until the answer is yes, then records it and the app stops asking (BL_LOSSLESS_OWNED). Once owned, the frame generation menu offers "Import Lossless.dll…", on the main screen and in the session drawer; an import stays until Steam's copy changes. - The compositor restarts the LSFG engine when its cache path changes, and a new engine selection clears an earlier start failure, so an updated DLL or a retry no longer needs the app restarted. - The HUD says when LSFG is waiting on Lossless Scaling instead of "starting"; frame generation strings are resources. - Removed LsfgNative's unused DLL variant and cache-match calls. * Find Lossless.dll in added Games libraries too * LSFG: 120, 90 and 60 fps targets alongside 2x-4x The pacer's target-rate mode picks 0-3 generations per game frame. While it is warm the input ring and shared chain follow every frame, so frames that generate nothing don't leave the next one without history. * Credit LSFG: Camille LaVey / Eden, ported to WinNative and DroidDeck by @maxjivi05 * LSFG: aim 0.1 fps under the target so output never passes the refresh rate --- README.md | 2 +- app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_common.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_common.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp | 36 +--- app/src/main/cpp/framegen/lsfg/lsfg_dll.h | 15 +- app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_engine.cpp | 9 +- app/src/main/cpp/framegen/lsfg/lsfg_engine.h | 9 +- app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_generate.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_generate.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp | 16 -- .../main/cpp/framegen/lsfg/lsfg_mipmaps.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_mipmaps.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp | 10 +- app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_shaders.cpp | 7 +- .../main/cpp/framegen/lsfg/lsfg_shaders.hpp | 7 +- app/src/main/cpp/framegen/lsfg/lsfg_vkd.h | 4 +- .../cpp/waylandcomp/src/framegen_bridge.c | 51 +++-- .../cpp/waylandcomp/src/framegen_bridge.h | 7 +- .../cpp/waylandcomp/src/framegen_engine.cpp | 6 +- .../cpp/waylandcomp/src/framegen_engine.h | 5 +- .../waylandcomp/src/framegen_engine_stub.c | 5 +- .../main/cpp/waylandcomp/src/framegen_jni.c | 4 +- .../com/droiddeck/launcher/MainActivity.kt | 38 +++- .../com/droiddeck/launcher/SessionActivity.kt | 56 ++++-- .../com/droiddeck/launcher/gpu/FrameGen.kt | 84 ++++---- .../com/droiddeck/launcher/gpu/Lossless.kt | 187 ++++++++++++++++++ .../droiddeck/launcher/gpu/LsfgNative.java | 105 ++-------- .../com/droiddeck/launcher/session/PerfHud.kt | 20 +- .../launcher/session/SessionService.kt | 1 + .../com/droiddeck/launcher/ui/FrameGenMenu.kt | 44 ++++- .../droiddeck/launcher/ui/FrontEndScreen.kt | 9 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 8 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 16 +- .../launcher/wayland/CompositorHost.kt | 4 +- .../launcher/wayland/WaylandCompositor.java | 8 +- app/src/main/res/values/strings.xml | 22 +++ .../droiddeck/launcher/gpu/FrameGenTest.kt | 42 ++++ .../droiddeck/launcher/gpu/LosslessTest.kt | 48 +++++ .../overlay/usr/local/bin/bannerlator-session | 18 ++ 51 files changed, 676 insertions(+), 353 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt diff --git a/README.md b/README.md index dd0abc25f..ea1242bb2 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,4 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence -GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. +GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp index 73be35b59..04a37b720 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_alpha.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp index 6c233c16c..9a4e15ddf 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp index 299e22cf7..d9413e647 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_beta.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp index 1d1e77a56..7d259f9e2 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp index f9ff4b4f8..54acb1a0f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_chain.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp index 4324c80bc..4bab527fd 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp index b72eb2287..c93275486 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_common.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp index 1f6b20983..c5ab3e0ad 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp index ddba102d5..cf9b31bfa 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_delta.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp index 8d8232675..e40ffe7bd 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp index 78ddbccc7..0af479be8 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp @@ -1,9 +1,9 @@ // See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless // Scaling frame-generation chain. // -// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to -// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk -// (PancakeTAS). Bannerlator is GPL-3.0. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck +// by @maxjivi05. GPL-3.0-or-later. #include "lsfg_dll.h" #include "lsfg_dxbc.h" @@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) { return status; } -Variant dllVariant(const std::string& dllPath, bool preferFp16) { - if (dllPath.empty()) return Variant::None; - - PeImage img; - if (!img.open(dllPath)) return Variant::None; - - ResourceTable table; - if (parseResources(img, table) != DllStatus::Ok) return Variant::None; - - Variant v = selectVariant(table, preferFp16); - if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated; - return v; -} - DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) { if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled; @@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b return DllStatus::Ok; } -DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath, - bool& outMatches) { - outMatches = false; - if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable; - - CacheHeader header{}; - if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable; - - PeImage img; - if (!img.open(dllPath)) return DllStatus::NotInstalled; - - outMatches = header.sourceSize == (uint64_t)img.size() - && header.sourceHash == fnv1a64(img.data(), img.size()); - return DllStatus::Ok; -} - DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) { outVariant = Variant::None; CacheHeader header{}; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h index 72a862b58..74d6ca83d 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h @@ -7,7 +7,7 @@ // ship as RCDATA resources inside Lossless.dll from the user's own paid copy // of Lossless Scaling (Steam app 993090). Every implementation - upstream // lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their -// own DLL and extracts the resources on device. Bannerlator does the same, and +// own DLL and extracts the resources on device. DroidDeck does the same, and // bundles nothing. // // The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a @@ -26,9 +26,9 @@ // Both producers fill the same shader-id -> SPIR-V words map, so everything // downstream of this file is source-agnostic. // -// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is -// credited to Camille LaVey / the Eden Emulator Project, and which follows -// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck +// by @maxjivi05. GPL-3.0-or-later. // ============================================================================ #include @@ -98,18 +98,11 @@ const char* variantName(Variant v); // reported as valid - the translator handles it. DllStatus validateDll(const std::string& dllPath); -// Which producer would be used for this DLL. -Variant dllVariant(const std::string& dllPath, bool preferFp16); - // Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath // (via temp file + rename, so a failed build cannot leave a half-written // cache behind). Records which producer ran in the cache header. DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16); -// Is the cache current for this DLL? Compares source size + content hash. -DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath, - bool& outMatches); - // Load a previously built cache. DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet); diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp index db23e3686..c8084119f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp @@ -1,9 +1,10 @@ // See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator. // // Follows upstream lsfg-vk's src/extract/trans.cpp, including its -// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp -// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored -// under cpp/thirdparty/dxbc. +// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and +// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator +// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, +// vendored under cpp/thirdparty/dxbc. #include "lsfg_dxbc.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp index 4f1482eb7..ba9e8a021 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp @@ -1,7 +1,8 @@ // See lsfg_engine.h. // -// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited -// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by +// @maxjivi05, where it runs on gamescope's output in the compositor. #include "lsfg_engine.h" @@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32 // nothing. Copy only when generating, on the one priming frame that // precedes generation, or while the governor is warming up towards a // probe - so the ring is populated by the time it is needed. - const bool needHistory = generations > 0 || primeHistory_ + const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame() || (governorEnabled_ && governor_.wantsHistory()); if (needHistory) { copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height}); @@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32 // regardless of the game's own settings, and it fed straight back into the // governor: the chain made the source rate collapse, the governor saw the // collapse and refused to generate, and refusing did not stop the chain. - if (warm_ && generations > 0) chain_->DispatchShared(cmd, count); + if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count); } void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex, diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_engine.h b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h index 20489e652..c8a5028d0 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_engine.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h @@ -16,8 +16,9 @@ // unavoidable cost of interpolating rather than extrapolating; no placement of // this code changes it. // -// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited -// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk. +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by +// @maxjivi05, where it runs on gamescope's output in the compositor. // ============================================================================ #include @@ -109,6 +110,10 @@ private: // generation last stopped. bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; } + // With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the + // shared chain have to follow every frame it keeps warm, not just the ones that generate. + bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; } + Device device_{}; std::string cachePath_; std::unique_ptr shaders_; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp index 392930b61..574382ed7 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_gamma.hpp" #include "lsfg_shaders.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp index 8210dd259..d1cd38486 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp index 1c84cb186..7be96561c 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_generate.hpp" #include "lsfg_dll.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp index 68e23967b..0612b7422 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp index ba8447313..ab13096d6 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp @@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass return (jint)lsfg::validateDll(toStdString(env, dllPath)); } -extern "C" JNIEXPORT jint JNICALL -Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath, - jboolean preferFp16) { - return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE); -} - extern "C" JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath, jstring cachePath, jboolean preferFp16) { @@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, preferFp16 == JNI_TRUE); } -extern "C" JNIEXPORT jboolean JNICALL -Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass, - jstring cachePath, - jstring dllPath) { - bool matches = false; - const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath), - toStdString(env, dllPath), matches); - return (jboolean)(status == lsfg::DllStatus::Ok && matches); -} - extern "C" JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) { lsfg::Variant variant = lsfg::Variant::None; diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp index 4d1762555..ef1e11318 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_mipmaps.hpp" #include "lsfg_dll.h" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp index 4ce06b0f5..d301c26ef 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp @@ -4,10 +4,9 @@ // SPDX-FileCopyrightText: Copyright 2025 lsfg-vk // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp index 29da8729d..8ddf453c2 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_pacer.hpp" @@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f; constexpr float SOURCE_STALE_SECONDS = 0.5f; constexpr float DISCONTINUITY_SECONDS = 0.25f; constexpr float HEADROOM_EPSILON = 0.02f; +// A target is aimed just under, so rounding never puts output past the panel's refresh rate. +constexpr float TARGET_MARGIN = 0.1f; constexpr float CREDIT_EPSILON = 1.0e-4f; constexpr float SOURCE_ACCUM_FLOOR = 0.01f; constexpr uint32_t MIN_RATE_SAMPLES = 12; @@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) { if (target_rate > 0.0f && config.refresh_rate > 0.0f) { target_rate = std::min(target_rate, config.refresh_rate); } + if (target_rate > 0.0f) target_rate -= TARGET_MARGIN; if (target_rate == 0.0f) { // No explicit target rate: the multiplier is the multiplier, full stop. diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp index 5613c4c30..fe4a43ee5 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp index 96fb7bfdc..d3266d173 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #include "lsfg_shaders.hpp" #include "lsfg_common.hpp" diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp index ba7854da4..d91b00c18 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp +++ b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // -// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later), -// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and -// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator -// resolves entry points through the renderer's own table (see lsfg_vkd.h). +// LSFG from the work of Camille LaVey / the Eden Emulator Project, following +// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the +// Vulkan dispatch differs here (see lsfg_vkd.h). #pragma once diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h index 0657ac975..c86d2224f 100644 --- a/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h +++ b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h @@ -2,10 +2,10 @@ // ============================================================================ // lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global. // -// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan +// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan // through a per-context VkTable (`vk_`), because an adrenotools-loaded driver // lives in an isolated linker namespace and shares no global symbols with the -// system loader. The LSFG chain, ported from eden by way of WinNative, calls +// system loader. The LSFG chain, ported from Eden by way of WinNative, calls // through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain // code to thread a table through every call, this shim exposes exactly the 34 // entry points the chain uses and is filled from the renderer's own table when diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 43b94987f..47a819bfd 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri static _Atomic int g_kind = VKP_FG_ENGINE_LSFG; static _Atomic int g_armed; static _Atomic int g_mult = 2; +static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */ static _Atomic int g_model = 3; static _Atomic int g_preset = 2; static _Atomic int g_cfg_dirty = 1; @@ -51,6 +52,7 @@ static struct { /* ---- engine + generation ring (compositor thread) ------------------------------------ */ static int g_engine_kind = -1; /* kind of the running engine, -1 = none */ +static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */ static int g_engine_ok; /* The engine of this kind (with this cache) could not start or build its chain; not retried * every frame - a new engine selection or a new cache clears it. */ @@ -92,15 +94,20 @@ static int64_t now_ns(void) { void vkp_framegen_set_engine(int kind) { if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG; atomic_store(&g_kind, kind); + atomic_store(&g_failed_kind, -1); atomic_store(&g_cfg_dirty, 1); } -void vkp_framegen_set_armed(int armed, int multiplier) { +void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) { + if (target_fps < 0) target_fps = 0; + /* Adaptive sizes the ring and the swapchain for the most it may ever generate. */ + if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1; if (multiplier < 2) multiplier = 2; if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1; if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1); + if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1); atomic_store(&g_armed, armed ? 1 : 0); - LOGD("framegen: set_armed(%d, x%d)", armed, multiplier); + LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps); } void vkp_framegen_set_lsfg_cache_path(const char *path) { @@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) { } static int ensure_engine(int kind) { - if (g_engine_ok && g_engine_kind == kind) return 1; + const int cache_gen = atomic_load(&g_cache_gen); + if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen)) + return 1; if (start_failed_for(kind)) return 0; if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; } char *path = NULL; @@ -293,7 +302,7 @@ static int ensure_engine(int kind) { if (g_cache_path) path = strdup(g_cache_path); pthread_mutex_unlock(&g_lock); if (kind == VKP_FG_ENGINE_LSFG && !path) { - FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)"); + FGLOG("LSFG Native can't start: no shader cache from Lossless.dll"); mark_failed(kind); return 0; } @@ -305,29 +314,39 @@ static int ensure_engine(int kind) { mark_failed(kind); return 0; } - g_engine_ok = 1; g_engine_kind = kind; + g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen; atomic_store(&g_cfg_dirty, 1); g_built_w = g_built_h = 0; FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info()); return 1; } -static int g_logged_mult; +/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */ +static const char *describe_mode(char *buf, size_t n, int mult, int target) { + if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1); + else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s"); + return buf; +} + +static int g_logged_mult, g_logged_target; static void log_arm_transition(int armed, int kind, int mult) { + const int target = atomic_load(&g_target); + char mode[96]; if (armed == g_was_armed) { - if (armed && mult != g_logged_mult) { - g_logged_mult = mult; - FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1); + if (armed && (mult != g_logged_mult || target != g_logged_target)) { + g_logged_mult = mult; g_logged_target = target; + FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target)); } return; } g_was_armed = armed; if (armed) { - g_logged_mult = mult; + g_logged_mult = mult; g_logged_target = target; float flow, hz; pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock); - FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late " - "with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz); + FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late " + "with the interpolated frames ahead of them", fge_engine_name(kind), + describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz); } else { FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated); g_generating_logged = 0; @@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, if (atomic_exchange(&g_cfg_dirty, 0)) { float flow, hz; pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock); - fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset)); + fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz, + atomic_load(&g_model), atomic_load(&g_preset)); } if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) { if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) { @@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view, 0, 0, NULL, 0, NULL, nb, bars); if (!g_generating_logged) { g_generating_logged = 1; - FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)", - fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s", + char mode[96]; + FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind), + describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h, fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : ""); } } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h index 2d4d6d99a..b60229cdb 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h @@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 }; /* ---- app controls (any thread) ------------------------------------------------------- */ void vkp_framegen_set_engine(int kind); -/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */ -void vkp_framegen_set_armed(int armed, int multiplier); -/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */ +/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs); + * armed = 0 stops generating (the ring and the engine stay for a re-arm). */ +void vkp_framegen_set_armed(int armed, int multiplier, int target_fps); +/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */ void vkp_framegen_set_lsfg_cache_path(const char *path); /* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index 1d9d29468..35e160edc 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -197,11 +197,11 @@ int fge_unavailable(void) { return g_lsfg && g_lsfg->unavailable() ? 1 : 0; } -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, - int perf_preset) { +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, + int model, int perf_preset) { if (multiplier < 2) multiplier = 2; if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale); - else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz); + else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz); } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.h b/app/src/main/cpp/waylandcomp/src/framegen_engine.h index ecc9e30d1..b0deec70b 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.h @@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path); void fge_stop(void); /* The engine came up but later found it cannot generate (chain build failed). */ int fge_unavailable(void); -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, - int perf_preset); +/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */ +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, + int model, int perf_preset); /* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same * (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */ int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt); diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c index 45bd27dff..dd4144d3e 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c @@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; } int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; } void fge_stop(void) {} int fge_unavailable(void) { return 1; } -void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) { - (void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset; +void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model, + int perf_preset) { + (void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset; } int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; } uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) { diff --git a/app/src/main/cpp/waylandcomp/src/framegen_jni.c b/app/src/main/cpp/waylandcomp/src/framegen_jni.c index c693ad25c..8d5bad571 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_jni.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_jni.c @@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz, - jboolean armed, jint multiplier) { + jboolean armed, jint multiplier, jint targetFps) { (void)env; (void)clazz; - vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier); + vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps); } JNIEXPORT void JNICALL diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ae962bb76..a26d2097b 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -28,7 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.toArgb import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.TurnipDriver -import com.droiddeck.launcher.gpu.LsfgNative +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.DesktopCatalog import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller @@ -101,6 +101,7 @@ class MainActivity : ComponentActivity() { private var percent by mutableIntStateOf(-1) private var failed by mutableStateOf(false) private var frameGenLabel by mutableStateOf("Off") + private var lossless by mutableStateOf(Lossless.State.NONE) private var showRemove by mutableStateOf(false) private var showNonAdreno by mutableStateOf(null) private var glThread by mutableStateOf(true) @@ -158,6 +159,9 @@ 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 pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } + } private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) @@ -167,6 +171,26 @@ class MainActivity : ComponentActivity() { if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then) } + /** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */ + private fun syncLossless() { + Thread({ + Lossless.sync(this) + val state = Lossless.state(this) + ui.post { lossless = state } + }, "lossless-sync").start() + } + + private fun importLossless(dll: File) { + Thread({ + val message = Lossless.message(this, Lossless.import(this, dll)) + val state = Lossless.state(this) + ui.post { + lossless = state + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + } + }, "lossless-import").start() + } + /** 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) { @@ -337,8 +361,8 @@ class MainActivity : ComponentActivity() { offlineAccount = offlineAccount, offline = offline, frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, steamGames = steamGames, emulators = emulatorList, running = runningLabel, - frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), - lsfgReady = LsfgNative.isInstalled(this), + frameGen = FrameGen.mode(this), + lossless = lossless, 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, @@ -414,10 +438,13 @@ class MainActivity : ComponentActivity() { onInstallPackage = { id -> installPackage(id) }, onRemovePackage = { id -> removePackage(id) }, onRuntime = { onRuntimeButton() }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this, engine, multiplier) + onFrameGenPick = { mode -> + FrameGen.set(this, mode) frameGenLabel = FrameGen.label(this) }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. @@ -649,6 +676,7 @@ class MainActivity : ComponentActivity() { returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn()) } refreshPhantomStatus() + syncLossless() // Opening the app and coming back from a session both land here. updates.onResume() // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 1c58332a7..9db297df2 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -24,6 +24,7 @@ import android.widget.FrameLayout import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -33,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.files.InAppFilePicker import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen -import com.droiddeck.launcher.gpu.LsfgNative +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.input.EvdevKeys import com.droiddeck.launcher.input.KeyboardHost @@ -170,9 +172,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var pcKeyboardOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) private var fillScreen by mutableStateOf(true) - private var frameGenLabel by mutableStateOf("Off") - private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF) - private var frameGenMultiplier by mutableStateOf(2) + private var lossless by mutableStateOf(Lossless.State.NONE) + private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } + } + private var frameGen by mutableStateOf(FrameGen.Mode.OFF) private var fexPreset by mutableStateOf("") private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) @@ -338,6 +342,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Opening the drawer takes the controller away from the game: release its pad. androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) { + syncLossless() padBridge?.releaseAll() androidx.compose.runtime.withFrameNanos { } val requested = sessionOverlay.requestFocus() @@ -361,8 +366,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { androidApps = androidApps, hudOn = hudOn, fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null, - frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier, - lsfgReady = LsfgNative.isInstalled(this@SessionActivity), + frameGen = frameGen, + lossless = lossless, oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode, touchAuto = if (usingTouchpad()) "touchpad" else "direct", shapeMode = shapeMode, fexPreset = fexPreset, @@ -371,11 +376,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on }, - onFrameGenPick = { engine, multiplier -> - FrameGen.set(this@SessionActivity, engine, multiplier) + onFrameGenPick = { mode -> + FrameGen.set(this@SessionActivity, mode) readPrefs() applyFrameGen() }, + onImportLossless = { + pickLossless.launch(InAppFilePicker.buildIntent( + this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title))) + }, onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, @@ -581,9 +590,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { hudOn = SessionPrefs.hudEnabled(this) fillScreen = SessionPrefs.forceFullscreen(this) touchMode = SessionPrefs.touchMode(this) - frameGenLabel = FrameGen.label(this) - frameGenEngine = FrameGen.engine(this) - frameGenMultiplier = FrameGen.multiplier(this) + frameGen = FrameGen.mode(this) fexPreset = SessionPrefs.fexPreset(this) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) oscMode = SessionPrefs.oscMode(this) @@ -887,10 +894,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val hz = refreshHz() Thread({ val problem = FrameGen.apply(this, hz) - if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() } + val state = Lossless.state(this) + runOnUiThread { + lossless = state + if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show() + } }, "frame-gen").start() } + /** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */ + private fun syncLossless() { + Thread({ + Lossless.sync(this) + val state = Lossless.state(this) + runOnUiThread { lossless = state } + }, "lossless-sync").start() + } + + private fun importLossless(dll: File) { + Thread({ + val message = Lossless.message(this, Lossless.import(this, dll)) + val state = Lossless.state(this) + runOnUiThread { + lossless = state + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen() + } + }, "lossless-import").start() + } + // ── Session state ─────────────────────────────────────────────────────────────────────── /** Keeps the loading overlay current from the session log, and its clock moving. */ diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 236c17a00..b864e837b 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu import android.content.Context import android.util.Log +import com.droiddeck.launcher.R import com.droiddeck.launcher.wayland.WaylandCompositor /** @@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor * * Both engines generate on gamescope's final output inside our compositor, so they work for any * game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the - * shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor + * shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor * setter is a value store on the native side, so this can be applied before the compositor is * up and changed live from the main screen while a session runs. */ @@ -26,62 +27,77 @@ object FrameGen { private const val FLOW_SCALE_WINFG = 0.60f private const val FLOW_SCALE_LSFG = 0.80f + /** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */ + val ADAPTIVE_TARGETS = listOf(120, 90, 60) + private const val PREFS = "frame_gen" - fun engine(context: Context): String = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF - - fun multiplier(context: Context): Int = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4) - - fun set(context: Context, engine: String, multiplier: Int) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() - .putString("engine", engine).putInt("multiplier", multiplier).apply() + /** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */ + data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) { + companion object { + val OFF = Mode(ENGINE_OFF) + } } - /** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */ - fun label(context: Context): String = label(engine(context), multiplier(context)) + fun engine(context: Context): String = mode(context).engine - fun label(engine: String, multiplier: Int): String = when (engine) { - ENGINE_WINFG -> "Win-FG $multiplier×" - ENGINE_LSFG -> "LSFG $multiplier×" - else -> "Off" + fun mode(context: Context): Mode { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF + val target = prefs.getInt("target", 0) + if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target) + return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4)) + } + + fun set(context: Context, mode: Mode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply() + } + + /** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */ + fun label(context: Context): String = label(context, mode(context)) + + fun label(context: Context, mode: Mode): String = when { + mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier) + mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target) + mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier) + else -> context.getString(R.string.frame_gen_off) } /** * Pushes the saved setting into the compositor. Returns a message for the user when the - * engine cannot run (LSFG without Lossless Scaling), or null. + * engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated + * Lossless.dll is prepared. */ fun apply(context: Context, refreshHz: Float): String? { - val engine = engine(context) - val multiplier = multiplier(context) - var problem: String? = null - when (engine) { + val mode = mode(context) + when (mode.engine) { ENGINE_LSFG -> { - val status = LsfgNative.ensureCache(context) - if (status != LsfgNative.STATUS_OK) { - problem = if (status == LsfgNative.STATUS_NOT_INSTALLED) - "LSFG needs Lossless Scaling installed from the Steam client" - else "LSFG: " + LsfgNative.statusName(status) - Log.w(TAG, problem) - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + val status = Lossless.sync(context) + val cache = Lossless.cacheFile(context) + if (status != LsfgNative.STATUS_OK || cache == null) { + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + val problem = context.getString( + if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed, + ) + Log.w(TAG, "$problem (status $status)") return problem } WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG) - WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath) + WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath) WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz) - WaylandCompositor.nativeSetFrameGenArmed(true, multiplier) + WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target) } ENGINE_WINFG -> { WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG) WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET) WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz) - WaylandCompositor.nativeSetFrameGenArmed(true, multiplier) + WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0) } - else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0) + else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) } - Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz") - return problem + Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz") + return null } private const val TAG = "FrameGen" diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt new file mode 100644 index 000000000..7a153b37e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt @@ -0,0 +1,187 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.R +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import java.security.DigestOutputStream +import java.security.MessageDigest + +/** + * LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090). + * + * The copy the Steam client installs, in any library it installs to, is taken by + * itself and taken again whenever the client updates it. Once the session has seen the account + * own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes + * or another is imported. The DLL is copied in and its shaders are built into a cache named after + * its contents, so a different DLL hands the compositor a different path and its engine reloads. + * Everything here blocks: call it off the main thread. + */ +object Lossless { + enum class Source { NONE, STEAM, IMPORT } + + enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED } + + data class State(val owned: Boolean, val ready: Boolean, val source: Source) { + companion object { + val NONE = State(owned = false, ready = false, source = Source.NONE) + } + } + + private const val TAG = "Lossless" + private const val PREFS = "lossless" + private const val KEY_OWNED = "owned" + private const val KEY_ID = "id" + private const val KEY_SOURCE = "source" + private const val KEY_STEAM_STAMP = "steamStamp" + private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll" + private const val UNCHANGED = -1 + + fun state(context: Context): State { + val source = prefs(context).getString(KEY_SOURCE, null) + ?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE + return State(owned(context), cacheFile(context)?.isFile == true, source) + } + + /** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */ + fun owned(context: Context): Boolean { + if (prefs(context).getBoolean(KEY_OWNED, false)) return true + if (!ownedFlag(context).exists()) return false + markOwned(context) + return true + } + + fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) } + + /** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */ + @Synchronized + fun sync(context: Context): Int { + val steam = steamDll(context) + if (steam != null) { + markOwned(context) + val stamp = "${steam.length()}:${steam.lastModified()}" + if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) { + val status = adopt(context, steam, Source.STEAM) + if (status != LsfgNative.STATUS_OK && status != UNCHANGED) { + Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}") + } + prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply() + } + } + return ensureBuilt(context) + } + + @Synchronized + fun import(context: Context, dll: File): ImportResult { + if (!owned(context)) return ImportResult.NOT_OWNED + return when (adopt(context, dll, Source.IMPORT)) { + UNCHANGED -> ImportResult.UNCHANGED + LsfgNative.STATUS_OK -> ImportResult.UPDATED + LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE, + LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID + else -> ImportResult.FAILED + } + } + + fun message(context: Context, result: ImportResult): String = context.getString( + when (result) { + ImportResult.UPDATED -> R.string.lsfg_imported + ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged + ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned + ImportResult.INVALID -> R.string.lsfg_import_invalid + ImportResult.FAILED -> R.string.lsfg_import_failed + }, + ) + + private fun ensureBuilt(context: Context): Int { + val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED + val cache = cacheFor(context, id) + if (usable(cache)) return LsfgNative.STATUS_OK + val dll = storedDll(context) + if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED + return build(dll, cache) + } + + private fun adopt(context: Context, source: File, from: Source): Int { + val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged") + try { + val id = copyHashing(source, staged) + if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED + val cache = cacheFor(context, id) + var status = LsfgNative.nativeValidateDll(staged.path) + if (status == LsfgNative.STATUS_OK) status = build(staged, cache) + if (status != LsfgNative.STATUS_OK) { + cache.delete() + return status + } + if (!staged.renameTo(storedDll(context))) { + cache.delete() + return LsfgNative.STATUS_CACHE_UNUSABLE + } + prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply() + removeStale(context, id) + Log.i(TAG, "Lossless.dll from $source ($from) ready as $id") + return LsfgNative.STATUS_OK + } catch (e: java.io.IOException) { + Log.w(TAG, "could not copy $source", e) + return LsfgNative.STATUS_UNREADABLE_FILE + } catch (t: Throwable) { + Log.e(TAG, "could not take $source", t) + return LsfgNative.STATUS_CACHE_UNUSABLE + } finally { + staged.delete() + } + } + + private fun build(dll: File, cache: File): Int = try { + val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true) + Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" + + if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "") + status + } catch (t: Throwable) { + Log.e(TAG, "shader cache ${cache.name}", t) + LsfgNative.STATUS_CACHE_UNUSABLE + } + + private fun usable(cache: File): Boolean = cache.isFile && + runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false) + + private fun copyHashing(source: File, target: File): String { + val digest = MessageDigest.getInstance("SHA-256") + source.inputStream().use { input -> + DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) } + } + return digest.digest().take(8).joinToString("") { "%02x".format(it) } + } + + private fun removeStale(context: Context, keep: String) { + File(context.filesDir, "lsfg-native").deleteRecursively() + dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name } + ?.forEach { it.delete() } + } + + /** The newest copy in any library the client installs to: its own, the card's, the Games folders. */ + private fun steamDll(context: Context): File? { + val libraries = listOfNotNull( + File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"), + GameStorage.effective(context)?.path?.let(::File), + ) + SessionPrefs.addedGamesDirs(context).map(::File) + return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() } + } + + private fun markOwned(context: Context) { + if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply() + } + + private fun statusName(status: Int): String = + runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status") + + private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned") + private fun dir(context: Context) = File(context.filesDir, "lsfg") + private fun storedDll(context: Context) = File(dir(context), "Lossless.dll") + private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache") + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java index c9b03ef03..29c7dbd17 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java @@ -1,99 +1,30 @@ package com.droiddeck.launcher.gpu; -import android.content.Context; -import android.util.Log; - -import com.droiddeck.launcher.runtime.LinuxRuntime; - -import java.io.File; - /** - * LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling - * and cached on device. - * - *

The 25 compute shaders are not redistributable. They live as resources inside the user's - * paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the - * Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped - * read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth - * doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the - * DLL's size and hash. Call {@link #ensureCache} off the main thread. - * - *

Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into - * the compositor library. + * The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library): + * {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its + * DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block. */ -public final class LsfgNative { - private static final String TAG = "LsfgNative"; - +final class LsfgNative { static { System.loadLibrary("bannerwayland"); } private LsfgNative() {} // Mirrors lsfg::DllStatus. - public static final int STATUS_OK = 0; - public static final int STATUS_NOT_INSTALLED = 1; - public static final int STATUS_UNREADABLE_FILE = 2; - public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3; - public static final int STATUS_MISSING_SHADERS = 4; - public static final int STATUS_TRANSLATION_FAILED = 5; - public static final int STATUS_CACHE_UNUSABLE = 6; + static final int STATUS_OK = 0; + static final int STATUS_NOT_INSTALLED = 1; + static final int STATUS_UNREADABLE_FILE = 2; + static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3; + static final int STATUS_MISSING_SHADERS = 4; + static final int STATUS_TRANSLATION_FAILED = 5; + static final int STATUS_CACHE_UNUSABLE = 6; - private static native int nativeValidateDll(String dllPath); - private static native int nativeDllVariant(String dllPath, boolean preferFp16); - private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16); - private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath); - private static native int nativeCacheVariant(String cachePath); - private static native String nativeStatusName(int status); - private static native String nativeVariantName(int variant); + // Mirrors lsfg::Variant. + static final int VARIANT_NONE = 0; - /** Where Lossless Scaling lands when installed from the client inside the runtime. */ - public static File losslessDll(Context context) { - return new File(LinuxRuntime.rootDir(context), - "root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll"); - } - - /** Where the translated SPIR-V chain is cached. */ - public static File cacheFile(Context context) { - return new File(context.getFilesDir(), "lsfg-native/shaders.cache"); - } - - public static boolean isInstalled(Context context) { - return losslessDll(context).isFile(); - } - - /** - * Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or - * changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start. - */ - public static int ensureCache(Context context) { - File dll = losslessDll(context); - if (!dll.isFile()) return STATUS_NOT_INSTALLED; - File cache = cacheFile(context); - try { - if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) { - return STATUS_OK; - } - int status = nativeValidateDll(dll.getPath()); - if (status != STATUS_OK) { - Log.w(TAG, "Lossless.dll rejected: " + statusName(status)); - return status; - } - //noinspection ResultOfMethodCallIgnored - cache.getParentFile().mkdirs(); - status = nativeBuildCache(dll.getPath(), cache.getPath(), true); - Log.i(TAG, "cache build: " + statusName(status) - + (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : "")); - return status; - } catch (Throwable t) { - Log.e(TAG, "ensureCache", t); - return STATUS_CACHE_UNUSABLE; - } - } - - public static String statusName(int status) { - try { - return nativeStatusName(status); - } catch (Throwable t) { - return "status " + status; - } - } + static native int nativeValidateDll(String dllPath); + static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16); + static native int nativeCacheVariant(String cachePath); + static native String nativeStatusName(int status); + static native String nativeVariantName(int variant); } diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index b0d49257b..a9b5b0459 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -8,7 +8,9 @@ import android.os.SystemClock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.droiddeck.launcher.R import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.util.Locale @@ -86,20 +88,22 @@ class PerfHud(context: Context) { } private fun line(base: Float): String { - val engine = FrameGen.engine(context) - if (engine == FrameGen.ENGINE_OFF) return fps(base) - val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG" - val multiplier = FrameGen.multiplier(context) + val mode = FrameGen.mode(context) + if (mode.engine == FrameGen.ENGINE_OFF) return fps(base) + val lsfg = mode.engine == FrameGen.ENGINE_LSFG + val name = if (lsfg) "LSFG" else "Win-FG" + val label = FrameGen.label(context, mode) + if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name) val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null } val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 } return when { - problem == 1 -> "$name: unsupported" - problem == 2 -> "$name: engine failed" + problem == 1 -> context.getString(R.string.hud_fg_unsupported, name) + problem == 2 -> context.getString(R.string.hud_fg_failed, name) stats != null && stats[3] > 1f -> { val source = if (stats[2] > 1f) stats[2] else base - String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3]) + String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3]) } - else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base)) + else -> context.getString(R.string.hud_fg_starting, label, fps(base)) } } 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 27dc9ac5f..9a5465bd6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -314,6 +314,7 @@ class SessionService : Service() { val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) guest.add("BL_ADDED_GAMES=" + listing.path) if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1") + if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1") if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" }) } // Where the guest leaves a request for another session (the desktop's Steam launchers). diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt index 2d533fd52..1ec0fbfad 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -1,22 +1,48 @@ package com.droiddeck.launcher.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless /** * The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup, - * a game's launch settings and the session drawer, so they say the same thing. + * a game's launch settings and the session drawer, so they say the same thing. Importing a + * Lossless.dll is offered once the account is known to own Lossless Scaling. */ @Composable internal fun FrameGenMenu( - host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean, - onPick: (engine: String, multiplier: Int) -> Unit, + host: MenuHost, mode: FrameGen.Mode, lossless: Lossless.State, + onPick: (FrameGen.Mode) -> Unit, + onImportLossless: () -> Unit, ) { - AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam" - fun pick(e: String, m: Int) { onPick(e, m); host.open = null } - MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) } - for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) } - for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) } + val context = LocalContext.current + AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = stringResource(R.string.frame_gen_title)) { firstItemFocus -> + val need = when { + lossless.ready -> null + lossless.owned -> stringResource(R.string.lsfg_needs_install) + else -> stringResource(R.string.lsfg_needs_steam) + } + @Composable + fun item(m: FrameGen.Mode, enabled: Boolean = true, detail: String? = null, focus: FocusRequester? = null) = + MenuItem(FrameGen.label(context, m), checked = mode == m, enabled = enabled, detail = detail, focusRequester = focus) { + onPick(m); host.open = null + } + item(FrameGen.Mode.OFF, focus = firstItemFocus) + for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m)) + val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } + + FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) } + lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) } + if (lossless.owned) { + val using = when (lossless.source) { + Lossless.Source.STEAM -> stringResource(R.string.lsfg_using_steam) + Lossless.Source.IMPORT -> stringResource(R.string.lsfg_using_import) + Lossless.Source.NONE -> null + } + MenuItem(stringResource(R.string.lsfg_import), checked = false, detail = using) { host.open = null; onImportLossless() } + } } } 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 1c0619fa3..74f5caaf3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -91,9 +92,8 @@ class FrontEndState( val steamGames: List, val emulators: List, val running: String?, - val frameGenEngine: String = FrameGen.ENGINE_OFF, - val frameGenMultiplier: Int = 2, - val lsfgReady: Boolean = false, + val frameGen: FrameGen.Mode = FrameGen.Mode.OFF, + val lossless: Lossless.State = Lossless.State.NONE, val pageKey: String? = null, val theme: String = Themes.GRAPHITE, val isHomeApp: Boolean = false, @@ -139,7 +139,8 @@ class FrontEndActions( val onInstallPackage: (String) -> Unit, val onRemovePackage: (String) -> Unit, val onRuntime: () -> Unit, - val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + val onFrameGenPick: (FrameGen.Mode) -> Unit, + val onImportLossless: () -> Unit, val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index bc51f4036..8a3dee136 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalInputModeManager @@ -73,6 +74,7 @@ import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.R // The Setup page: runtime and device checks, tools, frame generation and launch settings. @@ -269,7 +271,7 @@ internal fun SetupPanel( true to SessionPrefs.BACK_QAM_THEN_MENU, ), s.backActionsInverted, onPick = a.onBackActionsInverted, ) - SettingsRow("Frame generation", "Select the frame generation mode") { + SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) { Box { ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } FrameGenMenu(s, a, host) @@ -383,7 +385,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { @Composable private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) { - FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick) + FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless) } /** @@ -399,7 +401,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } } add { m -> Box(m) { - SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { + SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { host.open = if (host.open == "fg") null else "fg" } FrameGenMenu(s, a, host) 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 501dd69b9..af38940f0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -113,9 +113,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalContext import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FexPreset import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.input.SecondScreenDisplay @@ -157,9 +159,8 @@ class DrawerActions( val isHomeApp: Boolean, val androidApps: List, val hudOn: Boolean, - val frameGenEngine: String, - val frameGenMultiplier: Int, - val lsfgReady: Boolean, + val frameGen: FrameGen.Mode, + val lossless: Lossless.State, val oscMode: String, val suspendPolicy: String, val backActionsInverted: Boolean, @@ -173,7 +174,8 @@ class DrawerActions( val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, val onHud: (Boolean) -> Unit, - val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, + val onFrameGenPick: (FrameGen.Mode) -> Unit, + val onImportLossless: () -> Unit, /** The Android keyboard (text, turned into key presses). */ val onKeyboard: () -> Unit, /** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */ @@ -345,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen, ) val fgOpen = host.open == "fg" - val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier) - SettingsRow("Frame generation", null, highlighted = fgOpen) { + val fgLabel = FrameGen.label(LocalContext.current, a.frameGen) + SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) { Box { ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" } - FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick) + FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless) } } } 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 973332542..8e9fd35c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -69,7 +69,7 @@ object CompositorHost { // came back stretched with an engine on and right with it off. Disarmed, the window is // rebound and the swapchain rebuilt; the caller re-arms once that has settled, and every // engine-side object is made again for the new size. - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeLog("screen", "surface resized: rebinding the window, frame generation re-armed after") WaylandCompositor.nativeSetSurface(null) attached = surface @@ -85,7 +85,7 @@ object CompositorHost { */ fun rearmFrameGen(rearm: () -> Unit) { if (!started) return - WaylandCompositor.nativeSetFrameGenArmed(false, 0) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeLog("screen", "presenting window changed: frame generation re-armed") android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(rearm, 600) } 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 048ff2ef1..9b7fda731 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -450,10 +450,12 @@ public final class WaylandCompositor { public static native void nativeSetFrameGenEngine(int kind); /** Arm (multiplier 2..4: one real frame plus multiplier-1 interpolated ones per game frame) or - * disarm. Generated frames are presented ahead of the real frame on consecutive vblanks. */ - public static native void nativeSetFrameGenArmed(boolean armed, int multiplier); + * disarm. A targetFps above 0 makes LSFG adaptive instead: it generates 0..3 frames per game + * frame, whatever reaches that rate. Generated frames are presented ahead of the real frame + * on consecutive vblanks. */ + public static native void nativeSetFrameGenArmed(boolean armed, int multiplier, int targetFps); - /** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code LsfgNative.cacheFile}). */ + /** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code Lossless.cacheFile}). */ public static native void nativeSetLsfgCachePath(String path); /** Flow scale (0.25-1.0) and the panel's real refresh rate (the pacer never generates above it). */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10cf334af..cfa4435bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,28 @@ Resume Stop session Frame generation + Select the frame generation mode + Off + Win-FG %1$d× + LSFG %1$d× + LSFG Adaptive %1$d + Needs Lossless Scaling on Steam + Install Lossless Scaling in Steam or import Lossless.dll + Import Lossless.dll… + Using the copy from Steam + Using an imported copy + Choose Lossless.dll + Lossless.dll imported + This Lossless.dll is already in use + Lossless Scaling is not owned on this Steam account + This file is not Lossless Scaling\'s Lossless.dll + Could not prepare LSFG from this Lossless.dll + LSFG needs Lossless Scaling from Steam or an imported Lossless.dll + LSFG could not prepare its shaders from Lossless.dll + %1$s: unsupported + %1$s: engine failed + %1$s: needs Lossless Scaling + %1$s %2$s · starting Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games. Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts. Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk. diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt new file mode 100644 index 000000000..fe3c9773d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/FrameGenTest.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class FrameGenTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun offUntilPicked() { + assertEquals(FrameGen.Mode.OFF, FrameGen.mode(context)) + } + + @Test fun adaptiveTargetIsKept() { + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 90)) + assertEquals(FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 90), FrameGen.mode(context)) + } + + @Test fun fixedMultiplierReplacesATarget() { + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, target = 120)) + FrameGen.set(context, FrameGen.Mode(FrameGen.ENGINE_LSFG, 3)) + assertEquals(FrameGen.Mode(FrameGen.ENGINE_LSFG, 3), FrameGen.mode(context)) + } + + @Test fun onlyLsfgIsAdaptive() { + context.getSharedPreferences("frame_gen", Context.MODE_PRIVATE).edit() + .putString("engine", FrameGen.ENGINE_WINFG).putInt("multiplier", 4).putInt("target", 60).commit() + assertEquals(FrameGen.Mode(FrameGen.ENGINE_WINFG, 4), FrameGen.mode(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt new file mode 100644 index 000000000..158351477 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/LosslessTest.kt @@ -0,0 +1,48 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LosslessTest { + private lateinit var context: Context + private lateinit var flag: File + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("lossless", Context.MODE_PRIVATE).edit().clear().commit() + flag = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned") + flag.delete() + } + + @Test fun nothingIsOwnedOrReadyUntilTheSessionSaysSo() { + assertEquals(Lossless.State.NONE, Lossless.state(context)) + assertNull(Lossless.cacheFile(context)) + } + + @Test fun theSessionsOwnershipFlagIsRememberedForGood() { + flag.parentFile!!.mkdirs() + flag.writeText("") + assertTrue(Lossless.owned(context)) + flag.delete() + assertTrue(Lossless.owned(context)) + } + + @Test fun importIsRefusedWithoutOwnership() { + val dll = File(context.cacheDir, "Lossless.dll").apply { writeText("not a dll") } + assertFalse(Lossless.owned(context)) + assertEquals(Lossless.ImportResult.NOT_OWNED, Lossless.import(context, dll)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index d579dc73b..39370ce73 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -578,6 +578,24 @@ BUSCONF bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & games_watcher=$! fi + # Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in + # client once a session until the answer is yes. From then on the app offers importing + # Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again. + if [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then + ( + owned=$HOME/.config/droiddeck/lossless-owned + for ((_i = 0; _i < 60; _i++)); do + nap 30 + [ -e "$owned" ] && exit 0 + answer=$(bannerlator-steam-games probe "$steam_root" 993090 2>/dev/null) || continue + case $answer in + *'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;; + *) echo "== Lossless Scaling: not owned by this account" ;; + esac + exit 0 + done + ) & + fi echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. From 5251c6a5f6181648e3310fae7849355689202fa5 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Wed, 30 Sep 2026 16:43:52 -0400 Subject: [PATCH 07/28] Additional performance fixes (#87) * perf: answer Turnip's zero-timeout KGSL timestamp polls without blocking Turnip polls a timeline point with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID and a timeout of 0, which KGSL reads as wait forever; Mesa does it under a lock on every signalling submit, so vkd3d-proton, DXVK, gamescope and Zink ran in lock-step with the GPU. The session preload now answers those polls from the context's retired timestamp and passes everything else to the kernel. Bannerlator measured a D3D12 demo at 614 -> 4335 fps with the same answer in its Vulkan adapter. BL_NO_KGSL_POLL_FIX=1 turns it off. * perf: frame limiter, live panel refresh, opt-in GPU clock pin, cheaper cursor sync - A per-mode frame limit (Steam and desktop settings, off by default): gamescope's -r, the compositor's buffer-release pacer, the display layer's frame-rate vote and a panel mode the cap divides evenly, as WinNative's per-shortcut limit does. BL_FPS was always 0 and the pacer's limit was never set. - The compositor follows the panel's refresh rate when it changes mid-session instead of reporting the rate read at start. (WinNative.) - Performance > GPU: hold the Adreno clock at its top through KGSL's power-control property, no root (Bannerlator's PerfGpuTurbo). Off by default; cleared when the session stops and at the next app start if a killed process left it set. - The cursor image is copied across JNI only when its serial changes, not on every pointer move. * perf: stop re-seeding every prefix every 5 s, and run the library helpers only on change - bannerlator-seed-redists stamped its change check from a block that carried the current time, so it never matched: every prefix had ~32 keys appended to system.reg on every beat. The stamp now comes from the names; Proton's prefix template is seeded too (date kept), with WinNative's built-in list until the client has the install scripts. - The library beat runs the seeder every 15 s and steam-compat only when a directory it reads has changed, or once a minute. - The client-core pin reads /proc itself and runs taskset -a only for new processes or ones that grew threads. - The games watcher backs off after a failed ownership probe and does not probe while a game runs. - The Proton launcher runs the newest ARM64 depot by build number, and PROTON_USE_PIPEWIRE defaults to 0. (WinNative.) * perf: Mesa's database shader cache, and proot's tracer above the guest - MESA_DISK_CACHE_DATABASE=1 for the session: the file cache opens a file per lookup and opens and renames one per store, each a proot stop; the database is opened once and used with pread/pwrite. - proot's single tracer thread is raised to nice -6 (under the compositor's -8) two seconds after it starts, so the first guest process it forks keeps nice 0. setpriority() directly, not Process.setThreadPriority, which would also move its cgroup. --- app/src/main/cpp/waylandcomp/src/compositor.c | 7 ++ .../cpp/waylandcomp/src/waylandcomp_jni.c | 35 ++++++++ .../main/java/com/droiddeck/launcher/App.kt | 5 +- .../com/droiddeck/launcher/MainActivity.kt | 9 +- .../com/droiddeck/launcher/SessionActivity.kt | 35 ++++++-- .../droiddeck/launcher/session/GpuClockPin.kt | 70 ++++++++++++++++ .../droiddeck/launcher/session/PerfMode.kt | 20 +++-- .../launcher/session/SessionPrefs.kt | 23 ++++++ .../launcher/session/SessionService.kt | 40 ++++++++- .../launcher/session/SessionState.kt | 4 + .../launcher/ui/ModeSettingsDialog.kt | 9 ++ .../launcher/ui/PerformanceDialog.kt | 9 ++ .../launcher/wayland/CompositorHost.kt | 19 ++++- .../launcher/wayland/WaylandCompositor.java | 11 +++ .../usr/local/bin/bannerlator-seed-redists | 82 +++++++++++++------ .../overlay/usr/local/bin/bannerlator-session | 45 ++++++++-- .../usr/local/bin/bannerlator-steam-compat | 53 ++++++++---- .../usr/local/bin/bannerlator-steam-games | 32 +++++++- tools/linuxfs/preload/kgslpoll.c | 79 ++++++++++++++++++ tools/linuxfs/preload/netif.c | 7 +- 20 files changed, 527 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt create mode 100644 tools/linuxfs/preload/kgslpoll.c diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 8b2509a68..63f0eed8a 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -541,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { wl_shm_buffer_end_access(shm); } +/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below + * copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to + * change the cursor. A torn read is only a missed or an extra snapshot. */ +int banner_cursor_serial(void) { + return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED); +} + /* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...]. * Returns the number of ints written, or 0 if out is too small. */ int banner_cursor_snapshot(int *out, int cap) { diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 10283f570..414ca45b9 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -6,6 +6,7 @@ */ #include #include +#include #include #include #include @@ -25,6 +26,7 @@ extern int banner_wayland_run(void); extern void banner_wayland_send_pointer(int action, int x, int y); extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y); extern int banner_cursor_snapshot(int *out, int cap); +extern int banner_cursor_serial(void); extern void banner_wayland_send_key(int evdev, int state); extern void banner_wayland_send_scene_input(int type, int a, int b); extern void banner_wayland_vsync(int64_t frame_time_ns); @@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( return n; } +/* The serial nativeCursorSnapshot would return, without copying anything. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + return banner_cursor_serial(); +} + /* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( @@ -670,3 +679,29 @@ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) { banner_host_text_delete(before, after); } + +/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the + * device's, not this process's: it outlives the fd and the app, so the caller clears it at stop + * and again at every app start. No root; the kernel's thermal limits still apply. */ +extern void adrenotools_set_turbo(bool turbo); +JNIEXPORT void JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) { + (void)env; (void)clazz; + adrenotools_set_turbo(on == JNI_TRUE); +} + +/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering + * it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between + * scheduling groups by priority, and a guest process must stay where Android put the app. Returns + * the nice value it is left at, or 100 when it could not be read. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) { + (void)env; (void)clazz; + errno = 0; + int before = getpriority(PRIO_PROCESS, (id_t)tid); + if (before == -1 && errno != 0) return 100; + if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice); + errno = 0; + int after = getpriority(PRIO_PROCESS, (id_t)tid); + return after == -1 && errno != 0 ? 100 : after; +} diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 19e13cc88..0a563ed03 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -3,10 +3,12 @@ package com.droiddeck.launcher import android.app.Application import com.droiddeck.launcher.runtime.BwrapSpawner import com.droiddeck.launcher.session.CrashHandler +import com.droiddeck.launcher.session.GpuClockPin /** * 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). + * the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's + * sandboxes are started through (BwrapSpawner). */ class App : Application() { override fun onCreate() { @@ -14,6 +16,7 @@ class App : Application() { CrashHandler.install(this) // Before anything else touches the session prefs: a new install's first read decides it. com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this) + GpuClockPin.clearLeftover(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 a26d2097b..c93a2b388 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -135,6 +135,7 @@ class MainActivity : ComponentActivity() { private var zinkLazy by mutableStateOf(false) private var noXalia by mutableStateOf(true) private var gamescopeRealtime by mutableStateOf(false) + private var gpuClockPin by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) @@ -260,6 +261,7 @@ class MainActivity : ComponentActivity() { private var theme by mutableStateOf("graphite") private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) private var hdrOn by mutableStateOf(false) + private var fpsLimit by mutableStateOf(0) private var hdrReason by mutableStateOf(null) private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) @@ -913,7 +915,7 @@ class MainActivity : ComponentActivity() { ModeSettingsPage( ModeSettings( mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, - hdr = hdrOn, hdrReason = hdrReason, + hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, linuxRows = drivers.linuxRows, linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop, androidRows = drivers.androidRows, androidSelected = drivers.androidSelected, @@ -949,6 +951,7 @@ class MainActivity : ComponentActivity() { onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, + onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps }, onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() }, onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) }, onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, @@ -1023,6 +1026,7 @@ class MainActivity : ComponentActivity() { clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia, gamescopeRealtime = gamescopeRealtime, + gpuClockPin = gpuClockPin, prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, @@ -1031,6 +1035,7 @@ class MainActivity : ComponentActivity() { onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on }, onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on }, onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on }, + onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> @@ -1083,6 +1088,7 @@ class MainActivity : ComponentActivity() { refreshAddedGames() shapeMode = SessionPrefs.shapeMode(this) hdrOn = SessionPrefs.hdr(this, mode) + fpsLimit = SessionPrefs.fpsLimit(this, mode) hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason touchMode = SessionPrefs.touchMode(this) suspendPolicy = SessionPrefs.suspendPolicy(this, mode) @@ -1128,6 +1134,7 @@ class MainActivity : ComponentActivity() { zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) + gpuClockPin = SessionPrefs.gpuClockPin(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9db297df2..49160f70c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -191,7 +191,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val displayListener = object : DisplayManager.DisplayListener { override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays() override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays() - override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays() + override fun onDisplayChanged(displayId: Int) { + followPanelRefresh(displayId) + refreshSecondScreenDisplays() + } + } + + /** + * The panel's rate, followed for the rest of the session. It is read once at start, and a + * panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode + * asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and + * its games paced against a vblank that no longer came. (WinNative 53836ca9.) + */ + private fun followPanelRefresh(displayId: Int) { + if (!CompositorHost.isStarted) return + val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return + if (display.displayId != displayId) return + val hz = display.refreshRate + if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz) } private var isHomeApp by mutableStateOf(false) private var androidApps by mutableStateOf>(emptyList()) @@ -220,9 +237,15 @@ 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: 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)) + // Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap + // divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device + // says why. The cap is the session's: an activity re-created mid-session keeps it. + if (!SessionState.running) { + SessionState.fpsLimit = SessionPrefs.fpsLimit( + this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM), + ) + } + Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit)) goFullscreen() // The device's volume keys change the stream the session plays on (the relay and // PulseAudio are media playback); they are never forwarded to the guest. @@ -785,7 +808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CompositorHost.startOrAttach( holder.surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, - applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), + applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit, ) if (!SessionState.running) SessionEvents.record("compositor.started") // The service owns everything below the compositor. It is started whenever no session is @@ -1291,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { */ private fun syncClientCursor(): Boolean { if (!CompositorHost.isStarted) return false + // Asked on every pointer move; the image is copied only when the client has changed it. + if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf) if (n < 6) return false val serial = cursorBuf[0] diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt new file mode 100644 index 000000000..8e0c7c8a0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt @@ -0,0 +1,70 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.wayland.WaylandCompositor +import java.io.File + +/** + * The GPU held at its top clock for a session, without root: KGSL's power-control property turned + * off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the + * governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch; + * Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat, + * so it is opt-in, and the kernel's thermal limits still apply on top of it. + * + * The property belongs to the device, not to this process, and a killed app cannot unwind it: it + * is cleared when a session stops, and at the next app start when a marker file says this app set + * it and never cleared it - only then, so another app's own setting is not undone. + * (Bannerlator, PerfGpuTurbo.) + */ +object GpuClockPin { + private const val TAG = "GpuClockPin" + private const val KGSL_NODE = "/dev/kgsl-3d0" + + private const val MARKER = "gpu-clock-pinned" + + @Volatile + private var pinned = false + + private val supported: Boolean by lazy { File(KGSL_NODE).exists() } + + /** At a session's start: pins the clock when the user asked for it. The line is for the log. */ + @Synchronized + fun start(context: Context): String { + if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed" + if (!supported) return "gpu clock: no $KGSL_NODE, left governed" + return try { + File(context.filesDir, MARKER).createNewFile() + WaylandCompositor.nativeSetGpuTurbo(true) + pinned = true + "gpu clock: held at its top" + } catch (t: Throwable) { + Log.w(TAG, "pin", t) + "gpu clock: pin failed" + } + } + + /** At a session's end. */ + @Synchronized + fun stop(context: Context) { + if (pinned) clear(context) + } + + /** At the app's start: a process killed with the clock pinned left only the marker behind. */ + @Synchronized + fun clearLeftover(context: Context) { + if (File(context.filesDir, MARKER).exists()) clear(context) + } + + private fun clear(context: Context) { + if (supported) { + try { + WaylandCompositor.nativeSetGpuTurbo(false) + } catch (t: Throwable) { + Log.w(TAG, "clear", t) + } + } + pinned = false + File(context.filesDir, MARKER).delete() + } +} 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 7e99840d5..7a44423a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt @@ -17,7 +17,8 @@ import android.util.Log * 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; + * 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode + * the cap divides evenly, so every capped frame is shown for the same number of refreshes; * - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases * its boost on loading screens or menus keeps it up. * @@ -27,7 +28,7 @@ import android.util.Log object PerfMode { private const val TAG = "PerfMode" - fun apply(a: Activity): String { + fun apply(a: Activity, fpsLimit: Int = 0): String { val parts = ArrayList(2) // The fastest display mode at the panel's current size. @@ -35,10 +36,12 @@ object PerfMode { val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else { val cur = display.mode - val best = display.supportedModes + val sameSize = display.supportedModes .filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight } - .maxByOrNull { it.refreshRate } - if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) { + val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate } + ?: sameSize.maxByOrNull { it.refreshRate } + if (best != null && best.modeId != cur.modeId && + (best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) { a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId } "display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})" } else "display ${cur.refreshRate.toInt()} Hz" @@ -64,4 +67,11 @@ object PerfMode { return parts.joinToString(" · ") } + + /** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */ + fun cadenceFits(hz: Float, fpsLimit: Int): Boolean { + if (fpsLimit <= 0) return true + val ratio = hz / fpsLimit + return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f + } } 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 4a0312cad..5de5ede5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -246,6 +246,13 @@ object SessionPrefs { * plainly exist is the signature - and the fallback is to trace everything instead: slower, * but correct. Max's advice for devices whose kernels "don't work well with it". */ + /** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */ + fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false) + + fun setGpuClockPin(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("gpuClockPin", on).apply() + } + fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false) fun setProotNoSeccomp(context: Context, on: Boolean) { @@ -519,6 +526,22 @@ object SessionPrefs { prefs(context).edit().putBoolean("hdr.$mode", on).apply() } + /** + * The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's + * -r (what the client and its games see as the display's rate), the compositor's buffer release + * pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest + * one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a + * 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit + * does. Applies next session. + */ + fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0) + + fun setFpsLimit(context: Context, mode: String, fps: Int) { + prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply() + } + + val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120") + /** * The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a * fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices. 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 9a5465bd6..88ea6e93e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.input.FakeInputWriter import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.wayland.WaylandCompositor import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -269,6 +270,8 @@ class SessionService : Service() { val sessionDir = openSessionFolder() val sessionLog = File(sessionDir, "session.log") + Log.i(TAG, GpuClockPin.start(this)) + val size = SessionState.outputSize val guest = ArrayList() // The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the @@ -287,7 +290,7 @@ class SessionService : Service() { guest.add("DXVK_HDR=1") Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1") } - guest.add("BL_FPS=0") + guest.add("BL_FPS=" + SessionState.fpsLimit) guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz)) guest.add("BL_LOG=" + sessionLog.path) guest.add("BL_DEBUG_DIR=" + sessionDir.path) @@ -436,6 +439,7 @@ class SessionService : Service() { } sessionPid = pid if (pid > 1) { + raiseTracer(pid, gen) SessionEvents.guestStarted(pid) } else { SessionEvents.record("guest.start_failed", mapOf("pid" to pid)) @@ -528,6 +532,12 @@ class SessionService : Service() { // BL_STEAMDECK; it is the one that builds the command line). if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") + // Mesa's shader cache as one database instead of a file per entry. Every lookup in the + // file cache is an open and every store an open and a rename, each a proot stop (a rename + // two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is + // opened once and read and written with pread/pwrite, which proot never sees. Mesa removes + // the old folder itself once it has gone a week untouched. + guest.add("MESA_DISK_CACHE_DATABASE=1") if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0")) if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) @@ -968,6 +978,31 @@ class SessionService : Service() { } } + /** + * proot is one thread, and every syscall it traps anywhere in the session - a path lookup in + * Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to + * be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6, + * under the compositor's -8. After a moment, not at once: the first guest process is forked by + * proot itself and would inherit the value, and with it everything the session starts. + */ + private fun raiseTracer(pid: Int, gen: Int) { + Thread({ + try { + Thread.sleep(2000) + } catch (e: InterruptedException) { + return@Thread + } + if (gen != sessionGen || sessionPid != pid) return@Thread + val nice = try { + WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE) + } catch (t: Throwable) { + Log.w(TAG, "tracer priority", t) + return@Thread + } + Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)") + }, "tracer-priority").start() + } + private fun stopSession(status: Int) { synchronized(stopLock) { if (!SessionState.running) return @@ -976,6 +1011,7 @@ class SessionService : Service() { val stoppedGen = sessionGen SessionState.stopRequested = false SessionEvents.record("guest.exited", mapOf("status" to status)) + GpuClockPin.stop(this) SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) suspendOperationPending = false launchWatcher?.stopWatching() @@ -1152,6 +1188,8 @@ class SessionService : Service() { companion object { private const val TAG = "SessionService" + /** proot's tracer: above everything in the guest, under the compositor thread's -8. */ + private const val TRACER_NICE = -6 /** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */ private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug" /** Downloads file of KEY=VALUE lines added to the session environment verbatim. */ 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 71245d3fc..e197a6f8b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -95,6 +95,10 @@ object SessionState { @Volatile var refreshHz: Float = 60f + /** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */ + @Volatile + var fpsLimit: Int = 0 + /** The compositor has presented a frame, so the loading panel is behind us for this session. */ @Volatile var firstFrameSeen = false 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 fc41cd81e..3036b11fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -47,6 +47,8 @@ class ModeSettings( val shapeMode: String, val hdr: Boolean, val hdrReason: String?, + /** Frames per second the session is capped at; 0 = none. */ + val fpsLimit: Int = 0, val linuxRows: List, val linuxSelected: String, val androidRows: List, @@ -106,6 +108,7 @@ class ModeSettingsActions( val onCustomResolution: (Pair?) -> Unit = {}, val onShape: (String) -> Unit, val onHdr: (Boolean) -> Unit, + val onFpsLimit: (Int) -> Unit = {}, val onSelectLinux: (String) -> Unit, val onImportLinux: () -> Unit, val onRemoveLinux: (String) -> Unit, @@ -220,6 +223,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.", com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape, ) + ChoiceRow( + host, "fps", "Frame limit", "Applies next session.", + com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit, + note = "Caps the whole session. The screen switches to a rate the cap divides evenly.", + onPick = a.onFpsLimit, + ) if (editCustom) CustomResolutionDialog( initial = custom, onSave = { size -> editCustom = false; a.onCustomResolution(size) }, 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 1083babe2..fb441ef06 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -38,6 +38,7 @@ fun PerformancePage( noGlError: Boolean, noXalia: Boolean, gamescopeRealtime: Boolean, + gpuClockPin: Boolean, prootNoSeccomp: Boolean, guestHostname: String, phantomWarning: String?, @@ -48,6 +49,7 @@ fun PerformancePage( onNoGlError: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, onGamescopeRealtime: (Boolean) -> Unit, + onGpuClockPin: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, @@ -102,6 +104,13 @@ fun PerformancePage( gamescopeRealtime, onChange = onGamescopeRealtime, ) } + SettingsGroup("GPU") { + ToggleRow( + host, "gpuclock", "Hold the GPU at its top clock", + "Adreno only. Fewer hitches from the clock ramping up, at the cost of battery and heat. Released when the session ends.", + gpuClockPin, onChange = onGpuClockPin, + ) + } SettingsGroup("Session fixes") { ToggleRow( host, "sysmem", "Turnip: sysmem rendering", 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 8e9fd35c7..a19bd858a 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -32,7 +32,9 @@ object CompositorHost { outputWidth: Int, outputHeight: Int, refreshHz: Float, + fpsLimit: Int, ): Boolean { + pace(refreshHz, fpsLimit) if (started) { attached = surface WaylandCompositor.nativeSetSurface(surface) @@ -41,10 +43,6 @@ 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, ) @@ -54,6 +52,19 @@ object CompositorHost { return true } + /** + * The session's frame cap, on every attach: the compositor outlives sessions, and the next one + * may have a different cap. The release pacer hands a game one buffer back per capped frame + * (per refresh without a cap), and the zero-copy layer votes for the same cadence. Left at 0 + * the vote is inferred 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.) + */ + private fun pace(refreshHz: Float, fpsLimit: Int) { + WaylandCompositor.nativeSetFpsLimit(fpsLimit) + WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz) + } + /** * The Surface changed size under the compositor (a foldable opening or closing). Android does * not always recreate the Surface for that, and a swapchain built for the old size keeps 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 9b7fda731..71c7172a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -207,6 +207,9 @@ public final class WaylandCompositor { */ public static native int nativeCursorSnapshot(int[] out); + /** The serial {@link #nativeCursorSnapshot} would report, without copying the image. */ + public static native int nativeCursorSerial(); + /** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */ public static final int CURSOR_BUF_INTS = 6 + 256 * 256; @@ -434,6 +437,14 @@ public final class WaylandCompositor { * back to the game, like the X11 IdleNotify pacer, so the game itself slows to the cap. */ public static native void nativeSetFpsLimit(int fps); + /** KGSL power control off (true: the GPU held at its top clock) or back on. Device-wide and + * outlives the process; see {@code GpuClockPin}. A no-op without /dev/kgsl-3d0. */ + public static native void nativeSetGpuTurbo(boolean on); + + /** setpriority() on one of our own processes or threads, only ever raising it (no cgroup move, + * unlike android.os.Process.setThreadPriority). Returns the nice it is left at; 100 = unknown. */ + public static native int nativeRaisePriority(int tid, int nice); + /** Inject the app's X-server input in scene (virtual desktop) pixels. type 2 = move to a,b; * 3 = evdev button a (BTN_LEFT=0x110…) pressed (b=1) or released (b=0); 4 = a wheel steps, * negative = up. */ diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists index 9d2d60fa7..61c8d35a6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists @@ -1,5 +1,6 @@ #!/bin/bash -# Marks Steam's shared redistributables as already run in every Proton prefix. +# Marks Steam's shared redistributables as already run in every Proton prefix, and in the +# template Proton makes new prefixes from. # # A game whose depot lists _CommonRedist makes the client run the real installer inside the # prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves @@ -14,27 +15,15 @@ set -u steam_root=${1:?steam root} redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist" -[ -d "$redist" ] || exit 0 -# Every process an install script would run becomes the registry value the client writes once it -# has run it. Names sit one level inside "Run Process", the key they share a level further in. -block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk -v now="$(date +%s)" ' - function emit(key, name, path) { - path = key - sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) - if (path == key) - return - printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name - # The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node; - # both spellings are seeded because the reader picks by its own word size. - sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path) - printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, name - } +# Every process an install script would run, as the registry key and value the client writes once +# it has run it. Names sit one level inside "Run Process", the key they share a level further in. +pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk ' FNR == 1 { depth = 0; name = ""; key = "" } /^[[:space:]]*\{/ { depth++; next } /^[[:space:]]*\}/ { if (depth == 3 && name != "" && key != "") - emit(key, name) + printf "%s\t%s\n", key, name depth-- next } @@ -44,15 +33,62 @@ block=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | x if (n >= 3 && depth == 2) { name = part[2]; key = "" } } ') -[ -n "$block" ] || exit 0 -stamp=$(printf '%s' "$block" | cksum) +# The client downloads the scripts with the first game that wants them, which is too late for that +# game; until then the set they held when this was written stands in. (WinNative's list.) +[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN' +DirectX\\Jun2010 dxsetup +.NET\\4.8 4.8 +vcredist\\2008 x86 SP1 +vcredist\\2008 x64 SP1 +vcredist\\2010 x86 +vcredist\\2010 x64 +vcredist\\2013 x86 12.0.30501 +vcredist\\2013 x64 12.0.30501 +vcredist\\2015 x86 Update 3 14.0.24215.0 +vcredist\\2015 x64 Update 3 14.0.24215.0 +vcredist\\2017 x86 14.10.25008.0 +vcredist\\2017 x64 14.10.25008.0 +vcredist\\2019 x86 14.28.29334.0 +vcredist\\2019 x64 14.28.29334.0 +vcredist\\2022 x86 14.51.36247.0 +vcredist\\2022 x64 14.51.36247.0 +KNOWN +) +# The stamp is the set of names, taken before the block below dates its keys: stamped from the +# dated block it never matched, and every prefix had the whole block appended again on every beat. +stamp=$(printf '%s' "$pairs" | cksum) -for reg in "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ +block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" ' + { + path = $1 + sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) + if (path == $1) + next + printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2 + # The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node; + # both spellings are seeded because the reader picks by its own word size. + sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path) + printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2 + } +') +[ -n "$block" ] || exit 0 + +# Proton builds a new prefix from its template, and the client runs the install scripts in that +# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a +# changed one as a new template and upgrades every prefix made from the old. +for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ + "$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \ + "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ /mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do [ -f "$reg" ] || continue seeded=$(dirname "$reg")/.bannerlator-redists [ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue - printf '%s\n' "$block" >>"$reg" || continue - printf '%s\n' "$stamp" >"$seeded" - echo "== seeded redist markers into $reg" + dated=$(mktemp) || continue + touch -r "$reg" "$dated" + if printf '%s\n' "$block" >>"$reg"; then + printf '%s\n' "$stamp" >"$seeded" + echo "== seeded redist markers into $reg" + fi + touch -r "$dated" "$reg" + rm -f "$dated" done diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 39370ce73..f3b40af88 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -273,13 +273,24 @@ case $mode in # device it took 5 of 8, leaving out both little cores and the fastest one - which is a # reasonable thing to do while a game is running and the wrong thing when the client is all # there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns - # children continuously and each one inherits Steam's choice, not ours. + # children continuously and each one inherits Steam's choice, not ours. The beat reads /proc + # itself instead of running pgrep, and only runs taskset for a process that is new or has + # grown threads since it was last pinned: every fork is a round of traced syscalls, and a + # beat that forked a taskset per process every 5 s did that for an hour. taskset -a, because + # without it only a process's main thread moves and the renderer's workers stay where they were. if [ -n "${BL_CLIENT_CPUS:-}" ]; then step "client cores: $BL_CLIENT_CPUS" ( - for _ in $(seq 1 720); do - for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do - taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + declare -A _bl_pinned=() + for ((_i = 0; _i < 720; _i++)); do + for _d in /proc/[0-9]*; do + IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue + case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac + _t=("$_d"/task/*) + _p=${_d#/proc/} + [ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue + taskset -a -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + _bl_pinned[$_p]=${#_t[@]} done nap 5 done @@ -420,13 +431,33 @@ case $mode in prev=$app done ) & + # The library beat. Both helpers are whole programs under proot (the compat one a python + # start and a parse of the client's config), and nearly every beat has nothing new for them. + # The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born + # marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it + # reads from has changed since its last run - a new manifest, a new depot or tool, the client + # rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute + # regardless. ( beat=0 - for _ in $(seq 1 720); do + compat_stamp=$HOME/.bl-compat-beat + rm -f "$compat_stamp" + compat_due() { + local d + [ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0 + for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \ + "$steam_root/compatibilitytools.d" /mnt/bannerlator-sd/steamapps; do + [ "$d" -nt "$compat_stamp" ] && return 0 + done + return 1 + } + for ((_i = 0; _i < 720; _i++)); do nap 5 - bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true beat=$((beat + 1)) - if [ "$((beat % 3))" -eq 0 ]; then + [ "$((beat % 3))" -eq 0 ] || continue + bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true + if compat_due; then + : > "$compat_stamp" bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true fi done diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 28bb1f89d..fb70b40e2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -152,6 +152,9 @@ bl_game_cpus() { # container that path never reaches Proton either. (WinNative's winnative-proton-launch.) BL_LIBPATH_SETUP = """ bl_libpath() { + # A Proton built to prefer PipeWire (CachyOS's among the adopted ones) hung its audio thread + # here, where only PulseAudio answers. (WinNative, winnative-proton-launch.) + export PROTON_USE_PIPEWIRE=${PROTON_USE_PIPEWIRE:-0} [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 local kept= dir old_ifs=$IFS IFS=: @@ -246,17 +249,29 @@ if [ -f "$fake" ]; then *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; esac fi +# The newest of the ARM64 depots the client has, by the build number that opens each one's version +# file: taking the first one found ran a stale Experimental over a newer 11.0. (WinNative.) +depot= +best=0 for name in %s; do - depot="$steam_root/steamapps/common/$name" - if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then - bl_components "$depot" - 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[@]}" + candidate="$steam_root/steamapps/common/$name" + [ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue + build=0 + read -r build _ 2>/dev/null < "$candidate/version" + case $build in "" | *[!0-9]*) build=0 ;; esac + if [ -z "$depot" ] || [ "$build" -gt "$best" ]; then + depot=$candidate + best=$build fi done +if [ -n "$depot" ]; then + bl_components "$depot" + 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 echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 exit 1 """ % " ".join('"%s"' % n for n in SOURCES) @@ -568,15 +583,23 @@ def register_default(config_path, apps=(), name=None, protect=()): write_changed(config_path, text) +def depot_build(depot): + """The build number a depot's version file starts with; 0 when there is none.""" + try: + with open(os.path.join(depot, "version"), encoding="utf-8", errors="replace") as f: + first = f.read().split(None, 1) + except OSError: + return 0 + return int(first[0]) if first and first[0].isdigit() else 0 + + def find_source(steam_root): - """The ARM64 Proton depot the client has downloaded, or None.""" + """The newest ARM64 Proton depot the client has downloaded (the one the launcher runs), or None.""" common = os.path.join(steam_root, "steamapps", "common") - for name in SOURCES: - depot = os.path.join(common, name) - # bin-arm64 is what makes it the native build rather than the x86_64 one. - if os.path.isdir(os.path.join(depot, "files", "bin-arm64")): - return depot - return None + # bin-arm64 is what makes it the native build rather than the x86_64 one. + found = [os.path.join(common, name) for name in SOURCES + if os.path.isdir(os.path.join(common, name, "files", "bin-arm64"))] + return max(found, key=depot_build) if found else None def build_tool(tool_dir): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games index 3ab3e9844..1c2add014 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -128,6 +128,22 @@ def probe(steam_root, ids): method(client, 1, C.c_bool, C.c_int)(client, pipe) +def game_running(): + """Whether a Proton game is up: a wineserver is there for as long as any prefix is in use.""" + try: + pids = [p for p in os.listdir("/proc") if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open("/proc/%s/comm" % pid) as f: + if f.read().strip() == "wineserver": + return True + except OSError: + pass + return False + + def watch(steam_root, listing): games = read_json(listing, []) if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: @@ -138,7 +154,10 @@ def watch(steam_root, listing): # 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 + # A failed probe is retried on a doubling delay up to those five minutes rather than on every + # beat (a client that is not logged on fails it for as long as it stays that way), and none + # starts while a game is running: the answer can wait, the game's cores cannot. + probed, next_probe, failures = None, 0, 0 while True: now = time.monotonic() for game in games: @@ -155,7 +174,9 @@ def watch(steam_root, listing): next_lookup = now + 300 status = "waiting for a matching Steam title" key = (dict(candidates), dict(sources)) - if candidates and (key != probed or now >= next_probe): + if candidates and (key != probed or now >= next_probe) and game_running(): + status = last_status + elif candidates and (key != probed or now >= next_probe): probed, next_probe = key, now + 300 try: result = subprocess.run([sys.executable, __file__, "probe", steam_root, @@ -163,8 +184,10 @@ def watch(steam_root, listing): 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 + failures += 1 + next_probe = now + min(300, 30 * 2 ** failures) else: + failures = 0 line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP=")) snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP=")) if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0: @@ -175,7 +198,8 @@ 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 + failures += 1 + next_probe = now + min(300, 30 * 2 ** failures) elif candidates: status = last_status if status != last_status: diff --git a/tools/linuxfs/preload/kgslpoll.c b/tools/linuxfs/preload/kgslpoll.c new file mode 100644 index 000000000..00eb8f01f --- /dev/null +++ b/tools/linuxfs/preload/kgslpoll.c @@ -0,0 +1,79 @@ +/* + * A zero-timeout KGSL timestamp wait answered as the poll it was meant to be. + * + * Turnip's KGSL backend emulates timeline semaphores with timestamps, and asks "has this one + * retired yet?" by waiting on it with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID and a timeout of 0. + * KGSL reads a timeout of 0 as no timeout at all, so the poll blocks until the GPU gets there. + * Mesa's timeline bookkeeping polls like that under a lock on every signalling submit, which put + * vkd3d-proton, DXVK, gamescope and Zink in lock-step with the GPU: one frame queued, never two. + * Found and measured by Bannerlator (PROGRESS_LOG 2026-09-29: a D3D12 demo went from 614 to + * 4335 fps with the same answer given in its Vulkan adapter); the driver-side fix is on + * Banners-Turnip, and this covers the drivers that do not carry it yet. + * + * The answer comes from the context's retired timestamp, compared the way KGSL compares them + * (with wrap-around). Anything the read does not answer - an unknown context, a device that is not + * KGSL - goes to the kernel as before. BL_NO_KGSL_POLL_FIX=1 turns it off. + */ +#define _GNU_SOURCE +#include +#include +#include +#include + +#define KGSL_IOC_TYPE 0x09 +#define KGSL_TIMESTAMP_RETIRED 0x00000002 + +struct kgsl_device_waittimestamp_ctxtid { + unsigned int context_id; + unsigned int timestamp; + unsigned int timeout; +}; + +struct kgsl_cmdstream_readtimestamp_ctxtid { + unsigned int context_id; + unsigned int type; + unsigned int timestamp; +}; + +#define IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID \ + _IOW(KGSL_IOC_TYPE, 0x7, struct kgsl_device_waittimestamp_ctxtid) +#define IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID \ + _IOWR(KGSL_IOC_TYPE, 0x16, struct kgsl_cmdstream_readtimestamp_ctxtid) + +static int enabled(void) { + static int cached = -1; + const char *off; + + if (cached >= 0) return cached; + off = getenv("BL_NO_KGSL_POLL_FIX"); + cached = !(off != NULL && off[0] == '1'); + return cached; +} + +/* True when the call was answered here, with its result in *rc (and errno set on failure). */ +__attribute__((visibility("hidden"))) int bl_kgsl_poll(int (*real)(int, unsigned long, void *), + int fd, unsigned long request, void *arg, + int *rc) { + const struct kgsl_device_waittimestamp_ctxtid *wait = arg; + struct kgsl_cmdstream_readtimestamp_ctxtid read; + int saved = errno; + + if (request != IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID || arg == NULL || wait->timeout != 0 || + !enabled()) + return 0; + read.context_id = wait->context_id; + read.type = KGSL_TIMESTAMP_RETIRED; + read.timestamp = 0; + if (real(fd, IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID, &read) != 0) { + errno = saved; + return 0; + } + if ((int32_t)(read.timestamp - wait->timestamp) >= 0) { + errno = saved; + *rc = 0; + } else { + errno = ETIMEDOUT; + *rc = -1; + } + return 1; +} diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 4b79c3f3f..a835c6cdd 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -349,6 +349,9 @@ static int is_link_query(unsigned long request) { || request == SIOCGIFBRDADDR; } +int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request, + void *arg, int *rc) __attribute__((visibility("hidden"))); + /* Every DRM and evdev call comes through here, so the lookup is done once. Two threads racing * to store it store the same pointer. */ int ioctl(int fd, unsigned long request, ...) { @@ -360,7 +363,9 @@ int ioctl(int fd, unsigned long request, ...) { va_end(ap); if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); - int rc = real(fd, request, arg); + int rc; + if (bl_kgsl_poll(real, fd, request, arg, &rc)) return rc; + rc = real(fd, request, arg); if (arg == NULL || !is_link_query(request)) return rc; int saved = errno; if (answer_ifreq(request, (struct ifreq *) arg, rc)) return 0; From 7e6df1134429afb876ba80f86af5213f64454f11 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:57:49 -0400 Subject: [PATCH 08/28] feat: pick GPU drivers as matched pairs, set automatically for the device's GPU (#103) * feat: pick GPU drivers as matched pairs, set automatically for the device's GPU * DD-Turnip Driver Support (#106) --------- Co-authored-by: MaxsTechReview --- .../main/java/com/droiddeck/launcher/App.kt | 1 + .../com/droiddeck/launcher/ComponentsMenu.kt | 2 +- .../com/droiddeck/launcher/DriverMenus.kt | 312 ++++++++++++++++-- .../com/droiddeck/launcher/MainActivity.kt | 45 ++- .../droiddeck/launcher/core/DeviceReport.kt | 11 +- .../droiddeck/launcher/gpu/DriverBundle.kt | 196 +++++++++++ .../com/droiddeck/launcher/gpu/DriverPairs.kt | 112 +++++++ .../com/droiddeck/launcher/gpu/GpuInfo.kt | 80 +++++ .../gpu/LinuxVulkanDriverManager.java | 32 +- .../droiddeck/launcher/gpu/TurnipDriver.java | 49 ++- .../droiddeck/launcher/gpu/TurnipReleases.kt | 38 ++- .../launcher/session/SessionPrefs.kt | 51 ++- .../launcher/session/SessionService.kt | 15 +- .../droiddeck/launcher/ui/ComponentsPage.kt | 61 +++- .../com/droiddeck/launcher/ui/DriverPage.kt | 1 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 16 +- .../droiddeck/launcher/ui/GpuDriversPanel.kt | 312 ++++++++++++++++++ .../launcher/ui/ModeSettingsDialog.kt | 87 +---- .../launcher/gpu/DriverBundleTest.kt | 133 ++++++++ .../droiddeck/launcher/gpu/DriverPairsTest.kt | 109 ++++++ 20 files changed, 1475 insertions(+), 188 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/DriverBundleTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 0a563ed03..7cc6db7d6 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -16,6 +16,7 @@ class App : Application() { CrashHandler.install(this) // Before anything else touches the session prefs: a new install's first read decides it. com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this) + com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) GpuClockPin.clearLeftover(this) BwrapSpawner.start(this) } diff --git a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt index 3cbb18656..87c3f2790 100644 --- a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt @@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat var compCatalog by mutableStateOf>(emptyList()) var compCatalogAt by mutableStateOf(0L) var compProton by mutableStateOf(null) - var compComp by mutableStateOf("dxvk") + var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB) var compChecking by mutableStateOf(false) var compBusy by mutableStateOf(null) var compDownloads by mutableStateOf>(emptyMap()) diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt index 88c2e8853..ff5ce18fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -10,6 +10,10 @@ import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableIntStateOf +import com.droiddeck.launcher.gpu.DriverBundle +import com.droiddeck.launcher.gpu.DriverPairs +import com.droiddeck.launcher.gpu.GpuInfo import com.droiddeck.launcher.gpu.LinuxVulkanDriver import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager import com.droiddeck.launcher.gpu.TurnipDriver @@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.DriverRow +import com.droiddeck.launcher.ui.GpuDriversState +import com.droiddeck.launcher.ui.PairRow +import com.droiddeck.launcher.wayland.CompositorHost /** - * The Linux and Android driver menus: the drivers each can pick from, the release downloads the - * last check found, and what each mode is set to. The launcher screen keeps one and hands its - * rows to the mode settings dialog. + * The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and + * - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the + * Linux (runtime) and Android (display) lists as before, each picked on its own - except a + * bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher + * screen keeps one and hands it to the Components page's GPU drivers tab. */ internal class DriverMenus(private val activity: Activity, private val ui: Handler) { var linuxRows by mutableStateOf>(emptyList()) @@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl var canRestoreBundled by mutableStateOf(false) /** Asset name -> download percent, while it downloads. */ private val releaseProgress = HashMap() - var linuxSteam by mutableStateOf("") - var linuxDesktop by mutableStateOf("") + var linuxSelected by mutableStateOf("") + val gpu: GpuInfo = GpuInfo.detect() + // Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made. + var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO) + var pairRows by mutableStateOf>(emptyList()) + /** The pair being installed, and how far along. */ + var pairBusy by mutableStateOf(null) + var pairPercent by mutableIntStateOf(-1) + /** What Auto last did or found, for the line under the pair in use. */ + var autoStatus by mutableStateOf("") + /** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */ + var activeBundle by mutableStateOf(null) + private var autoCheckedThisProcess = false + + fun state() = GpuDriversState( + gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText, + supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED, + auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent, + autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking, + linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected, + linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled, + activeBundle = activeBundle, + ) var androidRows by mutableStateOf>(emptyList()) var androidSelected by mutableStateOf("") fun refreshDrivers() { val lm = LinuxVulkanDriverManager(activity) fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED + val bundles = DriverBundle.all(activity) + fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let { + DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE) + } linuxRows = LinuxVulkanDriver.optionValues(activity).map { id -> if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false) - else DriverRow( + else bundleRow(id, linux = true) ?: DriverRow( id, lm.getDriverName(id), listOfNotNull( lm.getDriverVersion(id).takeIf { it.isNotEmpty() }, @@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl true, origin(id), ) } - linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM) - linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP) + linuxSelected = SessionPrefs.linuxDriver(activity) + mode = SessionPrefs.gpuDriverMode(activity) val td = TurnipDriver(activity) val auto = td.autoId() androidRows = buildList { @@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl false, )) for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED)) - for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) + for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id))) } canRestoreBundled = td.hiddenBundled().isNotEmpty() androidSelected = SessionPrefs.androidDriver(activity) + activeBundle = DriverBundle.active(activity)?.label refreshReleaseRows() + refreshPairs() + } + + /** One line for the settings row that opens this: "Auto · WinNative · Balanced". */ + fun summary(): String { + val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle + return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "") + } + + /** The pairs the last check found, with what is installed and in use; this GPU's first. */ + fun refreshPairs() { + val lm = LinuxVulkanDriverManager(activity) + val td = TurnipDriver(activity) + val pairs = DriverPairs.from(TurnipReleases.cached(activity)) + val recommended = DriverPairs.recommendedKey(gpu, pairs) + pairRows = pairs.map { p -> + val bundle = p.bundle?.let { installedBundle(it) } + val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) } + val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) } + val mb = p.assets.sumOf { it.size } / 1_048_576.0 + PairRow( + key = p.key, name = p.name, version = p.version, + detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb), + recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete, + installed = displayId != null && linuxId != null, + active = displayId != null && linuxId != null && + SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId, + ) + }.sortedByDescending { it.recommended } + } + + fun setMode(auto: Boolean) { + mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL + SessionPrefs.setGpuDriverMode(activity, mode) + autoStatus = "" + if (auto) ensureAuto(force = false) + } + + /** + * Auto: make the recommended pair for this GPU the one installed and set. Looks online when + * [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and + * a new pair a day is plenty - otherwise works from the last check. Once per app start on its + * own; the pair it replaces is removed, anything the user imported or picked is left alone. + */ + fun ensureAuto(force: Boolean) { + mode = SessionPrefs.gpuDriverMode(activity) + if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return + if (!force && autoCheckedThisProcess) return + autoCheckedThisProcess = true + if (DriverPairs.recommendedKey(gpu, emptyList()) == null) { + autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}" + return + } + releaseChecking = true + autoStatus = "Checking for the latest drivers…" + Thread({ + val cached = TurnipReleases.cached(activity) + val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L || + cached.assets.none { it.pair.isNotEmpty() } + val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null + ui.post { + releaseChecking = false + refreshReleaseRows() + refreshPairs() + val pairs = DriverPairs.from(TurnipReleases.cached(activity)) + val key = DriverPairs.recommendedKey(gpu, pairs) + val pair = pairs.firstOrNull { it.key == key } + val row = pairRows.firstOrNull { it.key == key } + when { + row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "") + pair == null || !pair.complete -> + autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now" + else -> installPair(pair, auto = true) + } + } + }, "gpu-driver-auto").start() + } + + /** Manual: install (if need be) and set both halves of a pair. */ + fun selectPair(key: String) { + if (pairBusy != null) return + val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return + installPair(pair, auto = false) + } + + /** + * Download what is missing of [pair], then set both halves - only once both are in, so a + * failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the + * ones the new pair replaces are removed. + */ + private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) { + if (!pair.complete) return + pairBusy = pair.key + pairPercent = 0 + if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…" + Thread({ + val lm = LinuxVulkanDriverManager(activity) + val td = TurnipDriver(activity) + var downloaded = false + val result = runCatching { + val zips = pair.assets + val ids = zips.mapIndexed { i, asset -> + installedIdOf(asset) + ?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true } + } + if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids + } + ui.post { + pairBusy = null + pairPercent = -1 + result.onSuccess { (displayId, linuxId) -> + val displayChanged = SessionPrefs.androidDriver(activity) != displayId + SessionPrefs.setAndroidDriver(activity, displayId) + SessionPrefs.setLinuxDriver(activity, linuxId) + val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else "" + if (auto) { + val previous = SessionPrefs.gpuAutoInstalled(activity) + for (id in previous - setOf(displayId, linuxId)) { + val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id)) + when { + bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) } + lm.isInstalled(id) -> lm.removeDriver(id) + else -> td.remove(id) + } + TurnipReleases.forget(activity, id) + } + SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId)) + autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart" + } else { + android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show() + } + }.onFailure { e -> + Log.w(TAG, "driver pair ${pair.key}", e) + val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}" + if (auto) autoStatus = why ?: "Download failed" + else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show() + } + refreshDrivers() + } + }, "gpu-driver-pair").start() + } + + /** The bundle a release bundle was downloaded as, while both its halves are installed. */ + private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? = + TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) } + + /** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */ + private fun installedIdOf(asset: TurnipReleases.Asset): String? = when { + asset.bundle -> installedBundle(asset)?.id + asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled) + else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled) + } + + /** + * One release asset, downloaded, checked and installed through the importer; returns its id, + * a bundle's id for a bundle. + */ + private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String { + var file: File? = null + try { + file = TurnipReleases.download(activity, asset, progress) + val uri = Uri.fromFile(file) + val id = when { + asset.bundle -> DriverBundle.install(activity, uri).id + asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name) + else -> TurnipDriver(activity).installFromZip(uri, asset.name) + } + TurnipReleases.recordDownload(activity, asset, id) + return id + } finally { + file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) } + } } /** * Import off the main thread - a driver zip is a few MB and the glibc check reads the whole - * library - then say what happened. A refusal's message is the user-facing reason. + * library - then say what happened. A refusal's message is the user-facing reason. A bundle, + * from any import, installs both halves and is set as both drivers at once. [linux] null (the + * tab's own Import .zip) takes a single driver to whichever list its libc belongs in. */ - fun importDriver(uri: Uri, linux: Boolean) { + fun importDriver(uri: Uri, linux: Boolean?) { val name = activity.displayNameOf(uri) Thread({ + var bundle: DriverBundle.Bundle? = null val problem = try { - if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name) - else TurnipDriver(activity).installFromZip(uri, name) + when { + DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri) + linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name) + linux == false -> TurnipDriver(activity).installFromZip(uri, name) + else -> try { + TurnipDriver(activity).installFromZip(uri, name) + } catch (display: IllegalArgumentException) { + try { + LinuxVulkanDriverManager(activity).installDriver(uri, name) + } catch (runtime: IllegalArgumentException) { + throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle") + } + } + } null } catch (e: IllegalArgumentException) { e.message @@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl "Import failed: ${e.message}" } ui.post { + val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}" android.widget.Toast.makeText( - activity, problem ?: "Imported ${name ?: "driver"}", - if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT, + activity, problem ?: done, + if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT, ).show() refreshDrivers() } }, "import-driver").start() } + /** + * Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half + * sets the bundle's other half in the other list as well. + */ + fun selectDriver(id: String, linux: Boolean) { + val bundle = DriverBundle.containing(activity, id, linux) + when { + bundle != null -> useBundle(bundle) + linux -> SessionPrefs.setLinuxDriver(activity, id) + else -> SessionPrefs.setAndroidDriver(activity, id) + } + setMode(false) + refreshDrivers() + } + + /** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */ + private fun useBundle(bundle: DriverBundle.Bundle): String { + val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId + DriverBundle.select(activity, bundle) + if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false) + return "Using ${bundle.label} as both the runtime and display driver." + + if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else "" + } + /** * Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a * session never starts on a driver that is gone; a release download is forgotten, so the menu * offers it again. */ fun deleteDriver(id: String, linux: Boolean) { + val bundle = DriverBundle.containing(activity, id, linux) + if (bundle != null) { + DriverBundle.remove(activity, bundle) + TurnipReleases.forget(activity, bundle.id) + android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show() + refreshDrivers() + return + } if (linux) { LinuxVulkanDriverManager(activity).removeDriver(id) - for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { - if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "") - } + if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "") } else { val td = TurnipDriver(activity) if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id) @@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl /** The download entries and the refresh line, from what the last check found. */ fun refreshReleaseRows() { val check = TurnipReleases.cached(activity) - val lm = LinuxVulkanDriverManager(activity) - val td = TurnipDriver(activity) fun rows(linux: Boolean) = check?.assets.orEmpty() - .filter { it.linux == linux } - .filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null } + .filter { it.bundle || it.linux == linux } + .filter { a -> installedIdOf(a) == null } .map { a -> val mb = "%.1f MB".format(a.size / 1_048_576.0) com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name]) @@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl fun checkLatestTurnip() { if (releaseChecking) return releaseChecking = true - releaseStatus = "Checking Banners-Turnip and WinNative…" + releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…" Thread({ val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) { Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed" @@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl ui.post { releaseChecking = false refreshReleaseRows() + refreshPairs() if (problem != null) releaseStatus = "Couldn't check: $problem" } }, "turnip-release-check").start() @@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl releaseProgress[assetName] = 0 refreshReleaseRows() Thread({ - var file: java.io.File? = null val problem = try { - file = TurnipReleases.download(activity, asset) { pct -> - ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } - } - val uri = Uri.fromFile(file) - val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name) - else TurnipDriver(activity).installFromZip(uri, asset.name) - TurnipReleases.recordDownload(activity, asset, id) + installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } } null } catch (e: IllegalArgumentException) { e.message } catch (e: Exception) { Log.w(TAG, "release driver download", e) "Download failed: ${e.message}" - } finally { - file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) } } ui.post { releaseProgress.remove(assetName) + val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu" android.widget.Toast.makeText( - activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu", + activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick", android.widget.Toast.LENGTH_LONG, ).show() refreshDrivers() diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index c93a2b388..8c82ed818 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -160,6 +160,9 @@ 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 pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) } + } private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) } } @@ -657,11 +660,11 @@ class MainActivity : ComponentActivity() { } } - /** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */ + /** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { 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 + val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1 components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size] } @@ -681,6 +684,8 @@ class MainActivity : ComponentActivity() { syncLossless() // Opening the app and coming back from a session both land here. updates.onResume() + // Auto keeps this GPU's recommended driver pair installed and set: once per app start. + drivers.ensureAuto(force = false) // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the // session has just ended). Cheap when nothing is queued. if (!SessionState.running) Thread({ @@ -807,14 +812,16 @@ class MainActivity : ComponentActivity() { ) } - private fun openComponents(focusContent: Boolean = true) { + private fun openComponents(focusContent: Boolean = true, tab: String? = null) { focusComponentsContent = focusContent + if (tab != null) components.compComp = tab settingsMode = null showPerformance = false showProtons = false showMapping = false showComponents = true components.refreshComponents(snapshotFirst = true) + drivers.refreshDrivers() } private fun importComponent(uri: Uri) { @@ -849,6 +856,22 @@ class MainActivity : ComponentActivity() { onRefresh = { components.refreshComponentCatalog() }, onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) }, onBack = { showComponents = false }, + gpu = drivers.state(), + gpuActions = com.droiddeck.launcher.ui.GpuDriversActions( + onAuto = { on -> drivers.setMode(on) }, + onPair = { key -> drivers.selectPair(key) }, + onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() }, + // Picking one driver on its own is Manual: Auto would put its pair back. + onSelectLinux = { id -> drivers.selectDriver(id, linux = true) }, + onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) }, + onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, + onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) }, + onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) }, + onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) }, + onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) }, + onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) }, + onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, + ), ) } @@ -916,9 +939,7 @@ class MainActivity : ComponentActivity() { ModeSettings( mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode, hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit, - linuxRows = drivers.linuxRows, - linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop, - androidRows = drivers.androidRows, androidSelected = drivers.androidSelected, + gpuDrivers = drivers.summary(), touchMode = touchMode, suspendPolicy = suspendPolicy, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, @@ -939,8 +960,6 @@ class MainActivity : ComponentActivity() { addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), addedGamesArt = addedGamesArt, - linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus, - releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled, deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null, deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null, deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent, @@ -951,16 +970,8 @@ class MainActivity : ComponentActivity() { onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size }, onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape }, onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on }, + onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) }, onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps }, - onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() }, - onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) }, - onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) }, - onRefreshReleases = { drivers.checkLatestTurnip() }, - onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) }, - onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, - onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() }, - onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) }, - onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) }, onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t }, onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, 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 84c1d038b..96e495b53 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -113,19 +113,24 @@ object DeviceReport { k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) h("Drivers") + val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect() + k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "") + k("Driver mode", SessionPrefs.gpuDriverMode(context) + + (com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from( + com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: "")) val turnip = TurnipDriver(context) val androidChoice = SessionPrefs.androidDriver(context) k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice) k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim()) k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", ")) val lm = LinuxVulkanDriverManager(context) - for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { - val id = SessionPrefs.linuxDriver(context, m) + run { + val id = SessionPrefs.linuxDriver(context) val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)" else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() + lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } + if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]" - k("Linux driver ($m)", label) + k("Linux driver", label) } k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt new file mode 100644 index 000000000..169682101 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverBundle.kt @@ -0,0 +1,196 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.zip.ZipInputStream + +/** + * An all-in-one driver package: one zip carrying both halves of a matched pair, the display + * (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another, + * indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers): + * + * ``` + * manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}} + * android/ meta.json (AdrenoTools) + libvulkan_freedreno.so + * linux/ meta.json + libvulkan_freedreno.so + * ``` + * + * Installing one puts each half through its own importer, with that importer's checks, and + * remembers the two as one bundle: picking either half sets both, and deleting either deletes both, + * so a bundle is always used whole. Zips of the older kind, one driver each, never reach this. + */ +object DriverBundle { + private const val TAG = "DriverBundle" + private const val PREFS = "driver_bundles" + private const val MANIFEST = "manifest.json" + /** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */ + private const val MAX_BYTES = 512L shl 20 + + class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String) + + /** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */ + class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) { + /** "DD-Turnip 0.1.0". */ + val label: String get() = if (version.isEmpty()) name else "$name $version" + } + + /** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */ + fun parseManifest(json: JSONObject): Manifest? { + val drivers = json.optJSONObject("drivers") ?: return null + val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null + val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null + if (android == linux) return null + return Manifest( + json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"), + json.optString("tag"), android, linux, + ) + } + + private fun cleanPath(path: String): String? { + val p = path.trim().trim('/') + return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } } + } + + /** True when the zip at [uri] is a bundle rather than a single driver. */ + fun isBundle(context: Context, uri: Uri): Boolean = try { + context.contentResolver.openInputStream(uri)?.use { input -> + ZipInputStream(input).use { zip -> + generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST } + ?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null + } + } ?: false + } catch (e: Exception) { + Log.w(TAG, "reading $uri as a bundle", e) + false + } + + /** + * Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes + * the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip + * is not a usable bundle, [IOException] on read failures. + */ + fun install(context: Context, uri: Uri): Bundle { + val work = File(context.cacheDir, "bundle-${System.nanoTime()}") + val td = TurnipDriver(context) + val lm = LinuxVulkanDriverManager(context) + var androidId: String? = null + try { + unzip(context, uri, work) + val manifest = File(work, MANIFEST).takeIf { it.isFile } + ?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() } + ?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver") + val androidDir = File(work, manifest.androidPath) + val linuxDir = File(work, manifest.linuxPath) + + val androidStage = td.newStagingDir() + androidId = try { + androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) } + td.adopt(androidStage, manifest.name) + } catch (e: Exception) { + FileUtils.delete(androidStage) + throw e + } + + val linuxStage = lm.newStagingDir() + val linuxId = try { + val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") } + library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME)) + val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile } + ?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() } + lm.adopt(linuxStage, library?.name, meta, manifest.name) + } catch (e: Exception) { + FileUtils.delete(linuxStage) + throw e + } + + val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" }) + val saved = registry(context) + var id = base + var n = 2 + while (saved.contains(id)) id = "$base-${n++}" + val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId) + saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version) + .put("android", androidId).put("linux", linuxId).toString()).apply() + Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId") + return bundle + } catch (e: Exception) { + androidId?.let { td.remove(it) } + throw e + } finally { + FileUtils.delete(work) + } + } + + private fun unzip(context: Context, uri: Uri, dest: File) { + val root = dest.canonicalFile + var total = 0L + context.contentResolver.openInputStream(uri)?.use { input -> + ZipInputStream(input).use { zip -> + for (entry in generateSequence { zip.nextEntry }) { + if (entry.isDirectory) continue + val out = File(root, entry.name).canonicalFile + if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it") + out.parentFile?.mkdirs() + out.outputStream().use { sink -> + val buf = ByteArray(1 shl 16) + while (true) { + val r = zip.read(buf) + if (r <= 0) break + total += r + if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB") + sink.write(buf, 0, r) + } + } + } + } + } ?: throw IOException("cannot open $uri") + } + + /** Every installed bundle whose two halves are both still there. */ + fun all(context: Context): List { + val td = TurnipDriver(context) + val lm = LinuxVulkanDriverManager(context) + return registry(context).all.mapNotNull { (id, raw) -> + runCatching { + val j = JSONObject(raw as String) + Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux")) + }.getOrNull() + }.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id } + } + + fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id } + + /** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */ + fun containing(context: Context, driverId: String, linux: Boolean): Bundle? = + all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId } + + /** The bundle both drivers are set to right now, if they are one. */ + fun active(context: Context): Bundle? { + val android = SessionPrefs.androidDriver(context) + val linux = SessionPrefs.linuxDriver(context) + return all(context).firstOrNull { it.androidId == android && it.linuxId == linux } + } + + /** Set both halves as the drivers in use. */ + fun select(context: Context, bundle: Bundle) { + SessionPrefs.setAndroidDriver(context, bundle.androidId) + SessionPrefs.setLinuxDriver(context, bundle.linuxId) + } + + /** Delete both halves; a driver still set to either goes back to its default. */ + fun remove(context: Context, bundle: Bundle) { + TurnipDriver(context).remove(bundle.androidId) + LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId) + if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "") + registry(context).edit().remove(bundle.id).apply() + Log.i(TAG, "removed bundle ${bundle.id}") + } + + private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt new file mode 100644 index 000000000..f3b28fe38 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt @@ -0,0 +1,112 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family + +/** + * The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux + * build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a + * patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app + * installs and selects both halves of one pair at once. + * + * - Banners-Turnip publishes each variant as a set in every release (`Turnip-.zip` + * and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the + * newest release that carries both halves. + * - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with + * their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance). + * - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the + * newest bundle, and it can never be mixed. + */ +object DriverPairs { + const val BANNER = "banner" + const val BANNER_A8XX = "banner-a8xx" + const val BANNER_710 = "banner-710-720-test" + const val BANNER_ONEUI = "banner-8g2-oneui" + const val WN_BALANCED = "wn-b" + const val WN_PERFORMANCE = "wn-p" + const val DD_TURNIP = "dd-turnip" + /** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */ + fun ddTurnip(tag: String) = "$DD_TURNIP:$tag" + + class DriverPair( + val key: String, + /** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */ + val name: String, + /** The families the pair is built for; the list only offers it on those unless asked. */ + val families: Set, + val display: TurnipReleases.Asset?, + val linux: TurnipReleases.Asset?, + /** Both halves in one zip, instead of [display] and [linux]. */ + val bundle: TurnipReleases.Asset? = null, + ) { + val complete: Boolean get() = bundle != null || (display != null && linux != null) + /** The zips to download for the pair: the bundle, or the two halves. */ + val assets: List get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null) + /** "r4 · 1.19 + 0.1.2": the build of each half. */ + val version: String + get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") } + fun suits(gpu: GpuInfo) = gpu.family in families || + (gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families)) + } + + private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN) + + private fun describe(key: String): Pair> = when { + key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO + else -> describeFixed(key) + } + + private fun describeFixed(key: String): Pair> = when (key) { + BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX) + BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX) + BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW) + BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX) + WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO + WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO + else -> key to ALL_ADRENO + } + + /** Every pair the last release check found, in the order the list shows them. */ + fun from(check: TurnipReleases.Check?): List { + val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() } + val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE) + return assets.map { it.pair }.distinct() + .sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } } + .map { key -> + val (name, families) = describe(key) + val halves = assets.filter { it.pair == key } + halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) } + // The list keeps the newest release of each half; for Banners-Turnip the two halves + // must come from the same release, so a set missing one half in its newest release + // is offered incomplete rather than mixed with an older one. + val display = halves.firstOrNull { !it.linux } + val linux = halves.firstOrNull { it.linux } + val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag + DriverPair(key, name, families, display, if (sameRelease) linux else null) + } + } + + /** + * The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest + * official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other + * 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip + * release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for + * the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno). + */ + fun recommendedKey(gpu: GpuInfo, pairs: List): String? = when (gpu.family) { + Family.NOT_ADRENO -> null + Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED + Family.A7XX_LOW -> BANNER_710 + Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER + Family.A6XX -> BANNER + // KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one. + Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER + } + + /** The complete DD-Turnip pair with the highest version, or null when none is listed. */ + fun newestDdTurnip(pairs: List): String? = pairs + .filter { it.key.startsWith("$DD_TURNIP:") && it.complete } + .maxByOrNull { p -> + Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured + ?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L + }?.key +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt new file mode 100644 index 000000000..3054f0733 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt @@ -0,0 +1,80 @@ +package com.droiddeck.launcher.gpu + +import android.os.Build +import com.droiddeck.launcher.core.FileUtils +import java.io.File + +/** + * What GPU this is, in the terms the driver lists are sorted by. KGSL names the model + * ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the + * support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610 + * may start, but it is not "supported" just because it is a Qualcomm chip. + */ +data class GpuInfo( + /** "Adreno 740", or the vendor's own name when this is not an Adreno. */ + val name: String, + /** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */ + val model: Int, + val family: Family, + /** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */ + val soc: String, + /** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */ + val oneUi8Gen2: Boolean, +) { + enum class Family(val label: String) { + A8XX("Adreno 8xx"), + /** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */ + A7XX_LOW("Adreno 710/720/722"), + A7XX("Adreno 7xx"), + A6XX("Adreno 6xx"), + /** An Adreno whose model KGSL does not give: treated as the newest family it could be. */ + ADRENO_UNKNOWN("Adreno"), + NOT_ADRENO("Not an Adreno GPU"), + } + + enum class Support { TESTED, UNTESTED, UNSUPPORTED } + + val support: Support + get() = when { + family == Family.NOT_ADRENO -> Support.UNSUPPORTED + family == Family.A8XX -> Support.TESTED + family == Family.A7XX && model >= 725 -> Support.TESTED + else -> Support.UNTESTED + } + + /** One line for the device card and the system check. */ + val supportText: String + get() = when (support) { + Support.TESTED -> "Supported" + Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds" + else "Below tested hardware (Adreno 725 and newer): it may not run" + Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU" + } + + companion object { + fun detect(): GpuInfo { + val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists() + val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid") + .firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) } + val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else "" + val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0 + val family = familyOf(adreno, model) + val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true) + return GpuInfo( + name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno", + model = model, family = family, soc = soc, + oneUi8Gen2 = samsung && model == 740, + ) + } + + internal fun familyOf(adreno: Boolean, model: Int): Family = when { + !adreno -> Family.NOT_ADRENO + model == 0 -> Family.ADRENO_UNKNOWN + model >= 800 -> Family.A8XX + model in listOf(710, 720, 722) -> Family.A7XX_LOW + model >= 700 -> Family.A7XX + model >= 600 -> Family.A6XX + else -> Family.ADRENO_UNKNOWN + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java index 077e230cd..c0cb48f08 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriverManager.java @@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager { * Linux driver; {@link IOException} on read/extract failures. */ public String installDriver(Uri zipUri, String displayName) throws IOException { - File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis()); - FileUtils.delete(tmpDir); - if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + File tmpDir = newStagingDir(); boolean keep = false; try { String soName = null; @@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager { } } // The zip's own freedreno_icd.aarch64.json is dropped on purpose: its - // library_path is relative to itself, and we write an absolute one below. + // library_path is relative to itself, and adopt() writes an absolute one. } } + String id = adopt(tmpDir, soName, zipMeta, displayName); + keep = true; + return id; + } finally { + if (!keep) FileUtils.delete(tmpDir); + } + } + + /** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */ + File newStagingDir() throws IOException { + File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime()); + FileUtils.delete(tmpDir); + if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + return tmpDir; + } + + /** + * Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in + * the zip), write its manifest and meta.json, and move it in. Returns the id; the caller + * deletes {@code tmpDir} when this throws. + */ + String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException { + try { if (soName == null) { throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android " + "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver."); @@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager { if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json"); if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir); - keep = true; Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir); return id; } catch (org.json.JSONException e) { throw new IOException("manifest write failed: " + e.getMessage()); - } finally { - if (!keep) FileUtils.delete(tmpDir); } } diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java index 8d8863961..52d8129ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java @@ -179,10 +179,7 @@ public final class TurnipDriver { * {@link IOException} on read/extract failures. */ public String installFromZip(Uri zipUri, String displayName) throws IOException { - if (!contentDir.exists()) contentDir.mkdirs(); - File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis()); - FileUtils.delete(tmpDir); - if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + File tmpDir = newStagingDir(); boolean keep = false; try { try (InputStream is = context.getContentResolver().openInputStream(zipUri); @@ -198,26 +195,44 @@ public final class TurnipDriver { Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING); } } - String rejected = rejectionReason(tmpDir); - if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected); - - JSONObject meta = readMetaFile(new File(tmpDir, "meta.json")); - String name = meta != null ? meta.optString("name", "") : ""; - if (name.isEmpty()) { - name = displayName != null ? displayName : "adrenotools-driver"; - if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4); - } - String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name)); - File dir = new File(contentDir, id); - if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir); + String id = adopt(tmpDir, displayName); keep = true; - Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir); return id; } finally { if (!keep) FileUtils.delete(tmpDir); } } + /** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */ + File newStagingDir() throws IOException { + if (!contentDir.exists()) contentDir.mkdirs(); + File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime()); + FileUtils.delete(tmpDir); + if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir); + return tmpDir; + } + + /** + * Check a staged driver (meta.json and its library, flat) and move it in as a new driver. + * Returns the id; the caller deletes {@code tmpDir} when this throws. + */ + String adopt(File tmpDir, String displayName) throws IOException { + String rejected = rejectionReason(tmpDir); + if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected); + + JSONObject meta = readMetaFile(new File(tmpDir, "meta.json")); + String name = meta != null ? meta.optString("name", "") : ""; + if (name.isEmpty()) { + name = displayName != null ? displayName : "adrenotools-driver"; + if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4); + } + String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name)); + File dir = new File(contentDir, id); + if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir); + Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir); + return id; + } + private static JSONObject readMetaFile(File metaFile) { try { return new JSONObject(FileUtils.readString(metaFile)); diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index 14e7e81e6..9a6235f1e 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -29,12 +29,20 @@ object TurnipReleases { /** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */ /** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */ + /** + * [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux + * build of the same pair are made to run together. "" for a list cached before pairs were kept. + */ + /** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */ class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String, - val sha256: String = "") + val sha256: String = "", val pair: String = "", val bundle: Boolean = false) /** [latest] = each source's newest release tag, for the refresh line. */ class Check(val assets: List, val latest: List>, val failed: List, val checkedAt: Long) - private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair?) + /** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */ + private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false) + + private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?) private val SOURCES = listOf( // Turnip-[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have. @@ -45,12 +53,14 @@ object TurnipReleases { if (variant.endsWith("-Wayland")) return@Source null val linux = variant.endsWith("-Linux") variant = variant.removeSuffix("-Linux") - linux to when { + val label = when { variant.isEmpty() -> "Adreno 6xx/7xx" variant == "-A8xx" -> "Adreno 8xx" variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else "" + variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI" else -> variant.trimStart('-') } + Kind(linux, label, "banner" + variant.lowercase()) }, // WN-Turnip--_Axxx.zip (Android) and WN-Linux-Turnip--_Axxx.zip (Linux): // b = Balanced, p = Performance, Axxx = every Adreno. @@ -58,7 +68,13 @@ object TurnipReleases { val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] } val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3] - (m.groupValues[1].isNotEmpty()) to "$gpus · $flavour" + Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2]) + }, + // DD-Turnip-v.zip: DroidDeck's own all-in-one build, both halves in one zip, published + // to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair. + Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag -> + if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null + Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true) }, ) @@ -91,14 +107,19 @@ object TurnipReleases { for (j in 0 until list.length()) { val a = list.getJSONObject(j) val name = a.getString("name") - val (linux, label) = src.classify(name, tag) ?: continue + val kind = src.classify(name, tag) ?: continue + // A bundle is only offered from an official release, never a pre-release. + if (kind.bundle && r.optBoolean("prerelease")) continue + val linux = kind.linux + val label = kind.label // Only assets GitHub has a sha256 for are offered: the download is checked against it. val sha = Hashes.githubSha256(a.optString("digest")) ?: continue // Newest first: the first release carrying a variant is the one offered. - if (!seen.add("$linux|$label")) continue + if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name) .put("url", a.getString("browser_download_url")).put("size", a.optLong("size")) - .put("linux", linux).put("label", label).put("sha256", sha)) + .put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair) + .put("bundle", kind.bundle)) } } if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest)) @@ -136,7 +157,8 @@ object TurnipReleases { for (i in 0 until list.length()) { val a = list.getJSONObject(i) out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"), - a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""))) + a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""), + a.optBoolean("bundle", false))) } val latest = ArrayList>() val l = json.optJSONArray("latest") ?: JSONArray() 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 5de5ede5a..515393370 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -112,15 +112,52 @@ object SessionPrefs { } /** - * The imported glibc Turnip a mode draws with inside the runtime, keyed by - * SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the - * driver built into the runtime. Resolved by LinuxVulkanDriver at session start. + * The imported glibc Turnip every session draws with inside the runtime - Steam, its games and + * the desktop alike, as they run on the same GPU; "" = the driver built into the runtime. + * Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam + * session's choice, the one nearly everyone set, carries over. */ - fun linuxDriver(context: Context, mode: String): String = - prefs(context).getString("linuxDriver.$mode", "") ?: "" + fun linuxDriver(context: Context): String = + prefs(context).getString("linuxDriver", null) + ?: prefs(context).getString("linuxDriver.steam", null) + ?: prefs(context).getString("linuxDriver.desktop", "") ?: "" - fun setLinuxDriver(context: Context, mode: String, id: String) { - prefs(context).edit().putString("linuxDriver.$mode", id).apply() + fun setLinuxDriver(context: Context, id: String) { + prefs(context).edit().putString("linuxDriver", id).apply() + } + + const val GPU_DRIVERS_AUTO = "auto" + const val GPU_DRIVERS_MANUAL = "manual" + + /** + * Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for + * this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new + * installs; see settleGpuDriverMode. + */ + fun gpuDriverMode(context: Context): String = + prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO + + fun setGpuDriverMode(context: Context, mode: String) { + prefs(context).edit().putString("gpuDriverMode", mode).apply() + } + + /** + * Auto arrived after people had picked drivers by hand: an install that chose either driver + * keeps its choice (Manual), everyone else is Auto. Decided once, at process start. + */ + fun settleGpuDriverMode(context: Context) { + val p = prefs(context) + if (p.contains("gpuDriverMode")) return + val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty() + p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply() + } + + /** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */ + fun gpuAutoInstalled(context: Context): Set = + prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty() + + fun setGpuAutoInstalled(context: Context, ids: Set) { + prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).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 88ea6e93e..5abd06c2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -503,15 +503,12 @@ class SessionService : Service() { guest.add("GALLIUM_DRIVER=zink") guest.add("LIBGL_KOPPER_DRI2=true") LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) } - // An imported glibc Turnip for this mode, when the user chose one: the session script checks - // the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES, - // so the runtime's own driver above stays untouched and is what a bad import falls back to. - // A program from the rail is one of the desktop's emulators, so it draws with the desktop's - // Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver - // build, and with two drivers every emulator compiled its shaders twice - once per way of - // starting it (RPCS3's 6650 interpreter variants on each first boot). - val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode) - val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode) + // An imported glibc Turnip, when one is set (by the user, or by Auto): the session script + // checks the manifest and its library from inside and points the loader at it with + // VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad + // import falls back to. One driver for every session: the driver's shader cache is keyed on + // its build, and with one per mode every emulator compiled its shaders twice. + val linuxDriverId = SessionPrefs.linuxDriver(this) LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } // Turnip's own debug switches, for the runtime's driver and everything on it. The file in 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 566f66b4e..67fc47be7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -102,6 +102,9 @@ fun ComponentsPage( onImport: () -> Unit, onBack: () -> Unit, requestInitialFocus: Boolean = true, + /** The GPU drivers tab ([GPU_TAB]): what it shows and does. */ + gpu: GpuDriversState = GpuDriversState(), + gpuActions: GpuDriversActions = GpuDriversActions(), ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -112,23 +115,55 @@ fun ComponentsPage( var about by remember { mutableStateOf(false) } var protonMenu by remember { mutableStateOf(false) } fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action } + // The GPU drivers tab's Advanced pages: one driver list on its own, full page. + var driverPage by remember { mutableStateOf(null) } + val gpuTab = comp == GPU_TAB + when (driverPage) { + "rt" -> { + DriverPage( + title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.", + rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false, + onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null }, + ) + return + } + "panel" -> { + DriverPage( + title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.", + rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…", + canRestore = gpu.canRestoreBundled, + onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled, + onBack = { driverPage = null }, + ) + return + } + } val views = snapshot?.protons ?: emptyList() val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() val label = ComponentsManager.LABEL[comp] ?: comp val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never" - val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked" + val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked" Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { // ---- title, and the one thing that goes online ----------------------------------------- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { Text( "Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 1, modifier = Modifier.weight(1f), + maxLines = 1, ) - if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - ToolIcon(Icons.Outlined.Info, "About Components") { about = true } - ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh) + // The status gives way to the title, not the other way round. + if (!narrow) Text( + nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f), + ) else Spacer(Modifier.weight(1f)) + if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true } + if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh) + else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh) } if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -138,7 +173,7 @@ fun ComponentsPage( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp), ) { - if (view != null) Box { + if (view != null && !gpuTab) Box { ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu } AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton", note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first -> @@ -152,11 +187,12 @@ fun ComponentsPage( } } } - TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) }) + val tabs = listOf(GPU_TAB) + comps + TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) }) } // ---- what is in use ----------------------------------------------------------------------- - if (view != null) { + if (view != null && !gpuTab) { val st = view.components.getValue(comp) val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else "" Row( @@ -179,6 +215,12 @@ fun ComponentsPage( // ---- the lists ------------------------------------------------------------------------------ when { + gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { + GpuDriversPanel(gpu, gpuActions) { driverPage = it } + Row(modifier = Modifier.padding(top = 14.dp)) { + SmallButton("Import .zip", onClick = gpuActions.onImportZip) + } + } snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(12.dp)) @@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } + +/** The Components page's tab for the GPU drivers, before the Proton components' own. */ +const val GPU_TAB = "gpu" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index 6a8809088..7b6722ccd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -141,6 +141,7 @@ fun DriverPage( text = { Text( if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it." + else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default." else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "", fontSize = 13.sp, ) 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 8a3dee136..697e04b3c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -153,7 +153,9 @@ internal fun SetupPanel( } val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val gpuOk = remember { DeviceSupport.adreno() } + // Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support. + val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() } + val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED val gpuName = remember { DeviceSupport.gpuName() } val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) val signedIn = s.offlineAccount != null @@ -195,8 +197,16 @@ internal fun SetupPanel( Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { CheckRow( if (gpuOk) CheckState.OK else CheckState.WARN, - if (gpuOk) "Device supported" else "GPU not supported", - if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen", + when (gpu.support) { + com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported" + com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU" + else -> "GPU not supported" + }, + when (gpu.support) { + com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName" + com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}" + else -> "Steam draws with an Adreno driver; $gpuName may show a black screen" + }, ) CheckRow( when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt new file mode 100644 index 000000000..bedab4c2b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -0,0 +1,312 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxWidth +import androidx.compose.foundation.layout.height +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +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.getValue +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.vector.ImageVector +import androidx.compose.ui.semantics.Role +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 + +/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */ +class PairRow( + val key: String, + val name: String, + val version: String, + val detail: String, + val recommended: Boolean, + /** Built for this GPU's family; the others only show when asked. */ + val suits: Boolean, + val complete: Boolean, + val installed: Boolean, + val active: Boolean, +) + +class GpuDriversState( + val gpuName: String = "", + val gpuFamily: String = "", + val soc: String = "", + val supportText: String = "", + val supported: Boolean = true, + val unsupported: Boolean = false, + val auto: Boolean = true, + val pairs: List = emptyList(), + /** The pair key being installed, and how far along (-1 = unknown). */ + val busy: String? = null, + val percent: Int = -1, + val autoStatus: String = "", + val releaseStatus: String = "", + val checking: Boolean = false, + // Advanced: each driver on its own. + val linuxRows: List = emptyList(), + val linuxSelected: String = "", + val androidRows: List = emptyList(), + val androidSelected: String = "", + val linuxDownloads: List = emptyList(), + val androidDownloads: List = emptyList(), + val canRestoreBundled: Boolean = false, + /** The Android + Linux bundle both drivers are set to ("DD-Turnip 0.1.0"), or null. */ + val activeBundle: String? = null, +) { + /** The pair both drivers are set to, if they make one. */ + val activePair: PairRow? get() = pairs.firstOrNull { it.active } +} + +class GpuDriversActions( + val onAuto: (Boolean) -> Unit = {}, + val onPair: (String) -> Unit = {}, + val onRefresh: () -> Unit = {}, + val onSelectLinux: (String) -> Unit = {}, + val onSelectAndroid: (String) -> Unit = {}, + val onRemoveLinux: (String) -> Unit = {}, + val onRemoveAndroid: (String) -> Unit = {}, + val onDownloadDriver: (String) -> Unit = {}, + val onImportLinux: () -> Unit = {}, + val onImportAndroid: () -> Unit = {}, + /** The tab's own import: a bundle, or a single driver of either kind. */ + val onImportZip: () -> Unit = {}, + val onRestoreBundled: () -> Unit = {}, +) + +/** + * The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the + * games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps + * the pair recommended for the GPU installed and current; Manual picks a pair from the list; the + * two lists each on their own, and imports, are under Advanced. + */ +@Composable +internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var showAll by rememberSaveable { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + DeviceCard(s) + if (!s.unsupported) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) } + ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) } + } + if (s.auto) AutoCard(s) + else { + val shown = s.pairs.filter { it.suits || showAll } + if (shown.isEmpty()) Text( + if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp), + ) + for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) } + if (s.pairs.any { !it.suits }) FocusText( + if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal, + ) { showAll = !showAll } + } + } + SettingsGroup("Advanced") { + val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." } + SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") { + DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") } + } + SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") { + DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") } + } + } + } +} + +/** + * One driver list as a drop-down: the installed drivers to pick from, and the full page for + * downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both. + */ +@Composable +private fun DriverDropdown(rows: List, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + var open by remember { mutableStateOf(false) } + Box { + SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true } + DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) { + for (row in rows) DropdownMenuItem( + leadingIcon = { Radio(row.id == selected) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant) + } + if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, + onClick = { open = false; if (row.id != selected) onSelect(row.id) }, + ) + HorizontalDivider(color = pal.line) + DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() }) + } + } +} + +@Composable +private fun DeviceCard(s: GpuDriversState) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val tint = when { + s.unsupported -> pal.error + s.supported -> pal.good + else -> AttentionAmber + } + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) { + Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + } + Column(Modifier.weight(1f)) { + Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) + Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2) + } + } +} + +/** What Auto has set and what it is doing. */ +@Composable +private fun AutoCard(s: GpuDriversState) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val active = s.activePair + val recommended = s.pairs.firstOrNull { it.recommended } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), + ) { + Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) + Text( + active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck", + fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, + ) + Text( + if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair." + else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.", + fontSize = 13.sp, color = colors.onSurfaceVariant, + ) + if (s.busy != null) { + LinearProgressIndicator( + progress = { (s.percent.coerceAtLeast(0)) / 100f }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape), + ) + } + if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp)) + } +} + +@Composable +private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp) + .clip(Shape14).background(colors.surface).background(fill) + .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Column(Modifier.weight(1f)) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Radio(selected) + } +} + +@Composable +private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val usable = enabled && p.complete && !p.active + val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp) + .clip(Shape12).background(colors.surface).background(fill) + .border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12) + .hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick) + .controllerConfirm(enabled = usable, onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Radio(p.active) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (p.recommended) Text( + "RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good, + modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + when { + busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { + Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant) + LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } + p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal) + !p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant) + p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + } + } +} + +@Composable +private fun Radio(selected: Boolean) { + val pal = LocalPalette.current + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) { + if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal)) + } +} 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 3036b11fa..d9f26e6b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -33,6 +33,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova const val BUNDLED = "BUNDLED" const val DOWNLOADED = "DOWNLOADED" const val IMPORTED = "IMPORTED" + /** One half of an Android + Linux bundle: picked and deleted together with the other. */ + const val BUNDLE = "ANDROID + LINUX" } } @@ -47,12 +49,10 @@ class ModeSettings( val shapeMode: String, val hdr: Boolean, val hdrReason: String?, + /** The GPU drivers in use, as the row that opens them on the Components page says it. */ + val gpuDrivers: String = "Auto", /** Frames per second the session is capped at; 0 = none. */ val fpsLimit: Int = 0, - val linuxRows: List, - val linuxSelected: String, - val androidRows: List, - val androidSelected: String, val touchMode: String, val suspendPolicy: String, /** Steam only. */ @@ -83,12 +83,6 @@ class ModeSettings( val addedGames: List = emptyList(), val addedGamesArt: Boolean = true, /** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */ - val linuxDownloads: List = emptyList(), - val androidDownloads: List = emptyList(), - val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers", - val releaseChecking: Boolean = false, - /** A bundled display driver was deleted: the page offers to restore it. */ - val canRestoreBundled: Boolean = false, /** Steam only: Decky Loader is managed from the Steam session settings. */ val deckyInstalled: String? = null, val deckyLatestRelease: DeckyManager.Release? = null, @@ -108,17 +102,9 @@ class ModeSettingsActions( val onCustomResolution: (Pair?) -> Unit = {}, val onShape: (String) -> Unit, val onHdr: (Boolean) -> Unit, + /** Opens the GPU drivers on the Components page: they are shared by every session. */ + val onGpuDrivers: () -> Unit = {}, val onFpsLimit: (Int) -> Unit = {}, - val onSelectLinux: (String) -> Unit, - val onImportLinux: () -> Unit, - val onRemoveLinux: (String) -> Unit, - val onRefreshReleases: () -> Unit = {}, - /** Asset name of the release driver to download. */ - val onDownloadDriver: (String) -> Unit = {}, - val onRestoreBundled: () -> Unit = {}, - val onSelectAndroid: (String) -> Unit, - val onImportAndroid: () -> Unit, - val onRemoveAndroid: (String) -> Unit, val onTouch: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onOsc: (String) -> Unit, @@ -153,51 +139,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { val steam = s.mode == SessionService.MODE_STEAM val host = rememberMenuHost() var confirmDeckyRemoval by remember { mutableStateOf(false) } - // The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display). - // Coming back restores this page as it was left: the same scroll position, and controller focus - // on the driver box that opened the page. - var driverPage by remember { mutableStateOf(null) } - var returnTo by remember { mutableStateOf(null) } val pageScroll = androidx.compose.foundation.rememberScrollState() - val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() } - val displayChip = remember { androidx.compose.ui.focus.FocusRequester() } val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } - fun openDriverPage(key: String) { returnTo = key; driverPage = key } - androidx.compose.runtime.LaunchedEffect(driverPage) { - if (driverPage == null) { - // One frame first: the box has to be laid out before it can take focus. Opened from the - // cog, focus starts on the first control (Resolution) so the d-pad works at once; back - // from a driver page, it returns to the driver box that opened it. - androidx.compose.runtime.withFrameNanos { } - val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip } - runCatching { target.requestFocus() } - } - } - when (driverPage) { - "rt" -> { - DriverPage( - title = "Runtime driver", - hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.", - rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads, - status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false, - onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases, - onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null }, - ) - return - } - "panel" -> { - DriverPage( - title = "Display driver", - hint = "Used by the compositor in both modes. Restart the app to apply.", - rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads, - status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…", - canRestore = s.canRestoreBundled, - onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases, - onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled, - onBack = { driverPage = null }, - ) - return - } + androidx.compose.runtime.LaunchedEffect(Unit) { + // One frame first: the box has to be laid out before it can take focus. Opened from the + // cog, focus starts on the first control (Resolution) so the d-pad works at once. + androidx.compose.runtime.withFrameNanos { } + runCatching { firstChip.requestFocus() } } SettingsPage( host, @@ -244,17 +192,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } SettingsGroup("Drivers") { - SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") { - ValueChip( - s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false, - modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip), - ) { openDriverPage("rt") } - } - SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") { - ValueChip( - s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false, - modifier = androidx.compose.ui.Modifier.focusRequester(displayChip), - ) { openDriverPage("panel") } + SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") { + ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() } } } SettingsGroup(if (steam) "Touch & controls" else "Touch") { diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/DriverBundleTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/DriverBundleTest.kt new file mode 100644 index 000000000..434e87d09 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/DriverBundleTest.kt @@ -0,0 +1,133 @@ +package com.droiddeck.launcher.gpu + +import android.content.Context +import android.net.Uri +import com.droiddeck.launcher.session.SessionPrefs +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class DriverBundleTest { + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + File(context.filesDir, "graphics_driver").deleteRecursively() + File(context.filesDir, LinuxVulkanDriverManager.DIR_NAME).deleteRecursively() + context.getSharedPreferences("driver_bundles", Context.MODE_PRIVATE).edit().clear().commit() + SessionPrefs.setAndroidDriver(context, "") + SessionPrefs.setLinuxDriver(context, "") + } + + /** Enough of an AArch64 ELF for the importers: the header they read, then the libc they look for. */ + private fun library(libc: String) = ByteArray(64).also { + byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1).copyInto(it) + it[18] = 0xB7.toByte() + } + libc.toByteArray() + ByteArray(1) + + private val manifest = JSONObject() + .put("package", "DD-Turnip").put("version", "0.1.0").put("tag", "DD-Turnip-v0.1.0") + .put("drivers", JSONObject() + .put("android", JSONObject().put("path", "android")) + .put("linux", JSONObject().put("path", "linux"))) + + private fun zip(entries: Map): Uri { + val file = File.createTempFile("bundle", ".zip", context.cacheDir) + ZipOutputStream(file.outputStream()).use { z -> + for ((name, data) in entries) { z.putNextEntry(ZipEntry(name)); z.write(data); z.closeEntry() } + } + return Uri.fromFile(file) + } + + private fun bundle(linuxLibc: String = "libc.so.6", extra: Map = emptyMap()) = zip(mapOf( + "android/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("libraryName", "libvulkan_freedreno.so").toString().toByteArray(), + "android/libvulkan_freedreno.so" to library("libc.so"), + "linux/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("driverVersion", "DD-Turnip-v0.1.0").toString().toByteArray(), + "linux/libvulkan_freedreno.so" to library(linuxLibc), + "manifest.json" to manifest.toString().toByteArray(), + ) + extra) + + @Test fun manifestNeedsBothHalvesInSeparateSafeDirectories() { + val m = DriverBundle.parseManifest(manifest)!! + assertEquals("android", m.androidPath) + assertEquals("linux", m.linuxPath) + assertNull(DriverBundle.parseManifest(JSONObject().put("drivers", JSONObject().put("android", JSONObject().put("path", "a"))))) + val same = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "android/") } + assertNull(DriverBundle.parseManifest(same)) + val escape = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "../x") } + assertNull(DriverBundle.parseManifest(escape)) + } + + @Test fun singleDriverZipsAreNotBundles() { + val legacy = zip(mapOf("meta.json" to "{}".toByteArray(), "libvulkan_freedreno.so" to library("libc.so"))) + assertFalse(DriverBundle.isBundle(context, legacy)) + assertTrue(DriverBundle.isBundle(context, bundle())) + } + + @Test fun installsBothHalvesAndPicksAndDeletesThemTogether() { + val b = DriverBundle.install(context, bundle()) + assertEquals("DD-Turnip-v0.1.0", b.id) + assertEquals("DD-Turnip 0.1.0", b.label) + assertTrue(TurnipDriver(context).isInstalled(b.androidId)) + assertTrue(LinuxVulkanDriverManager(context).isInstalled(b.linuxId)) + assertEquals(b.id, DriverBundle.containing(context, b.linuxId, linux = true)?.id) + assertEquals(b.id, DriverBundle.containing(context, b.androidId, linux = false)?.id) + + assertNull(DriverBundle.active(context)) + DriverBundle.select(context, b) + assertEquals(b.androidId, SessionPrefs.androidDriver(context)) + assertEquals(b.linuxId, SessionPrefs.linuxDriver(context)) + assertNotNull(DriverBundle.active(context)) + + DriverBundle.remove(context, b) + assertFalse(TurnipDriver(context).isInstalled(b.androidId)) + assertFalse(LinuxVulkanDriverManager(context).isInstalled(b.linuxId)) + assertEquals("", SessionPrefs.androidDriver(context)) + assertEquals("", SessionPrefs.linuxDriver(context)) + assertTrue(DriverBundle.all(context).isEmpty()) + } + + @Test fun aRefusedHalfLeavesNothingInstalled() { + try { + DriverBundle.install(context, bundle(linuxLibc = "libc.so")) + fail("a bionic library in linux/ must be refused") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("glibc")) + } + assertTrue(TurnipDriver(context).enumerateImported().isEmpty()) + assertTrue(LinuxVulkanDriverManager(context).enumerateInstalledDrivers().isEmpty()) + } + + @Test fun entriesOutsideTheBundleAreRefused() { + try { + DriverBundle.install(context, bundle(extra = mapOf("../escape.so" to ByteArray(4)))) + fail("a zip-slip entry must be refused") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("outside")) + } + assertFalse(File(context.cacheDir.parentFile, "escape.so").exists()) + } + + @Test fun twoInstallsOfOneReleaseGetTheirOwnIds() { + val first = DriverBundle.install(context, bundle()) + val second = DriverBundle.install(context, bundle()) + assertEquals("DD-Turnip-v0.1.0-2", second.id) + assertEquals(2, DriverBundle.all(context).size) + assertFalse(first.androidId == second.androidId) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt new file mode 100644 index 000000000..47431f253 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/DriverPairsTest.kt @@ -0,0 +1,109 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.GpuInfo.Family +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriverPairsTest { + private fun gpu(model: Int, adreno: Boolean = true, oneUi: Boolean = false) = + GpuInfo("Adreno $model", model, GpuInfo.familyOf(adreno, model), "", oneUi) + + private fun asset(name: String, tag: String, linux: Boolean, pair: String) = + TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair) + + @Test + fun familiesFollowTheAdrenoModel() { + assertEquals(Family.A8XX, GpuInfo.familyOf(true, 825)) + assertEquals(Family.A8XX, GpuInfo.familyOf(true, 810)) + assertEquals(Family.A7XX_LOW, GpuInfo.familyOf(true, 722)) + assertEquals(Family.A7XX, GpuInfo.familyOf(true, 740)) + assertEquals(Family.A6XX, GpuInfo.familyOf(true, 610)) + assertEquals(Family.ADRENO_UNKNOWN, GpuInfo.familyOf(true, 0)) + assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0)) + } + + @Test + fun onlyTestedHardwareCountsAsSupported() { + assertEquals(GpuInfo.Support.TESTED, gpu(740).support) + assertEquals(GpuInfo.Support.TESTED, gpu(825).support) + assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support) + assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support) + assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support) + } + + @Test + fun autoPicksWinNativeBalancedOn8xxAndBannersElsewhere() { + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), emptyList())) + assertEquals("840 with no DD-Turnip release listed", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(840), emptyList())) + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), emptyList())) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), emptyList())) + assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), emptyList())) + assertEquals(DriverPairs.BANNER_710, DriverPairs.recommendedKey(gpu(720), emptyList())) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), emptyList())) + assertNull(DriverPairs.recommendedKey(gpu(0, adreno = false), emptyList())) + } + + @Test + fun pairsMatchHalvesAndNeverMixBannersReleases() { + val check = TurnipReleases.Check( + listOf( + asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), + asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER), + // The A8xx set's newest display build is r4, but its Linux half is only in r3. + asset("Turnip-r4-A8xx.zip", "r4", false, DriverPairs.BANNER_A8XX), + asset("Turnip-r3-A8xx-Linux.zip", "r3", true, DriverPairs.BANNER_A8XX), + asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED), + asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED), + ), + emptyList(), emptyList(), 0L, + ) + val pairs = DriverPairs.from(check).associateBy { it.key } + assertTrue(pairs.getValue(DriverPairs.BANNER).complete) + assertFalse("halves from two releases are not a pair", pairs.getValue(DriverPairs.BANNER_A8XX).complete) + val wn = pairs.getValue(DriverPairs.WN_BALANCED) + assertTrue("WinNative's two release lines pair up", wn.complete) + assertEquals("v1.19 + v0.1.2", wn.version) + assertTrue(wn.suits(gpu(830))) + assertFalse(pairs.getValue(DriverPairs.BANNER).suits(gpu(830))) + } + + @Test + fun everyBundleReleaseIsACompletePairOnItsOwnNewestFirst() { + fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + val newer = bundle("DD-Turnip-v0.2.0") + val older = bundle("DD-Turnip-v0.1.0") + val pairs = DriverPairs.from(TurnipReleases.Check( + listOf(asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), newer, older, + asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER)), + emptyList(), emptyList(), 0L, + )) + assertEquals(listOf(DriverPairs.ddTurnip("DD-Turnip-v0.2.0"), DriverPairs.ddTurnip("DD-Turnip-v0.1.0"), DriverPairs.BANNER), pairs.map { it.key }) + val dd = pairs.first() + assertTrue(dd.complete) + assertEquals(listOf(newer), dd.assets) + assertEquals("v0.2.0", dd.version) + assertEquals("DroidDeck · DD-Turnip", dd.name) + assertTrue(dd.suits(gpu(840))) + assertTrue(dd.suits(gpu(740))) + } + + @Test + fun adreno840GetsTheNewestOfficialDdTurnip() { + fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000, + false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true) + val pairs = DriverPairs.from(TurnipReleases.Check( + listOf(bundle("DD-Turnip-v0.9.0"), bundle("DD-Turnip-v1.0.0"), bundle("DD-Turnip-v0.10.1"), + asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED), + asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED)), + emptyList(), emptyList(), 0L, + )) + assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs)) + assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs)) + assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs)) + assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs)) + } +} From 771dca7048cea5dbef623e67a06a1fe939cad347 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:08:57 -0400 Subject: [PATCH 09/28] Preserve added game launch options (#111) --- .../usr/local/bin/bannerlator-steam-shortcuts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts index c3fcd4ae3..0abb8b719 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts @@ -97,7 +97,7 @@ def write(obj): return bytes(out) -def shortcut(game): +def shortcut(game, launch_options=""): appid = int(game["appid"]) & 0xFFFFFFFF return { "appid": struct.unpack(" Date: Wed, 30 Sep 2026 19:34:15 -0400 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20carry=20the=20installed=20Steam=20?= =?UTF-8?q?build=20into=20imported=20games=20so=20Steam=20sto=E2=80=A6=20(?= =?UTF-8?q?#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: carry the installed Steam build into imported games so Steam stops re-verifying them A second-library game downloaded by WinNative was registered with a bare manifest (no buildid or InstalledDepots), so Steam treated it as build 0 and verified all ~92 GB of Final Fantasy XV through proot. Reuse WinNative's .winnative-steam manifest, or a completed .DepotDownloader/depot.config, so Steam only fetches what changed since the recorded build. * fix: give queued Steam fresh installs of imported games the recorded build Steam rewrites a bare import manifest into a fresh install at build 0 before downloading anything. While nothing is downloaded or staged, the WinNative build is still safe to carry over, so Steam skips the full re-download (FF7 Remake was queued for 98 GB this way). * fix: mount the game storage at /mnt/droiddeck-sd The second library was bound at /mnt/bannerlator-sd, a name left from the Bannerlator port, which is what Steam shows in its paths and errors. Bind it at /mnt/droiddeck-sd, keep the old path as an alias for links and prefixes that still name it, move Steam's library entry in place, re-point import links, read ownership snapshots taken under the old path, and key added-game shortcut ids by the old path so their prefixes stay put. * fix: restart Steam once when owned added games are confirmed On a fresh install the importer runs before any ownership snapshot exists, so every added game became a non-Steam shortcut and stayed one until the client happened to restart. Once the watcher confirms owned titles that are not imported, it asks the session for one restart, while no game runs and no download is in progress, and the importer turns them into Steam entries. --- .../droiddeck/launcher/frontend/AddedGames.kt | 7 +- .../launcher/session/SessionPrefs.kt | 2 +- .../launcher/session/SessionService.kt | 5 +- docs/development/steam-game-imports.md | 17 +- .../usr/local/bin/bannerlator-desktop-games | 2 +- .../usr/local/bin/bannerlator-seed-redists | 2 +- .../overlay/usr/local/bin/bannerlator-session | 4 +- .../usr/local/bin/bannerlator-steam-games | 122 +++++++++++++-- .../usr/local/bin/bannerlator-steam-library | 26 +++- tools/tests/test_steam_games.py | 146 ++++++++++++++++++ 10 files changed, 301 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 6d833d931..3a1510402 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -12,6 +12,8 @@ import java.util.zip.CRC32 /** Windows game folders shared with the Steam session. */ object AddedGames { private const val TAG = "AddedGames" + private const val LIBRARY = "/mnt/droiddeck-sd" + private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd" class Game( val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String, @@ -65,7 +67,7 @@ object AddedGames { if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/") } GameStorage.effective(context)?.let { lib -> - if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/") + if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/") } return null } @@ -108,7 +110,8 @@ object AddedGames { if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return } val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return val name = folder.name - val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value + // Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put. + val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value val appId = crc or 0x80000000L val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") val steamId = steamRoute(steamRoot, appId) 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 515393370..9eb8a1d35 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -599,7 +599,7 @@ object SessionPrefs { // ── Game storage ──────────────────────────────────────────────────────────────────────── /** - * A second Steam library on this device: the folder bound at /mnt/bannerlator-sd and + * A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and * registered with the client, which then asks where to install every game and shows both * on its Storage page. "" = automatic: the SD card when one is in the phone (the default, * so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal 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 5abd06c2c..4114c627e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -697,9 +697,12 @@ class SessionService : Service() { if (library != null) { val problem = GameStorage.prepare(library.path) if (problem == null) { + File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs() + binds.add("${library.path}:/mnt/droiddeck-sd") + // Links, prefixes and Steam entries made before the rename still name the old path. File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs() binds.add("${library.path}:/mnt/bannerlator-sd") - Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")") + Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")") } else { Log.w(TAG, "game storage: $problem; internal only this session") } diff --git a/docs/development/steam-game-imports.md b/docs/development/steam-game-imports.md index 4639afa5b..4c4efc0e1 100644 --- a/docs/development/steam-game-imports.md +++ b/docs/development/steam-game-imports.md @@ -21,16 +21,21 @@ The resulting snapshot is stored under that account's `userdata//config` and refreshed every 30 seconds while logged in. Title lookups retry every five minutes and when the source changes, so an early network failure does not end the watcher. Changed status and probe failures are written to the session log. Startup and post-exit import -use the last successful snapshot for the selected account. A newly identified title -therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts -again. An unavailable client or offline first launch falls back to shortcuts; existing +use the last successful snapshot for the selected account. The client reads manifests +and shortcuts only at start, so when a snapshot confirms owned titles that are not yet +imported, the watcher restarts the client once (not while a game runs or a download is in +progress) and the importer runs between the exit and the next start. An unavailable client or offline first launch falls back to shortcuts; existing snapshots remain usable offline. Steam still enforces the current license at launch. For a confirmed game without a manifest, the importer links the source folder into the internal Steam library and writes an installed manifest with `Universe=1` and -`StateFlags=4`. Steam ignores manifests without the universe field. This registers -existing files; it does not assert a verified build ID or depot manifest. Steam can -still require verification, updates or redistributables. Steam updates and uninstall +`StateFlags=4`. Steam ignores manifests without the universe field. A manifest without +installed depots makes Steam verify every file, so the importer carries the build another +client recorded: WinNative's `.winnative-steam/steamapps` manifest beside the game folder, +or a completed download's `.DepotDownloader/depot.config` depot manifest IDs. Steam then +only downloads what changed since that build. Bare manifests from earlier imports are +upgraded while Steam has not touched them. Without either record Steam still verifies. +WinNative's manifest also identifies the folder without a store lookup. Steam updates and uninstall operate on the linked game files, so imported folders must be writable. Existing Steam manifests and directories are preserved. Only the exact minimal manifests generated by the earlier importer are migrated, with the matching source symlink still present. If diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games index 16fc736ec..7690fd219 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games @@ -11,7 +11,7 @@ import sys HOME = os.environ.get("HOME", "/root") STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam") -LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/bannerlator-sd", "SD Games")) +LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd", "SD Games")) # The client's own tools and runtimes live in steamapps beside the games; they are not titles. NOT_GAMES = {228980, 1493710, 3127680, 4183110, 4427310, 4185400} APPS_DIR = os.path.join(HOME, ".local/share/applications") diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists index 61c8d35a6..ea1d413f5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists @@ -79,7 +79,7 @@ block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" ' for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ "$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \ "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ - /mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do + /mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do [ -f "$reg" ] || continue seeded=$(dirname "$reg")/.bannerlator-redists [ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index f3b40af88..01fb68198 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -234,7 +234,7 @@ case $mode in bannerlator-steam-install || exit $? step "Steam client ready" steam_root=$XDG_DATA_HOME/Steam - # Register the card library (bound at /mnt/bannerlator-sd) with the client - internal storage is + # Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is # its main library already - and the ARM64 Proton that lets it launch the Windows ones. # A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts, # so it is in the Compatibility dropdown the first time the user looks. One per line, each the @@ -446,7 +446,7 @@ case $mode in local d [ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0 for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \ - "$steam_root/compatibilitytools.d" /mnt/bannerlator-sd/steamapps; do + "$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do [ "$d" -nt "$compat_stamp" ] && return 0 done return 1 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games index 1c2add014..46dcdb83e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -13,6 +13,9 @@ import urllib.parse import urllib.request STATE = ".droiddeck-games.json" +WINNATIVE = ".winnative-steam/steamapps" +LIBRARY = "/mnt/droiddeck-sd" +LEGACY_LIBRARY = "/mnt/bannerlator-sd" BASE_ID = 76561197960265728 @@ -40,6 +43,11 @@ def edition_key(name): return title_key(name) +def legacy_path(path): + path = str(path) + return LEGACY_LIBRARY + path[len(LIBRARY):] if path == LIBRARY or path.startswith(LIBRARY + "/") else path + + def source_stamp(game): try: exe = Path(game["exe"]) @@ -53,19 +61,23 @@ def source_stamp(game): return None +def manifest_ids(steamapps, name): + matches = [] + for manifest in Path(steamapps).glob("appmanifest_*.acf"): + try: + install = re.search(r'"installdir"\s*"([^"\\]*)"', manifest.read_text(), re.I) + if install and install[1] == name: + matches.append(int(manifest.stem.removeprefix("appmanifest_"))) + except (OSError, ValueError): + pass + return matches + + def identify(game): folder = Path(game["folder"]) steamapps = folder.parent.parent - if folder.parent.name == "common" and steamapps.name == "steamapps": - matches = [] - for manifest in steamapps.glob("appmanifest_*.acf"): - try: - text = manifest.read_text() - install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I) - if install and install[1] == folder.name: - matches.append(int(manifest.stem.removeprefix("appmanifest_"))) - except (OSError, ValueError): - pass + for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]: + matches = manifest_ids(library, folder.name) if matches: return matches[0] if len(matches) == 1 else None ids = set() @@ -144,6 +156,27 @@ def game_running(): return False +def downloads_idle(steam_root): + try: + return not any(Path(steam_root, "steamapps/downloading").iterdir()) + except OSError: + return True + + +def request_restart(steam_root): + """The client reads manifests and shortcuts only at start, so an import needs one restart; + the session's loop runs the importer between the exit and the next start.""" + Path(os.environ.get("HOME", "/root"), ".bl-restart-once").touch() + subprocess.run([str(Path(steam_root) / "steamrtarm64/steam"), "-shutdown"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) + + +def unimported(steam_root, snapshot, candidates): + routes = read_json(Path(steam_root) / "userdata" / snapshot["account"] / "config/.droiddeck-routes.json", {}) + routed = set(routes.values()) if isinstance(routes, dict) else set() + return sorted({appid for appid in candidates.values() if snapshot["owned"].get(str(appid)) is True} - routed) + + def watch(steam_root, listing): games = read_json(listing, []) if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: @@ -158,6 +191,7 @@ def watch(steam_root, listing): # beat (a client that is not logged on fails it for as long as it stays that way), and none # starts while a game is running: the answer can wait, the game's cores cannot. probed, next_probe, failures = None, 0, 0 + pending, requested = [], [] while True: now = time.monotonic() for game in games: @@ -195,6 +229,7 @@ def watch(steam_root, listing): snapshot["candidates"] = candidates snapshot["sources"] = sources save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot) + pending = unimported(steam_root, snapshot, candidates) 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) @@ -202,12 +237,51 @@ def watch(steam_root, listing): next_probe = now + min(300, 30 * 2 ** failures) elif candidates: status = last_status + if pending and pending != requested and not game_running() and downloads_idle(steam_root): + requested = pending + print("bannerlator-steam-games: restarting the client once to import %s" % pending, flush=True) + try: + request_restart(steam_root) + except (OSError, subprocess.TimeoutExpired) as error: + print("bannerlator-steam-games: restart unavailable: %s" % error, flush=True) if status != last_status: print("bannerlator-steam-games: " + status, flush=True) last_status = status time.sleep(30) +def installed_build(folder, appid, installdir): + """The build another Steam client recorded for these files, so Steam updates from it + instead of verifying every file.""" + try: + text = (folder.parent / WINNATIVE / f"appmanifest_{appid}.acf").read_text() + install = re.search(r'("installdir"\s*")([^"\\]*)(")', text, re.I) + flags = re.search(r'"StateFlags"\s*"(\d+)"', text) + if (install and install[2] == folder.name and flags and flags[1] == "4" + and re.search(rf'"appid"\s*"{appid}"', text) and '"InstalledDepots"' in text): + return text[:install.start(2)] + installdir + text[install.end(2):] + except OSError: + pass + if not (folder / ".download_complete").is_file(): + return None + try: + ids = json.loads((folder / ".DepotDownloader/depot.config").read_text())["installedManifestIDs"] + depots = sorted((int(depot), int(gid)) for depot, gid in ids.items()) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return None + if not depots or not all(0 < depot < 0x80000000 and 0 < gid < 0x7FFFFFFFFFFFFFFF for depot, gid in depots): + return None + return "".join(f' "{depot}"\n {{\n "manifest" "{gid}"\n }}\n' for depot, gid in depots) + + +def update_not_started(text, steamapps, appid, installdir): + fields = dict(re.findall(r'^\s*"(\w+)"\s*"([^"]*)"', text, re.M)) + return (fields.get("installdir") == installdir and fields.get("buildid", "0") == "0" + and fields.get("BytesDownloaded", "0") == "0" and fields.get("BytesStaged", "0") == "0" + and re.search(r'"InstalledDepots"\s*\{\s*\}', text) is not None + and not (steamapps / "downloading" / str(appid)).exists()) + + def seed_install(steam_root, game, appid): steamapps = Path(steam_root) / "steamapps" common = steamapps / "common" @@ -215,16 +289,30 @@ def seed_install(steam_root, game, appid): target = common / name source = Path(game["folder"]) title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ") - content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n' + minimal = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n' + f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n') - legacy = content.replace(' "Universe" "1"\n', '') + legacy = minimal.replace(' "Universe" "1"\n', '') + build = installed_build(source, appid, name) + if build is None: + content = minimal + elif build.lstrip().startswith('"AppState"'): + content = build + else: + content = minimal[:-2] + ' "InstalledDepots"\n {\n' + build + ' }\n}\n' manifest = steamapps / f"appmanifest_{appid}.acf" - for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]: + if target.is_symlink() and os.readlink(target) != str(source) and os.readlink(target) == legacy_path(source): + relink = target.with_name(name + ".droiddeck") + relink.unlink(missing_ok=True) + relink.symlink_to(source, target_is_directory=True) + os.replace(relink, target) + for root in [Path(steam_root), Path(LIBRARY)]: existing = root / "steamapps" / manifest.name if existing.is_file(): if (existing == manifest and source.is_dir() and target.is_symlink() and os.readlink(target) == str(source) - and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))): + and existing.read_text() != content + and (existing.read_text() in (minimal, legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"')) + or (build is not None and update_not_started(existing.read_text(), steamapps, appid, name)))): temp = manifest.with_suffix(".droiddeck") temp.write_text(content) os.replace(temp, manifest) @@ -256,11 +344,13 @@ def route(steam_root, acct, games): owned, candidates, sources = {}, {}, {} shortcuts, routes = [], {} for game in games: - appid = candidates.get(game.get("folder")) + folder = game.get("folder", "") + appid = candidates.get(folder, candidates.get(legacy_path(folder))) stamp = source_stamp(game) + recorded = sources.get(folder, sources.get(legacy_path(folder))) confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000 and owned.get(str(appid)) is True and stamp is not None - and sources.get(game.get("folder")) == stamp) + and recorded in (stamp, [stamp[0], legacy_path(stamp[1])] + stamp[2:])) if confirmed: try: if seed_install(steam_root, game, appid): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library index e67878e6e..d065db6b5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library @@ -1,5 +1,5 @@ #!/usr/bin/python3 -# Registers the card the app installs to (bound at /mnt/bannerlator-sd) as a library folder of the +# Registers the card the app installs to (bound at /mnt/droiddeck-sd) as a library folder of the # native Steam client, so they show as installed there instead of being downloaded a second time. # # bannerlator-steam-library @@ -14,7 +14,7 @@ import sys # The app binds the storage the user chose (Steam cog -> Game storage) at this path and names it # in BL_LIBRARY_LABEL; nothing bound = internal only, and the entry is removed so the client does # not keep offering a place that is not there. -LIBRARIES = (("/mnt/bannerlator-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),) +LIBRARIES = (("/mnt/droiddeck-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),) # The client calls its own library "Local Drive (/)", which says nothing about where that is. # It is the phone's internal storage, so say so. MAIN_LABEL = "Internal Storage" @@ -66,6 +66,26 @@ def register(steam_root, library, label): RETIRED = ("/mnt/bannerlator",) +RENAMED = (("/mnt/bannerlator-sd", "/mnt/droiddeck-sd"),) + + +def rename(steam_root, old, new): + """Moves a library registered under an earlier mount point to the current one, keeping its + entry (content id, app sizes) rather than registering it anew.""" + path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf") + if not os.path.isfile(path): + return + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + old_path = re.compile(r'("path"\s+")%s(")' % re.escape(old)) + if not old_path.search(text): + return + if re.search(r'"path"\s+"%s"' % re.escape(new), text): + forget(steam_root, old) + return + with open(path, "w", encoding="utf-8") as f: + f.write(old_path.sub(lambda m: m.group(1) + new + m.group(2), text)) + print("bannerlator-steam-library: moved %s to %s" % (old, new)) def forget(steam_root, library): @@ -152,6 +172,8 @@ def main(): steam_root = sys.argv[1] for library in RETIRED: forget(steam_root, library) + for old, new in RENAMED: + rename(steam_root, old, new) relabel(steam_root, steam_root, MAIN_LABEL) for library, label in LIBRARIES: if os.path.isdir(os.path.join(library, "steamapps")): diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index 26220319d..34c96c5d6 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -20,6 +20,7 @@ def load(name): imports = load('bannerlator-steam-games') shortcuts = load('bannerlator-steam-shortcuts') +library = load('bannerlator-steam-library') class ImportsTest(unittest.TestCase): @@ -73,6 +74,7 @@ class ImportsTest(unittest.TestCase): from unittest.mock import Mock result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True}))) with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \ + patch.object(imports, 'request_restart'), \ patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \ patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \ patch.object(imports.subprocess, 'run', return_value=result), \ @@ -83,6 +85,35 @@ class ImportsTest(unittest.TestCase): snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {}) self.assertTrue(snapshot['owned']['42']) + def run_watch(self, beats, routes=None, busy=False): + listing = self.root / 'games.json' + listing.write_text(json.dumps([self.game])) + if routes is not None: + imports.save_json(self.acct / 'config/.droiddeck-routes.json', routes) + if busy: + (self.steam / 'steamapps/downloading/7').mkdir(parents=True) + from unittest.mock import Mock + result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True}))) + with patch.object(imports, 'identify', return_value=42), \ + patch.object(imports, 'game_running', return_value=False), \ + patch.object(imports, 'request_restart') as restart, \ + patch.object(imports.time, 'monotonic', side_effect=list(range(0, 30 * beats, 30))), \ + patch.object(imports.time, 'sleep', side_effect=[None] * (beats - 1) + [RuntimeError('stop')]), \ + patch.object(imports.subprocess, 'run', return_value=result), \ + patch('builtins.print'): + with self.assertRaisesRegex(RuntimeError, 'stop'): + imports.watch(self.steam, listing) + return restart + + def test_newly_owned_game_restarts_the_client_once(self): + self.assertEqual(1, self.run_watch(3).call_count) + + def test_imported_game_does_not_restart_the_client(self): + self.assertEqual(0, self.run_watch(2, routes={'1': 42}).call_count) + + def test_restart_waits_for_downloads(self): + self.assertEqual(0, self.run_watch(2, busy=True).call_count) + def test_unowned_unknown_and_different_account_stay_shortcuts(self): for owned, account in [(False, '123'), (True, '456')]: self.snapshot(owned, account) @@ -91,6 +122,93 @@ class ImportsTest(unittest.TestCase): self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) self.assertFalse((self.steam / 'steamapps').exists()) + def winnative_manifest(self, flags='4', installdir='Example'): + path = self.folder.parent / imports.WINNATIVE / 'appmanifest_42.acf' + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n' + '\t"buildid"\t\t"7"\n\t"InstalledDepots"\n\t{\n\t\t"43"\n\t\t{\n\t\t\t"manifest"\t\t"99"\n' + '\t\t\t"size"\t\t"4"\n\t\t}\n\t}\n}\n' % (flags, installdir)) + return path + + def test_winnative_manifest_carries_installed_build(self): + source = self.winnative_manifest() + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + text = (self.steam / 'steamapps/appmanifest_42.acf').read_text() + self.assertEqual(source.read_text().replace('"Example"', '"DroidDeck-42"'), text) + + def test_winnative_manifest_identifies_folder(self): + self.winnative_manifest() + with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError): + self.assertEqual(42, imports.identify(self.game)) + + def test_incomplete_winnative_manifest_is_ignored(self): + for flags, installdir in [('1026', 'Example'), ('4', 'Other')]: + self.winnative_manifest(flags, installdir) + self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42')) + + def test_depot_config_records_installed_depots(self): + config = self.folder / '.DepotDownloader/depot.config' + config.parent.mkdir() + config.write_text(json.dumps({'installedManifestIDs': {'43': 99}})) + self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42')) + (self.folder / '.download_complete').touch() + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + text = (self.steam / 'steamapps/appmanifest_42.acf').read_text() + self.assertIn('"InstalledDepots"\n {\n "43"\n {\n "manifest" "99"', text) + self.assertTrue(text.endswith(' }\n}\n')) + config.write_text(json.dumps({'installedManifestIDs': {'43': 0x7FFFFFFFFFFFFFFF}})) + self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42')) + + def test_bare_import_manifest_gains_installed_build(self): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + self.winnative_manifest() + imports.route(self.steam, self.acct, [self.game]) + self.assertIn('"buildid"\t\t"7"', (self.steam / 'steamapps/appmanifest_42.acf').read_text()) + + def steam_fresh_install(self, downloaded='0'): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + path = self.steam / 'steamapps/appmanifest_42.acf' + path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"6"\n\t"installdir"\t\t"DroidDeck-42"\n' + '\t"buildid"\t\t"0"\n\t"BytesDownloaded"\t\t"%s"\n\t"BytesStaged"\t\t"0"\n' + '\t"InstalledDepots"\n\t{\n\t}\n}\n' % downloaded) + self.winnative_manifest() + return path + + def test_queued_fresh_install_takes_installed_build(self): + path = self.steam_fresh_install() + imports.route(self.steam, self.acct, [self.game]) + self.assertIn('"buildid"\t\t"7"', path.read_text()) + + def test_started_fresh_install_is_left_to_steam(self): + for downloaded, staging in [('5', False), ('0', True)]: + path = self.steam_fresh_install(downloaded) + if staging: + (self.steam / 'steamapps/downloading/42').mkdir(parents=True) + before = path.read_text() + imports.route(self.steam, self.acct, [self.game]) + self.assertEqual(before, path.read_text()) + + def test_links_and_snapshots_from_the_old_mount_carry_over(self): + old = self.root / 'old-mount' + with patch.object(imports, 'LIBRARY', str(self.root / 'Games')), \ + patch.object(imports, 'LEGACY_LIBRARY', str(old)): + legacy_game = dict(self.game, folder=str(old / 'Example'), exe=str(old / 'Example/Example.exe')) + stamp = imports.source_stamp(self.game) + imports.save_json(self.acct / 'config' / imports.STATE, + dict(account='123', owned={'42': True}, candidates={legacy_game['folder']: 42}, + sources={legacy_game['folder']: [stamp[0], legacy_game['exe']] + stamp[2:]})) + link = self.steam / 'steamapps/common/DroidDeck-42' + link.parent.mkdir(parents=True) + link.symlink_to(legacy_game['folder'], target_is_directory=True) + games, routes = imports.route(self.steam, self.acct, [self.game]) + self.assertEqual([], games) + self.assertEqual({str(self.game['appid']): 42}, routes) + self.assertEqual(str(self.folder), imports.os.readlink(link)) + def test_existing_manifest_is_never_overwritten(self): manifest = self.steam / 'steamapps/appmanifest_42.acf' manifest.parent.mkdir(parents=True) @@ -188,6 +306,34 @@ class ImportsTest(unittest.TestCase): self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text())) +class LibraryRenameTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.steam = Path(temp.name) + self.vdf = self.steam / 'steamapps/libraryfolders.vdf' + self.vdf.parent.mkdir() + + def entry(self, index, path): + return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"contentid"\t\t"%d"\n\t}\n' % (index, path, 7 + index) + + def test_old_mount_keeps_its_entry_under_the_new_path(self): + self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/root') + self.entry(1, '/mnt/bannerlator-sd') + '}\n') + with patch('builtins.print'): + library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd') + text = self.vdf.read_text() + self.assertIn('"path"\t\t"/mnt/droiddeck-sd"\n\t\t"contentid"\t\t"8"', text) + self.assertNotIn('bannerlator-sd', text) + + def test_old_mount_is_dropped_when_new_path_is_registered(self): + self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/mnt/droiddeck-sd') + self.entry(1, '/mnt/bannerlator-sd') + '}\n') + with patch('builtins.print'): + library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd') + text = self.vdf.read_text() + self.assertEqual(1, text.count('"path"')) + self.assertIn('/mnt/droiddeck-sd', text) + + class SteamProbeTest(unittest.TestCase): def test_probe_reads_account_and_licenses_and_releases_handles(self): import ctypes as C From 184ed90c514913fa5b269ad35b0fa3eb74d28477 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:46:45 -0400 Subject: [PATCH 11/28] fix: offer updates to release-signed local builds and align the Updates page (#112) --- app/build.gradle | 3 ++ .../main/java/com/droiddeck/launcher/App.kt | 1 + .../com/droiddeck/launcher/ui/UpdatesPage.kt | 26 +++++++++---- .../droiddeck/launcher/update/AppUpdates.kt | 37 ++++++++++++++++--- .../launcher/update/AppUpdatesTest.kt | 2 +- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 37efe56dc..d2bb5daaf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,8 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO ?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: '' def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' } def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true' +// The release key's certificate: a build signed with it, from CI or a computer, updates in place. +def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim() // When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds. def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' } // tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as. @@ -83,6 +85,7 @@ android { buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\"" buildConfigField "int", "PR_NUMBER", "${prNumber}" buildConfigField "boolean", "CI_BUILD", "${ciBuild}" + buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\"" buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L" buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\"" diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 7cc6db7d6..77551f925 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -17,6 +17,7 @@ class App : Application() { // Before anything else touches the session prefs: a new install's first read decides it. com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this) com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) + com.droiddeck.launcher.update.AppUpdates.init(this) GpuClockPin.clearLeftover(this) BwrapSpawner.start(this) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 6d824a604..e1efe1b06 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -112,10 +112,15 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie if (LocalNarrowPane.current) { StatusPanel(s, u, ua, me) Box(Modifier.height(18.dp)) + ChannelLabel() ChannelPicker(u, ua) - } else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { - Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } - Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } + } else { + // The label sits over both columns, so the status card lines up with the first channel. + ChannelLabel() + Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { + Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } + Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } + } } } } @@ -140,13 +145,13 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m val pal = LocalPalette.current val catalog = u.catalog val release = catalog?.let { AppUpdates.release(it, u.follow) } - val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null + val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null val name = channelName(u.follow) val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH) class Look(val tint: Color, val status: String, val headline: String, val detail: String?) val look = when { - !me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer", - "It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.") + !me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place", + "This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.") catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null) offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null) offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null) @@ -235,13 +240,20 @@ private fun changeTitle(t: String): String = private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB" /** The three channels as cards to pick from; Test builds opens its list of PRs under it. */ +@Composable +private fun ChannelLabel() { + Text( + "UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp), + ) +} + @Composable private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { val colors = MaterialTheme.colorScheme val catalog = u.catalog val tests = catalog?.tests.orEmpty() Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp)) ChannelCard( Icons.Outlined.Verified, "Stable", "Tested releases, for most people", catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE, diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 955ea2b1e..5d3eb1593 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -1,6 +1,8 @@ package com.droiddeck.launcher.update import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.core.Hashes import org.json.JSONArray @@ -8,6 +10,7 @@ import org.json.JSONObject import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest import java.time.Instant /** @@ -47,8 +50,11 @@ object AppUpdates { class Catalog(val stable: Release?, val nightly: Release?, val tests: List, val checkedAt: Long) - /** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */ - class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L) + /** + * What is running: the commit it was built from, its PR (0 for none), whether the builds here + * install over it (it is signed with the release key), and when it was committed. + */ + class Installed(val commit: String, val pr: Int, val version: String, val updatable: Boolean, val committedAt: Long = 0L) enum class Offer { /** This is the build the channel has. */ @@ -63,7 +69,28 @@ object AppUpdates { GONE, } - fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME) + @Volatile private var releaseSigned = BuildConfig.CI_BUILD + + /** From App.onCreate: whether this copy is signed with the release key, which a local build may be too. */ + fun init(context: Context) { + releaseSigned = runCatching { signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) } } + .getOrDefault(BuildConfig.CI_BUILD) + } + + /** SHA-256 of each certificate the package is signed with, including the ones a rotated key vouches for. */ + private fun signers(context: Context): List { + val pm = context.packageManager + @Suppress("DEPRECATION") + val certs = if (Build.VERSION.SDK_INT >= 28) { + val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo ?: return emptyList() + if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory + } else { + pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures + } ?: return emptyList() + return certs.map { cert -> MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } } + } + + fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, releaseSigned, BuildConfig.COMMIT_TIME) fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) { Channel.STABLE -> catalog.stable @@ -87,7 +114,7 @@ object AppUpdates { /** The dot on the rail: the followed channel has a newer build of what is running. */ fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean = - catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE + catalog != null && installed().updatable && offer(catalog, follow) == Offer.UPDATE fun isRunning(r: Release, me: Installed = installed()): Boolean { if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false @@ -105,7 +132,7 @@ object AppUpdates { me.pr != 0 -> Follow(Channel.TEST, me.pr) catalog == null -> return Follow(Channel.STABLE) catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE) - me.ci -> Follow(Channel.NIGHTLY) + me.updatable -> Follow(Channel.NIGHTLY) else -> Follow(Channel.STABLE) } setFollow(context, inferred) diff --git a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt index 2ac5f5e80..6e57f3d67 100644 --- a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt @@ -20,7 +20,7 @@ class AppUpdatesTest { checkedAt = 0L, ) - private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, ci = true) + private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, updatable = true) @Test fun theStableBuildIsCurrentOnStable() { From b3a87d3d7346e7e94cf3cd05e64a024bd7c8a3f8 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:18:08 -0400 Subject: [PATCH 12/28] ci: streamline signed PR test builds (#114) --- .github/workflows/build.yml | 6 +- .github/workflows/pr-release-cleanup.yml | 16 ++-- .github/workflows/pr-release-publish.yml | 108 +++++++++++++++++++++-- 3 files changed, 113 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5f189da8..ae0b401cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -387,7 +387,11 @@ jobs: success) echo "### Build passed" echo - echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. A maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release." + echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." + echo + echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Nightly and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + echo + echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." ;; failure) echo "### ERROR: Build failed" diff --git a/.github/workflows/pr-release-cleanup.yml b/.github/workflows/pr-release-cleanup.yml index abe506be3..a34254af2 100644 --- a/.github/workflows/pr-release-cleanup.yml +++ b/.github/workflows/pr-release-cleanup.yml @@ -30,10 +30,12 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail - tag="pr-$PR_NUMBER" - if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then - gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag - echo "Deleted $tag" - else - echo "No test build for PR #$PR_NUMBER" - fi + found=0 + for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do + if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then + gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag + echo "Deleted $tag" + found=1 + fi + done + [ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER" diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index afc3cfa09..09143833d 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,28 +1,35 @@ name: PR Release Publish -run-name: sign PR #${{ inputs.pr_number }} with the release key +run-name: ${{ github.event_name == 'pull_request_target' && format('sign PR #{0} requested by @{1}', github.event.pull_request.number, github.actor) || format('sign PR #{0} with the release key', inputs.pr_number) }} on: workflow_dispatch: inputs: pr_number: - description: Open pull request + description: Open pull request number required: true - type: number + type: string + pull_request_target: + types: [labeled] + branches: [main] permissions: actions: read contents: read + issues: write pull-requests: read concurrency: - group: pr-release-${{ inputs.pr_number }} + group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} cancel-in-progress: false jobs: authorize: - if: github.ref == 'refs/heads/main' + if: >- + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') runs-on: ubuntu-24.04 outputs: + pr_number: ${{ steps.gate.outputs.pr_number }} run_id: ${{ steps.gate.outputs.run_id }} head_sha: ${{ steps.gate.outputs.head_sha }} label: ${{ steps.gate.outputs.label }} @@ -35,13 +42,23 @@ jobs: - name: The packages a signed build ships as id: variants run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" - - name: Check the PR and build + - name: Check the requester, PR, and build id: gate env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ inputs.pr_number }} + EVENT_NAME: ${{ github.event_name }} + REQUESTER: ${{ github.actor }} + PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} run: | set -euo pipefail + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number '$PR_NUMBER'"; exit 1; } + if [ "$EVENT_NAME" = pull_request_target ]; then + permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$REQUESTER/permission" --jq .permission) + case "$permission" in + admin|maintain|write) ;; + *) echo "::error::@$REQUESTER does not have permission to publish release-signed test builds"; exit 1 ;; + esac + fi pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null head=$(jq -r '.head.sha' <<<"$pr") @@ -85,6 +102,7 @@ jobs: .head.sha == $head and .base.sha == $base ' <<<"$current" >/dev/null { + echo "pr_number=$PR_NUMBER" echo "run_id=$run_id" echo "head_sha=$head" echo "label=PR.$PR_NUMBER-${head:0:7}" @@ -107,6 +125,7 @@ jobs: permissions: actions: read contents: read + issues: write pull-requests: read steps: - uses: actions/download-artifact@v4 @@ -119,7 +138,7 @@ jobs: - name: Describe the change env: GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ inputs.pr_number }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} run: | set -euo pipefail pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") @@ -143,9 +162,10 @@ jobs: owner: Droid-Deck repositories: DroidDeck-CI - name: Publish to DroidDeck-CI + id: publish env: GH_TOKEN: ${{ steps.ci-token.outputs.token }} - PR_NUMBER: ${{ inputs.pr_number }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} run: | set -euo pipefail @@ -175,3 +195,73 @@ jobs: while IFS= read -r id; do gh api --method DELETE "repos/$repo/releases/assets/$id" >/dev/null done + + published=$(gh release view "$tag" -R "$repo" --json tagName,assets) + jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<<"$published" >/dev/null + release="$GITHUB_SERVER_URL/$repo/releases/tag/$tag" + apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }') + echo "release=$release" >> "$GITHUB_OUTPUT" + echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT" + + - name: Report the signed build on the PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + RELEASE: ${{ steps.publish.outputs.release }} + APK: ${{ steps.publish.outputs.apk }} + run: | + set -euo pipefail + short=${HEAD_SHA:0:7} + mark='' + body=$(cat </dev/null + else + gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null + fi + + - name: Clear the publish request label + if: github.event_name == 'pull_request_target' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + run: | + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/publish-test" + + - name: Post to Discord + env: + WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + RELEASE: ${{ steps.publish.outputs.release }} + APK: ${{ steps.publish.outputs.apk }} + run: | + set -euo pipefail + if [ -z "$WEBHOOK" ]; then + echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted" + exit 0 + fi + short=${HEAD_SHA:0:7} + title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title) + what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title" + get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE") + content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get") + jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' | + curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null + echo "Posted: $content" From a5f2895a6a2f8478d043b0a1bcbec73f8057a068 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 22:46:44 -0400 Subject: [PATCH 13/28] feat: publish and consume update catalog --- .github/workflows/build.yml | 10 +- .github/workflows/pr-release-cleanup.yml | 11 +- .github/workflows/pr-release-publish.yml | 18 +- .github/workflows/update-catalog.yml | 41 ++ .../com/droiddeck/launcher/UpdatesMenu.kt | 1 + .../com/droiddeck/launcher/ui/UpdatesPage.kt | 23 +- .../droiddeck/launcher/update/AppUpdates.kt | 391 +++++++++-------- .../launcher/update/SelfInstaller.kt | 49 +++ .../launcher/update/AppUpdatesTest.kt | 149 ++++++- tools/release/update_catalog.py | 405 ++++++++++++++++++ tools/tests/test_update_catalog.py | 80 ++++ 11 files changed, 973 insertions(+), 205 deletions(-) create mode 100644 .github/workflows/update-catalog.yml create mode 100755 tools/release/update_catalog.py create mode 100644 tools/tests/test_update_catalog.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae0b401cd..c7b73e870 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -389,7 +389,7 @@ jobs: echo echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." echo - echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Nightly and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." echo echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." ;; @@ -519,6 +519,8 @@ jobs: 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. + Commit: $GITHUB_SHA + VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle) 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: @@ -580,3 +582,9 @@ jobs: jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' | curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null echo "Posted: $content" + + catalog: + needs: announce + if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }} + uses: ./.github/workflows/update-catalog.yml + secrets: inherit diff --git a/.github/workflows/pr-release-cleanup.yml b/.github/workflows/pr-release-cleanup.yml index a34254af2..29ebbdefc 100644 --- a/.github/workflows/pr-release-cleanup.yml +++ b/.github/workflows/pr-release-cleanup.yml @@ -2,14 +2,15 @@ name: PR Release Cleanup run-name: remove PR #${{ github.event.pull_request.number }}'s test build # A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it -# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing +# (a merged fix is in the next Preview). pull_request_target for the publisher app's secrets; nothing # from the PR is checked out or run. on: pull_request_target: types: [ closed ] branches: [ main ] -permissions: {} +permissions: + contents: read jobs: cleanup: @@ -39,3 +40,9 @@ jobs: fi done [ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER" + + catalog: + needs: cleanup + if: ${{ always() }} + uses: ./.github/workflows/update-catalog.yml + secrets: inherit diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 09143833d..d8caa090d 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,5 +1,5 @@ name: PR Release Publish -run-name: ${{ github.event_name == 'pull_request_target' && format('sign PR #{0} requested by @{1}', github.event.pull_request.number, github.actor) || format('sign PR #{0} with the release key', inputs.pr_number) }} +run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} with the release key" on: workflow_dispatch: @@ -32,6 +32,8 @@ jobs: pr_number: ${{ steps.gate.outputs.pr_number }} run_id: ${{ steps.gate.outputs.run_id }} head_sha: ${{ steps.gate.outputs.head_sha }} + base_sha: ${{ steps.gate.outputs.base_sha }} + version_code: ${{ steps.gate.outputs.version_code }} label: ${{ steps.gate.outputs.label }} variants: ${{ steps.variants.outputs.list }} steps: @@ -105,6 +107,8 @@ jobs: echo "pr_number=$PR_NUMBER" echo "run_id=$run_id" echo "head_sha=$head" + echo "base_sha=$base" + echo "version_code=${candidate##* }" echo "label=PR.$PR_NUMBER-${head:0:7}" } >> "$GITHUB_OUTPUT" @@ -167,6 +171,8 @@ jobs: GH_TOKEN: ${{ steps.ci-token.outputs.token }} PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + BASE_SHA: ${{ needs.authorize.outputs.base_sha }} + VERSION_CODE: ${{ needs.authorize.outputs.version_code }} run: | set -euo pipefail repo=Droid-Deck/DroidDeck-CI @@ -177,6 +183,8 @@ jobs: Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER Commit: $HEAD_SHA + Base: $BASE_SHA + VersionCode: $VERSION_CODE 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: @@ -218,7 +226,7 @@ jobs: $mark ## Signed test build - A release-signed build of this PR at \`$short\` is ready. It installs over Stable or Nightly and keeps app data. + A release-signed build of this PR at \`$short\` is ready. It installs over Stable or Preview and keeps app data. [Download the standard APK]($APK) · [All variants and checksums]($RELEASE) @@ -265,3 +273,9 @@ jobs: jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' | curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null echo "Posted: $content" + + catalog: + needs: publish + if: ${{ always() && needs.publish.result != 'skipped' }} + uses: ./.github/workflows/update-catalog.yml + secrets: inherit diff --git a/.github/workflows/update-catalog.yml b/.github/workflows/update-catalog.yml new file mode 100644 index 000000000..83b948d40 --- /dev/null +++ b/.github/workflows/update-catalog.yml @@ -0,0 +1,41 @@ +name: Update app catalog + +on: + workflow_call: + workflow_dispatch: + release: + types: [published, edited, deleted, unpublished] + +permissions: + contents: read + +concurrency: + group: update-app-catalog + cancel-in-progress: false + +jobs: + catalog: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + # Always use the current trusted generator, even when a release event points at an older tag. + - uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - uses: actions/create-github-app-token@v2 + id: ci-token + with: + app-id: ${{ vars.DROIDDECK_CI_APP_ID }} + private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }} + owner: Droid-Deck + repositories: DroidDeck-CI + + - name: Regenerate catalog.json + env: + SOURCE_GITHUB_TOKEN: ${{ github.token }} + CI_GITHUB_TOKEN: ${{ steps.ci-token.outputs.token }} + run: | + set -euo pipefail + python3 tools/release/update_catalog.py diff --git a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt index e93b9f62a..6a3e05d01 100644 --- a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt @@ -91,6 +91,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl private fun install(r: AppUpdates.Release) { if (stage != null) return val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return } + AppUpdates.installBlock(r)?.let { error = it; return } if (SessionState.running) { error = "Stop the running session first, then update"; return } if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return } error = null diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index e1efe1b06..278cb1662 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -148,6 +148,7 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null val name = channelName(u.follow) val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH) + val installBlock = release?.let { AppUpdates.installBlock(it, me) } class Look(val tint: Color, val status: String, val headline: String, val detail: String?) val look = when { !me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place", @@ -158,7 +159,7 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m offer == Offer.AHEAD -> Look(pal.signal, "Ahead of Stable", "You're ahead of Stable", "This build is newer than the last Stable release. You'll move onto Stable with its next one.") offer == Offer.GONE -> Look(AttentionAmber, "Test ended", "This test has ended", - "Its fix was merged or dropped. Follow Nightly to keep getting the newest fixes.") + "Its fix was merged or dropped. Follow Preview to keep getting the newest fixes.") else -> Look(pal.good, "Up to date", "You have the latest $name", null) } Column( @@ -174,8 +175,11 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m ReleaseNotes(changeTitle(release!!.title.ifBlank { release.tag }), release.summary, release.tag) } if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp)) + if (installBlock != null && (offered || offer == Offer.AHEAD)) { + Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp)) + } if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp)) - val installable = release?.apk != null && !s.sessionRunning + val installable = release?.apk != null && installBlock == null && !s.sessionRunning val button = release?.apk?.size?.takeIf { it > 0 }?.let { " · ${megabytes(it)}" }.orEmpty() Box(Modifier.padding(top = 14.dp)) { when { @@ -186,8 +190,9 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m } offer == Offer.UPDATE -> PrimaryButton("Update$button", enabled = installable, main = true) { ua.onInstall(release!!) } offer == Offer.SWITCH -> PrimaryButton("Install$button", enabled = installable, main = true) { ua.onInstall(release!!) } - offer == Offer.AHEAD -> SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) } - offer == Offer.GONE -> PrimaryButton("Follow Nightly", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) } + offer == Offer.AHEAD && installBlock == null -> + SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) } + offer == Offer.GONE -> PrimaryButton("Follow Preview", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) } } } if (s.sessionRunning && (offered || offer == Offer.AHEAD)) { @@ -226,10 +231,10 @@ private fun ReleaseNotes(title: String, notes: String, key: String) { } } -/** The headline for a build on offer: "DroidDeck 0.3.0", "New Nightly build", "PR #93 test build". */ +/** The headline for a build on offer: "DroidDeck 0.3.0", "New Preview build", "PR #93 test build". */ private fun newBuild(f: Follow, r: Release) = when (f.channel) { Channel.STABLE -> "DroidDeck ${r.version ?: r.tag}" - Channel.NIGHTLY -> "New Nightly build" + Channel.NIGHTLY -> "New Preview build" Channel.TEST -> "PR #${r.pr} test build" } @@ -259,8 +264,8 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE, ) { ua.onFollow(Follow(Channel.STABLE)) } ChannelCard( - Icons.Outlined.Bolt, "Nightly", "Newest fixes, the odd new bug", - catalog?.nightly?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY, + Icons.Outlined.Bolt, "Preview", "Newest main-branch fixes, before Stable", + catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY, ) { ua.onFollow(Follow(Channel.NIGHTLY)) } ChannelCard( Icons.Outlined.Science, "Test builds", "Try a fix before it's released", @@ -343,7 +348,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) { private fun channelName(f: Follow) = when (f.channel) { Channel.STABLE -> "Stable" - Channel.NIGHTLY -> "Nightly" + Channel.NIGHTLY -> "Preview" Channel.TEST -> "the PR #${f.pr} test" } diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 5d3eb1593..3310951d0 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -4,97 +4,118 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import com.droiddeck.launcher.BuildConfig -import com.droiddeck.launcher.core.Hashes import org.json.JSONArray import org.json.JSONObject import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.security.MessageDigest -import java.time.Instant /** - * DroidDeck's own builds, on three channels: Stable (the org repo's latest release), Nightly (the - * newest `main-` release on DroidDeck-CI, one per merge to main) and Test (a `pr-` release - * on DroidDeck-CI, a signed build of an open PR). All three are signed with the release key, so any - * of them installs over any other and keeps the app's data. + * DroidDeck's published builds. CI turns GitHub Releases into one static catalog in DroidDeck-CI; + * the app consumes that contract instead of inferring channels from tags and release-note prose. * - * Which build is newer is decided without a diff of the repo (GitHub's compare answers in - * megabytes): main is linear, so the newest main build is the newest; Stable is ordered by its - * version; a test build is newer when its PR's head moved. The last check is remembered, so the - * page shows it without going online. + * [Channel.NIGHTLY] is intentionally kept as the internal enum/pref value for upgrades from older + * DroidDeck versions. The channel is presented to users as Preview. */ object AppUpdates { private const val REPO = "Droid-Deck/DroidDeck" private const val CI_REPO = "Droid-Deck/DroidDeck-CI" + private const val CATALOG_URL = "https://raw.githubusercontent.com/$CI_REPO/main/catalog.json" private const val PREFS = "app_updates" private const val KEY_CATALOG = "catalog" private const val KEY_CHANNEL = "channel" + private const val CATALOG_SCHEMA = 1 enum class Channel { STABLE, NIGHTLY, TEST } - /** The channel the user follows; [pr] is the followed test build's PR, for [Channel.TEST]. */ data class Follow(val channel: Channel, val pr: Int = 0) - class Apk(val name: String, val url: String, val size: Long, val sha256: String) - - /** - * One published build. [commit] is a full or short SHA of the source it was built from; [pr] is - * 0 for a main or stable build. [apk] is the file for the package this app runs as, or null when - * the build has none (a variant a release predates). - */ - class Release( - val tag: String, val title: String, val summary: String, val commit: String, val pr: Int, - val version: String?, val publishedAt: Long, val url: String, val apk: Apk?, + class Apk( + val name: String, + val url: String, + val size: Long, + val sha256: String, + val packageName: String, + val versionCode: Int, + val signerSha256: String, ) - class Catalog(val stable: Release?, val nightly: Release?, val tests: List, val checkedAt: Long) + class Release( + val tag: String, + val title: String, + val summary: String, + val commit: String, + val pr: Int, + val version: String?, + val versionCode: Int, + val publishedAt: Long, + val url: String, + val apk: Apk?, + ) - /** - * What is running: the commit it was built from, its PR (0 for none), whether the builds here - * install over it (it is signed with the release key), and when it was committed. - */ - class Installed(val commit: String, val pr: Int, val version: String, val updatable: Boolean, val committedAt: Long = 0L) + class Catalog( + val stable: Release?, + val preview: Release?, + val tests: List, + val checkedAt: Long, + ) + + class Installed( + val commit: String, + val pr: Int, + val version: String, + val versionCode: Int, + val updatable: Boolean, + val committedAt: Long = 0L, + ) enum class Offer { - /** This is the build the channel has. */ CURRENT, - /** The channel has a newer build of what is running. */ UPDATE, - /** The channel's build is a different line (a test build from Nightly, or back); installing moves over. */ SWITCH, - /** What is running is newer than the channel's build (a Nightly past the last Stable). */ AHEAD, - /** The followed test build is gone: its PR was merged or closed. */ GONE, } @Volatile private var releaseSigned = BuildConfig.CI_BUILD - /** From App.onCreate: whether this copy is signed with the release key, which a local build may be too. */ fun init(context: Context) { - releaseSigned = runCatching { signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) } } - .getOrDefault(BuildConfig.CI_BUILD) + releaseSigned = runCatching { + signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) } + }.getOrDefault(BuildConfig.CI_BUILD) } - /** SHA-256 of each certificate the package is signed with, including the ones a rotated key vouches for. */ private fun signers(context: Context): List { val pm = context.packageManager @Suppress("DEPRECATION") val certs = if (Build.VERSION.SDK_INT >= 28) { - val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo ?: return emptyList() + val info = pm.getPackageInfo( + context.packageName, + PackageManager.GET_SIGNING_CERTIFICATES, + ).signingInfo ?: return emptyList() if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory } else { pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures } ?: return emptyList() - return certs.map { cert -> MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } } + return certs.map { cert -> + MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()) + .joinToString("") { "%02x".format(it) } + } } - fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, releaseSigned, BuildConfig.COMMIT_TIME) + fun installed(): Installed = Installed( + BuildConfig.SOURCE_COMMIT, + BuildConfig.PR_NUMBER, + BuildConfig.VERSION_NAME, + BuildConfig.VERSION_CODE, + releaseSigned, + BuildConfig.COMMIT_TIME, + ) fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) { Channel.STABLE -> catalog.stable - Channel.NIGHTLY -> catalog.nightly + Channel.NIGHTLY -> catalog.preview Channel.TEST -> catalog.tests.firstOrNull { it.pr == follow.pr } } @@ -112,19 +133,27 @@ object AppUpdates { } } - /** The dot on the rail: the followed channel has a newer build of what is running. */ - fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean = - catalog != null && installed().updatable && offer(catalog, follow) == Offer.UPDATE + fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean { + if (catalog == null) return false + val me = installed() + val r = release(catalog, follow) ?: return false + return me.updatable && offer(catalog, follow, me) == Offer.UPDATE && installBlock(r, me) == null + } fun isRunning(r: Release, me: Installed = installed()): Boolean { if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false - return me.commit.startsWith(r.commit, ignoreCase = true) || r.commit.startsWith(me.commit, ignoreCase = true) + return me.commit.startsWith(r.commit, ignoreCase = true) || + r.commit.startsWith(me.commit, ignoreCase = true) + } + + /** Why Android cannot install this published build over [me], before downloading anything. */ + fun installBlock(r: Release, me: Installed = installed()): String? = when { + r.apk == null -> "This build has no download for this copy of DroidDeck." + r.apk.versionCode < me.versionCode -> + "Android won't install it over this build because its versionCode ${r.apk.versionCode} is below the installed ${me.versionCode}." + else -> null } - /** - * The channel followed. Until the user picks one it is worked out once from what is running and - * kept: a test build follows its PR, the Stable release follows Stable, and a main build Nightly. - */ fun follow(context: Context, catalog: Catalog?): Follow { stored(context)?.let { return it } val me = installed() @@ -140,7 +169,9 @@ object AppUpdates { } fun setFollow(context: Context, follow: Follow) { - val value = if (follow.channel == Channel.TEST) "test:${follow.pr}" else follow.channel.name.lowercase() + // Keep "nightly" on disk so an older DroidDeck still understands the preference after rollback. + val value = if (follow.channel == Channel.TEST) "test:${follow.pr}" + else follow.channel.name.lowercase() prefs(context).edit().putString(KEY_CHANNEL, value).apply() } @@ -148,129 +179,114 @@ object AppUpdates { val v = prefs(context).getString(KEY_CHANNEL, null) ?: return null return when { v == "stable" -> Follow(Channel.STABLE) - v == "nightly" -> Follow(Channel.NIGHTLY) - v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let { Follow(Channel.TEST, it) } + v == "nightly" || v == "preview" -> Follow(Channel.NIGHTLY) + v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let { + Follow(Channel.TEST, it) + } else -> null } } - /** The last check, or null before the first. */ fun cached(context: Context): Catalog? { val raw = prefs(context).getString(KEY_CATALOG, null) ?: return null - return runCatching { readCatalog(JSONObject(raw)) }.getOrNull() + return runCatching { readCachedCatalog(JSONObject(raw)) }.getOrNull() } - /** Read every channel now and remember the result: three small requests, four for an annotated tag. */ + /** One static request, rather than several unauthenticated GitHub API requests. */ fun refresh(context: Context): Catalog { - val suffix = variantSuffix(context.packageName) - val stable = runCatching { stable(suffix) }.getOrElse { if (it is NoRelease) null else throw it } - val ci = JSONArray(get("https://api.github.com/repos/$CI_REPO/releases?per_page=50")) - var nightly: Release? = null - val tests = ArrayList() - for (i in 0 until ci.length()) { - val r = ci.getJSONObject(i) - if (r.optBoolean("draft")) continue - val tag = r.getString("tag_name") - when { - tag.startsWith("main-") -> { - val candidate = ciRelease(r, suffix, tag.removePrefix("main-"), 0) - if (nightly == null || candidate.publishedAt > nightly.publishedAt) nightly = candidate - } - tag.startsWith("pr-") -> { - val pr = tag.removePrefix("pr-").toIntOrNull() ?: continue - val body = r.optString("body") - // Only builds of this repo's PRs: a test build from before the org move names another. - if (!body.contains("github.com/$REPO/pull/$pr")) continue - val commit = Regex("""Commit: `?([0-9a-f]{7,40})""").find(body)?.groupValues?.get(1) ?: continue - tests.add(ciRelease(r, suffix, commit, pr)) - } - } - } - tests.sortByDescending { it.publishedAt } - val catalog = Catalog(stable, nightly, tests, System.currentTimeMillis()) + val catalog = readPublishedCatalog( + JSONObject(get(CATALOG_URL)), + context.packageName, + System.currentTimeMillis(), + ) prefs(context).edit().putString(KEY_CATALOG, writeCatalog(catalog).toString()).apply() return catalog } - private class NoRelease : IOException("no stable release") - - private fun stable(suffix: String): Release { - val r = try { - JSONObject(get("https://api.github.com/repos/$REPO/releases/latest")) - } catch (e: IOException) { - if (e.message?.contains("HTTP 404") == true) throw NoRelease() else throw e + internal fun readPublishedCatalog( + root: JSONObject, + packageName: String, + checkedAt: Long, + ): Catalog { + val schema = root.optInt("schema") + val sourceRepo = root.optString("sourceRepo") + val ciRepo = root.optString("ciRepo") + if (schema != CATALOG_SCHEMA || sourceRepo != REPO || ciRepo != CI_REPO) { + throw IOException( + "The update catalog has an unsupported format (schema $schema, source $sourceRepo, CI $ciRepo)", + ) } - val tag = r.getString("tag_name") - // The tag's commit; an annotated tag points at a tag object first. - var obj = JSONObject(get("https://api.github.com/repos/$REPO/git/ref/tags/$tag")).getJSONObject("object") - if (obj.getString("type") == "tag") obj = JSONObject(get(obj.getString("url"))).getJSONObject("object") - val (title, summary) = describeStable(r.optString("name").ifBlank { tag }, r.optString("body")) - return Release(tag, title, summary, obj.getString("sha"), 0, tag.removePrefix("v"), published(r), - r.optString("html_url"), apk(r, suffix)) + val tests = root.optJSONArray("tests") ?: JSONArray() + return Catalog( + root.optJSONObject("stable")?.let { readPublishedRelease(it, packageName) }, + root.optJSONObject("preview")?.let { readPublishedRelease(it, packageName) }, + (0 until tests.length()) + .map { readPublishedRelease(tests.getJSONObject(it), packageName) } + .filter { it.pr > 0 } + .sortedByDescending { it.publishedAt }, + checkedAt, + ) } - private fun ciRelease(r: JSONObject, suffix: String, commit: String, pr: Int): Release { - val (title, summary) = describeCi(r.optString("name"), r.optString("body")) - return Release(r.getString("tag_name"), title, summary, commit, pr, null, published(r), r.optString("html_url"), apk(r, suffix)) - } - - private fun published(r: JSONObject): Long = - runCatching { Instant.parse(r.optString("published_at")).toEpochMilli() }.getOrDefault(0L) - - /** The apk for this package: `.apk`, and for the standard app the one with no other variant's suffix. */ - private fun apk(r: JSONObject, suffix: String): Apk? { - val others = variantSuffixes().values.filter { it.isNotEmpty() && it != suffix } - val assets = r.optJSONArray("assets") ?: return null - for (i in 0 until assets.length()) { - val a = assets.getJSONObject(i) - val name = a.getString("name") - if (!name.endsWith("$suffix.apk") || others.any { name.endsWith("$it.apk") }) continue - // Only a file GitHub has a sha256 for: the download is checked against it. - val sha = Hashes.githubSha256(a.optString("digest")) ?: continue - return Apk(name, a.getString("browser_download_url"), a.optLong("size"), sha) + private fun readPublishedRelease(o: JSONObject, packageName: String): Release { + val commit = o.getString("commit") + if (!Regex("^[0-9a-fA-F]{7,40}$").matches(commit)) { + throw IOException("The update catalog contains an invalid commit") } - return null + val versionCode = o.getInt("versionCode") + if (versionCode <= 0) throw IOException("The update catalog contains an invalid versionCode") + val apk = o.optJSONObject("apks")?.optJSONObject(packageName)?.let { + readPublishedApk(it, packageName) + } + if (apk != null && apk.versionCode != versionCode) { + throw IOException("The update catalog disagrees about the APK versionCode") + } + return Release( + o.getString("tag"), + o.optString("title"), + o.optString("summary"), + commit, + o.optInt("pr"), + if (o.isNull("version")) null else o.optString("version").ifEmpty { null }, + versionCode, + o.optLong("publishedAt"), + o.optString("url"), + apk, + ) } - private fun variantSuffixes(): Map = BuildConfig.VARIANT_SUFFIXES.split(';') - .mapNotNull { e -> e.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }.toMap() - - private fun variantSuffix(packageName: String): String = variantSuffixes()[packageName] ?: "" - - /** - * A CI release's notes open with what it is: "**Merged to main** - [PR #n](…): title (merged …)" - * or "**Not merged** - a test build of open [PR #n](…): title", then a paragraph from the PR. - */ - internal fun describeCi(name: String, body: String): Pair { - val paragraphs = paragraphs(body) - val head = paragraphs.firstOrNull().orEmpty() - val title = head.substringAfter("): ", "").substringBeforeLast(" (merged").ifBlank { plain(head) }.ifBlank { name } - val summary = paragraphs.getOrNull(1)?.takeUnless { it.startsWith("Signed build") || it.startsWith("Source:") }.orEmpty() - return plain(title) to lines(summary) + private fun readPublishedApk(o: JSONObject, packageName: String): Apk { + val actualPackage = o.getString("packageName") + val versionCode = o.getInt("versionCode") + val signer = o.getString("signerSha256").lowercase() + val sha = o.getString("sha256").lowercase() + val url = o.getString("url") + if (actualPackage != packageName) throw IOException("The update catalog names the wrong Android package") + if (versionCode <= 0) throw IOException("The update catalog has an invalid APK versionCode") + if (!Regex("^[0-9a-f]{64}$").matches(sha)) { + throw IOException("The update catalog has an invalid checksum") + } + if (!signer.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) { + throw IOException("The update catalog is not signed for DroidDeck's release key") + } + val allowed = listOf( + "https://github.com/$REPO/releases/download/", + "https://github.com/$CI_REPO/releases/download/", + ) + if (allowed.none { url.startsWith(it) }) { + throw IOException("The update catalog has an unexpected download URL") + } + return Apk( + o.getString("name"), + url, + o.optLong("size"), + sha, + actualPackage, + versionCode, + signer, + ) } - /** A stable release: its name, and the first paragraph of its notes that is prose rather than a heading or picture. */ - private fun describeStable(name: String, body: String): Pair = - name to lines(paragraphs(body).firstOrNull { !it.startsWith("#") && !it.startsWith("![") && !it.startsWith("<") }.orEmpty()) - - private fun paragraphs(body: String): List = - body.replace("\r", "").split(Regex("\n\\s*\n")).map { it.trim() }.filter { it.isNotEmpty() } - - /** A paragraph's lines as text, a list item's marker as a bullet: the page shows them collapsed or in full. */ - private fun lines(md: String): String = md.lines().map { line -> - val item = Regex("""^\s*[-*]\s+""").find(line) - if (item != null) "• " + plain(line.substring(item.range.last + 1)) else plain(line) - }.filter { it.isNotEmpty() }.joinToString("\n") - - /** Markdown down to text: links to their words, emphasis and code marks gone, on one line. */ - private fun plain(md: String): String = md - .replace(Regex("""!\[[^]]*]\([^)]*\)"""), "") - .replace(Regex("""\[([^]]*)]\([^)]*\)"""), "$1") - .replace(Regex("""[*_`]{1,3}"""), "") - .replace(Regex("""\s+"""), " ") - .trim() - - /** Dotted versions, number by number: "0.10.0" is past "0.9.2"; a missing part counts as 0. */ fun compareVersions(a: String, b: String): Int { val x = a.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 } val y = b.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 } @@ -285,40 +301,79 @@ object AppUpdates { val c = URL(url).openConnection() as HttpURLConnection c.connectTimeout = 15_000 c.readTimeout = 20_000 - c.setRequestProperty("Accept", "application/vnd.github+json") + c.setRequestProperty("Accept", "application/json") c.setRequestProperty("User-Agent", "DroidDeck-app") + c.setRequestProperty("Cache-Control", "no-cache") try { val code = c.responseCode - if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later") - if (code != 200) throw IOException("GitHub answered HTTP $code") + if (code != 200) throw IOException("The update catalog answered HTTP $code") return c.inputStream.bufferedReader().use { it.readText() } } finally { c.disconnect() } } - private fun writeRelease(r: Release): JSONObject = JSONObject() - .put("tag", r.tag).put("title", r.title).put("summary", r.summary).put("commit", r.commit).put("pr", r.pr) - .put("version", r.version).put("publishedAt", r.publishedAt).put("url", r.url) - .put("apk", r.apk?.let { JSONObject().put("name", it.name).put("url", it.url).put("size", it.size).put("sha256", it.sha256) }) + private fun writeApk(a: Apk): JSONObject = JSONObject() + .put("name", a.name) + .put("url", a.url) + .put("size", a.size) + .put("sha256", a.sha256) + .put("packageName", a.packageName) + .put("versionCode", a.versionCode) + .put("signerSha256", a.signerSha256) - private fun readRelease(o: JSONObject): Release = Release( - o.getString("tag"), o.optString("title"), o.optString("summary"), o.getString("commit"), o.optInt("pr"), - o.optString("version").ifEmpty { null }, o.optLong("publishedAt"), o.optString("url"), - o.optJSONObject("apk")?.let { Apk(it.getString("name"), it.getString("url"), it.optLong("size"), it.getString("sha256")) }, + private fun writeRelease(r: Release): JSONObject = JSONObject() + .put("tag", r.tag) + .put("title", r.title) + .put("summary", r.summary) + .put("commit", r.commit) + .put("pr", r.pr) + .put("version", r.version) + .put("versionCode", r.versionCode) + .put("publishedAt", r.publishedAt) + .put("url", r.url) + .put("apk", r.apk?.let(::writeApk)) + + private fun readCachedRelease(o: JSONObject): Release = Release( + o.getString("tag"), + o.optString("title"), + o.optString("summary"), + o.getString("commit"), + o.optInt("pr"), + if (o.isNull("version")) null else o.optString("version").ifEmpty { null }, + o.getInt("versionCode"), + o.optLong("publishedAt"), + o.optString("url"), + o.optJSONObject("apk")?.let { + Apk( + it.getString("name"), + it.getString("url"), + it.optLong("size"), + it.getString("sha256"), + it.getString("packageName"), + it.getInt("versionCode"), + it.getString("signerSha256"), + ) + }, ) private fun writeCatalog(c: Catalog): JSONObject = JSONObject() - .put("stable", c.stable?.let(::writeRelease)).put("nightly", c.nightly?.let(::writeRelease)) - .put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } }).put("checkedAt", c.checkedAt) + .put("stable", c.stable?.let(::writeRelease)) + .put("preview", c.preview?.let(::writeRelease)) + .put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } }) + .put("checkedAt", c.checkedAt) - private fun readCatalog(o: JSONObject): Catalog { + private fun readCachedCatalog(o: JSONObject): Catalog { val tests = o.optJSONArray("tests") ?: JSONArray() + val preview = o.optJSONObject("preview") ?: o.optJSONObject("nightly") return Catalog( - o.optJSONObject("stable")?.let(::readRelease), o.optJSONObject("nightly")?.let(::readRelease), - (0 until tests.length()).map { readRelease(tests.getJSONObject(it)) }, o.optLong("checkedAt"), + o.optJSONObject("stable")?.let(::readCachedRelease), + preview?.let(::readCachedRelease), + (0 until tests.length()).map { readCachedRelease(tests.getJSONObject(it)) }, + o.optLong("checkedAt"), ) } - private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) } diff --git a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt index 3a6e36fed..cf69b45aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt @@ -4,9 +4,11 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.Settings +import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.core.Hashes import java.io.File @@ -14,6 +16,7 @@ import java.io.FileOutputStream import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest /** * Installs one of DroidDeck's own builds over itself: download into the cache, check the file @@ -60,9 +63,13 @@ object SelfInstaller { } } } + if (apk.size > 0 && target.length() != apk.size) { + throw IOException("The download size didn't match the published APK and was discarded - try again") + } if (!apk.sha256.equals(Hashes.sha256(target), ignoreCase = true)) { throw IOException("The download didn't match its checksum and was discarded - try again") } + validateDownloaded(context, target, apk) return target } catch (e: Exception) { FileUtils.delete(target) @@ -72,6 +79,48 @@ object SelfInstaller { } } + /** Inspect the actual downloaded APK before giving it to Android's installer. */ + private fun validateDownloaded(context: Context, file: File, apk: AppUpdates.Apk) { + if (apk.packageName != context.packageName) { + throw IOException("The update is for Android package ${apk.packageName}, not ${context.packageName}") + } + if (!apk.signerSha256.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) { + throw IOException("The update metadata doesn't name DroidDeck's release signing key") + } + @Suppress("DEPRECATION") + val flags = if (Build.VERSION.SDK_INT >= 28) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + val info = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags) + ?: throw IOException("Android couldn't read the downloaded APK") + if (info.packageName != context.packageName) { + throw IOException("The downloaded APK is for ${info.packageName}, not ${context.packageName}") + } + @Suppress("DEPRECATION") + val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong() + if (code != apk.versionCode.toLong()) { + throw IOException("The downloaded APK says versionCode $code, not the published ${apk.versionCode}") + } + if (code < BuildConfig.VERSION_CODE.toLong()) { + throw IOException("Android won't install versionCode $code over the installed ${BuildConfig.VERSION_CODE}") + } + + // Before Android 9 this lineage intentionally presents the public legacy test key. Android + // 9+ understands the v3 hand-over and must see DroidDeck's private release key as current. + if (Build.VERSION.SDK_INT >= 28) { + val signing = info.signingInfo ?: throw IOException("The downloaded APK has no signing information") + val digests = signing.apkContentsSigners.orEmpty().map { cert -> + MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()) + .joinToString("") { "%02x".format(it) } + } + if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) { + throw IOException("The downloaded APK isn't signed with DroidDeck's release key") + } + } + } + /** Writes [apk] into an install session and commits it; the answer comes as an [ACTION_STATUS] broadcast. */ fun install(context: Context, apk: File) { val installer = context.packageManager.packageInstaller diff --git a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt index 6e57f3d67..c3c315aa6 100644 --- a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt @@ -1,26 +1,69 @@ package com.droiddeck.launcher.update +import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.update.AppUpdates.Channel import com.droiddeck.launcher.update.AppUpdates.Follow import com.droiddeck.launcher.update.AppUpdates.Installed import com.droiddeck.launcher.update.AppUpdates.Offer import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.json.JSONObject import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException +@RunWith(RobolectricTestRunner::class) class AppUpdatesTest { - private fun release(commit: String, pr: Int = 0, version: String? = null) = - AppUpdates.Release("t", "title", "", commit, pr, version, 0L, "", null) + private fun apk(versionCode: Int = 9) = AppUpdates.Apk( + "DroidDeck.apk", + "https://github.com/Droid-Deck/DroidDeck-CI/releases/download/t/DroidDeck.apk", + 10, + "a".repeat(64), + "com.droiddeck.launcher", + versionCode, + BuildConfig.RELEASE_SIGNER, + ) - private val stable = release("aaaaaaa1111111111111111111111111111111", version = "0.2.0") + private fun release( + commit: String, + pr: Int = 0, + version: String? = null, + versionCode: Int = 9, + withApk: Boolean = true, + ) = AppUpdates.Release( + "t", + "title", + "", + commit, + pr, + version, + versionCode, + 0L, + "", + if (withApk) apk(versionCode) else null, + ) + + private val stable = release( + "aaaaaaa1111111111111111111111111111111", + version = "0.2.0", + ) private val catalog = AppUpdates.Catalog( stable = stable, - nightly = release("bbbbbbb"), + preview = release("bbbbbbb"), tests = listOf(release("ccccccc2222222222222222222222222222222", pr = 93)), checkedAt = 0L, ) - private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, updatable = true) + private fun me( + commit: String, + pr: Int = 0, + version: String = "0.2.0", + versionCode: Int = 9, + ) = Installed(commit, pr, version, versionCode, updatable = true) @Test fun theStableBuildIsCurrentOnStable() { @@ -28,17 +71,20 @@ class AppUpdatesTest { } @Test - fun aNightlyPastStableIsAheadNotBehind() { + fun aPreviewPastStableIsAheadNotBehind() { assertEquals(Offer.AHEAD, AppUpdates.offer(catalog, Follow(Channel.STABLE), me("ddddddd999"))) } @Test fun aNewerStableVersionIsAnUpdate() { - assertEquals(Offer.UPDATE, AppUpdates.offer(catalog, Follow(Channel.STABLE), me("eeeeeee999", version = "0.1.9"))) + assertEquals( + Offer.UPDATE, + AppUpdates.offer(catalog, Follow(Channel.STABLE), me("eeeeeee999", version = "0.1.9")), + ) } @Test - fun nightlyMatchesItsShortShaAndOffersTheNewest() { + fun previewMatchesItsShortShaAndOffersTheNewest() { assertEquals(Offer.CURRENT, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me("bbbbbbb0123456789"))) assertEquals(Offer.UPDATE, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me(stable.commit))) } @@ -52,7 +98,7 @@ class AppUpdatesTest { } @Test - fun aTestBuildIsNeverTheMainBuildOfTheSameCommit() { + fun aTestBuildIsNeverThePreviewBuildOfTheSameCommit() { assertEquals(Offer.SWITCH, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me("bbbbbbb", pr = 5))) } @@ -64,20 +110,77 @@ class AppUpdatesTest { } @Test - fun ciNotesGiveTheChangeAndItsSummary() { - val (title, summary) = AppUpdates.describeCi( - "main 8a61a18", - "**Merged to main** - [PR #95](https://github.com/Droid-Deck/DroidDeck/pull/95): fix: keep `Steam` alive (merged 2026-09-30)\n\n" + - "Steam no longer **exits** on resume.\n\nSigned build of https://github.com/x - in main.", + fun aVersionCodeDowngradeIsBlockedBeforeDownload() { + val older = release("ddddddd", versionCode = 8) + assertNotNull(AppUpdates.installBlock(older, me("eeeeeee", versionCode = 9))) + assertNull(AppUpdates.installBlock(stable, me("eeeeeee", versionCode = 9))) + } + + @Test + fun aMissingVariantIsBlockedBeforeDownload() { + val missing = release("ddddddd", withApk = false) + assertNotNull(AppUpdates.installBlock(missing, me("eeeeeee"))) + } + + private fun publishedCatalog(signer: String = BuildConfig.RELEASE_SIGNER): JSONObject { + val raw = """ + { + "schema": 1, + "sourceRepo": "Droid-Deck/DroidDeck", + "ciRepo": "Droid-Deck/DroidDeck-CI", + "stable": null, + "preview": { + "tag": "main-deadbee", + "title": "Preview", + "summary": "", + "commit": "deadbee1234567890", + "pr": 0, + "version": null, + "versionCode": 9, + "publishedAt": 1, + "url": "https://github.com/Droid-Deck/DroidDeck-CI/releases/tag/main-deadbee", + "apks": { + "com.droiddeck.launcher": { + "name": "DroidDeck-main-deadbee.apk", + "url": "https://github.com/Droid-Deck/DroidDeck-CI/releases/download/main-deadbee/DroidDeck-main-deadbee.apk", + "size": 10, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "packageName": "com.droiddeck.launcher", + "versionCode": 9, + "signerSha256": "__SIGNER__" + } + } + }, + "tests": [] + } + """.trimIndent().replace("__SIGNER__", signer) + return JSONObject(raw) + } + + @Test + fun publishedCatalogSelectsThisPackagesApk() { + val parsed = AppUpdates.readPublishedCatalog( + publishedCatalog(), + "com.droiddeck.launcher", + 123L, ) - assertEquals("fix: keep Steam alive", title) - assertEquals("Steam no longer exits on resume.", summary) - val (prTitle, prSummary) = AppUpdates.describeCi( - "PR #93", - "**Not merged** - a test build of open [PR #93](https://github.com/Droid-Deck/DroidDeck/pull/93): fix: report Adreno 840v2\n\n" + - "Source: https://github.com/Droid-Deck/DroidDeck/pull/93\nCommit: 93382ff", - ) - assertEquals("fix: report Adreno 840v2", prTitle) - assertEquals("", prSummary) + assertEquals("main-deadbee", parsed.preview!!.tag) + assertEquals("com.droiddeck.launcher", parsed.preview!!.apk!!.packageName) + assertEquals(9, parsed.preview!!.apk!!.versionCode) + assertNull(parsed.preview!!.version) + assertEquals(123L, parsed.checkedAt) + } + + @Test + fun publishedCatalogRejectsAnotherSigner() { + try { + AppUpdates.readPublishedCatalog( + publishedCatalog("0".repeat(64)), + "com.droiddeck.launcher", + 123L, + ) + fail("wrong signer should be rejected") + } catch (_: IOException) { + } } } diff --git a/tools/release/update_catalog.py b/tools/release/update_catalog.py new file mode 100755 index 000000000..f4d543ad5 --- /dev/null +++ b/tools/release/update_catalog.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Build and publish the one-file update catalog consumed by DroidDeck. + +The catalog is derived from GitHub releases, but the Android app never has to infer +channels, parse release notes, dereference tags, or spend unauthenticated API quota. +""" + +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import json +import os +import re +import time +import urllib.error +import urllib.parse +import urllib.request + +API = "https://api.github.com" +SHA256 = re.compile(r"^[0-9a-f]{64}$") +PR_TAG = re.compile(r"^pr-([1-9][0-9]*)$") +MAIN_TAG = re.compile(r"^main-([0-9a-f]{7,40})$") + + +class ApiError(RuntimeError): + def __init__(self, status: int, message: str): + super().__init__(f"GitHub API {status}: {message}") + self.status = status + + +class GitHub: + def __init__(self, token: str): + self.token = token + + def request(self, method: str, path: str, body=None): + data = None + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "DroidDeck-update-catalog", + "X-GitHub-Api-Version": "2022-11-28", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(API + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as response: + raw = response.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + raise ApiError(e.code, raw[:500]) from e + + def get(self, path: str): + return self.request("GET", path) + + def put(self, path: str, body): + return self.request("PUT", path, body) + + +def q(value: str) -> str: + return urllib.parse.quote(value, safe="") + + +def repo_file(gh: GitHub, repo: str, path: str, ref: str) -> str: + obj = gh.get(f"/repos/{repo}/contents/{q(path)}?ref={q(ref)}") + if obj.get("encoding") != "base64": + raise RuntimeError(f"{repo}:{ref}:{path} was not base64 content") + return base64.b64decode(obj["content"]).decode() + + +def resolve_tag(gh: GitHub, repo: str, tag: str) -> str: + obj = gh.get(f"/repos/{repo}/git/ref/tags/{q(tag)}")["object"] + while obj["type"] == "tag": + obj = gh.get(f"/repos/{repo}/git/tags/{q(obj['sha'])}")["object"] + if obj["type"] != "commit": + raise RuntimeError(f"{repo} tag {tag} points to {obj['type']}, not a commit") + return obj["sha"] + + +def parse_gradle(text: str) -> tuple[int, str | None]: + code = re.search(r"(?m)^\s*versionCode\s+(\d+)\s*$", text) + name = re.search(r'(?m)^\s*versionName\s+"([^"]+)"\s*$', text) + if not code: + raise RuntimeError("app/build.gradle has no numeric versionCode") + return int(code.group(1)), name.group(1) if name else None + + +def parse_variants(text: str) -> list[tuple[str, str]]: + out = [] + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + fields = line.split() + if len(fields) < 3: + raise RuntimeError(f"bad variants.txt row: {raw!r}") + out.append((fields[1], "" if fields[2] == "-" else fields[2])) + if not out: + raise RuntimeError("variants.txt has no packages") + return out + + +def published_millis(release: dict) -> int: + value = release.get("published_at") or release.get("created_at") + if not value: + return 0 + return int(dt.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + + +def paragraphs(markdown: str) -> list[str]: + return [p.strip() for p in re.split(r"\n\s*\n", markdown.replace("\r", "")) if p.strip()] + + +def plain(markdown: str) -> str: + text = re.sub(r"!\[[^]]*]\([^)]*\)", "", markdown) + text = re.sub(r"\[([^]]*)]\([^)]*\)", r"\1", text) + text = re.sub(r"[*_`]{1,3}", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def prose_lines(markdown: str) -> str: + lines = [] + for raw in markdown.splitlines(): + match = re.match(r"^\s*[-*]\s+", raw) + value = ("• " + plain(raw[match.end():])) if match else plain(raw) + if value: + lines.append(value) + return "\n".join(lines) + + +def ci_description(release: dict) -> tuple[str, str]: + ps = paragraphs(release.get("body") or "") + head = ps[0] if ps else "" + title = "" + if "): " in head: + title = head.split("): ", 1)[1] + title = re.sub(r" \(merged[^)]*\)$", "", title) + title = plain(title or head or release.get("name") or release["tag_name"]) + summary = ps[1] if len(ps) > 1 else "" + if summary.startswith("Signed build") or summary.startswith("Source:"): + summary = "" + return title, prose_lines(summary) + + +def stable_description(release: dict) -> tuple[str, str]: + name = release.get("name") or release["tag_name"] + summary = "" + for p in paragraphs(release.get("body") or ""): + if not (p.startswith("#") or p.startswith("![") or p.startswith("<")): + summary = prose_lines(p) + break + return name, summary + + +def source_meta(source: GitHub, repo: str, ref: str): + gradle = repo_file(source, repo, "app/build.gradle", ref) + code, version = parse_gradle(gradle) + variants = parse_variants(repo_file(source, repo, "tools/release/variants.txt", ref)) + signer = repo_file(source, repo, "keystore/release-signer.sha256", ref).strip().lower() + if not SHA256.fullmatch(signer): + raise RuntimeError(f"{repo}:{ref} has an invalid release signer digest") + return code, version, variants, signer + + +def asset_for_suffix(assets: list[dict], suffix: str, all_suffixes: list[str]): + others = [s for s in all_suffixes if s and s != suffix] + for asset in assets: + name = asset.get("name", "") + if not name.endswith(f"{suffix}.apk"): + continue + if any(name.endswith(f"{other}.apk") for other in others): + continue + digest = (asset.get("digest") or "").lower() + if not digest.startswith("sha256:") or not SHA256.fullmatch(digest[7:]): + continue + return { + "name": name, + "url": asset["browser_download_url"], + "size": int(asset.get("size") or 0), + "sha256": digest[7:], + } + return None + + +def release_entry( + release: dict, + commit: str, + pr: int, + version: str | None, + version_code: int, + variants: list[tuple[str, str]], + signer: str, + title: str, + summary: str, +): + suffixes = [suffix for _, suffix in variants] + apks = {} + for package, suffix in variants: + asset = asset_for_suffix(release.get("assets") or [], suffix, suffixes) + if not asset: + continue + asset.update( + { + "packageName": package, + "versionCode": version_code, + "signerSha256": signer, + } + ) + apks[package] = asset + return { + "tag": release["tag_name"], + "title": title, + "summary": summary, + "commit": commit, + "pr": pr, + "version": version, + "versionCode": version_code, + "publishedAt": published_millis(release), + "url": release.get("html_url") or "", + "apks": apks, + } + + +def release_body_value(body: str, key: str): + match = re.search(rf"(?mi)^\s*{re.escape(key)}:\s*`?([^\s`]+)", body or "") + return match.group(1) if match else None + + +def main_commit(release: dict) -> str | None: + body = release.get("body") or "" + explicit = release_body_value(body, "Commit") + if explicit and re.fullmatch(r"[0-9a-f]{7,40}", explicit): + return explicit + match = re.search(r"/commit/([0-9a-f]{40})", body) + return match.group(1) if match else None + + +def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) -> dict: + try: + stable_release = source.get(f"/repos/{source_repo}/releases/latest") + except ApiError as e: + if e.status != 404: + raise + stable_release = None + + stable = None + if stable_release: + stable_commit = resolve_tag(source, source_repo, stable_release["tag_name"]) + code, version_name, variants, signer = source_meta(source, source_repo, stable_commit) + title, summary = stable_description(stable_release) + stable = release_entry( + stable_release, + stable_commit, + 0, + version_name or stable_release["tag_name"].removeprefix("v"), + code, + variants, + signer, + title, + summary, + ) + + releases = ci.get(f"/repos/{ci_repo}/releases?per_page=100") + previews = [] + tests = [] + for release in releases: + if release.get("draft"): + continue + tag = release.get("tag_name") or "" + main_match = MAIN_TAG.fullmatch(tag) + if main_match: + commit = main_commit(release) + if not commit: + continue + try: + code, _, variants, signer = source_meta(source, source_repo, commit) + except ApiError: + continue + title, summary = ci_description(release) + previews.append( + release_entry(release, commit, 0, None, code, variants, signer, title, summary) + ) + continue + + pr_match = PR_TAG.fullmatch(tag) + if not pr_match: + continue + pr = int(pr_match.group(1)) + body = release.get("body") or "" + if f"github.com/{source_repo}/pull/{pr}" not in body: + continue + try: + pull = source.get(f"/repos/{source_repo}/pulls/{pr}") + except ApiError: + continue + if pull.get("state") != "open" or pull.get("draft") or pull.get("base", {}).get("ref") != "main": + continue + commit = release_body_value(body, "Commit") + if not commit or not re.fullmatch(r"[0-9a-f]{7,40}", commit): + continue + base_ref = release_body_value(body, "Base") or pull["base"]["sha"] + try: + base_code, _, variants, signer = source_meta(source, source_repo, base_ref) + except ApiError: + continue + version_code_text = release_body_value(body, "VersionCode") + if version_code_text and version_code_text.isdigit(): + version_code = int(version_code_text) + else: + # Legacy test releases did not record it. If the PR did not change build.gradle, + # the base value is exact; otherwise use the PR head's value. + version_code = base_code + try: + changed = source.get(f"/repos/{source_repo}/pulls/{pr}/files?per_page=100") + if any(item.get("filename") == "app/build.gradle" for item in changed): + head_repo = pull["head"]["repo"]["full_name"] + version_code = parse_gradle(repo_file(source, head_repo, "app/build.gradle", pull["head"]["sha"]))[0] + except (ApiError, KeyError): + pass + title, summary = ci_description(release) + tests.append( + release_entry(release, commit, pr, None, version_code, variants, signer, title, summary) + ) + + previews.sort(key=lambda r: r["publishedAt"], reverse=True) + tests.sort(key=lambda r: r["publishedAt"], reverse=True) + return { + "schema": 1, + "sourceRepo": source_repo, + "ciRepo": ci_repo, + "generatedAt": int(time.time() * 1000), + "stable": stable, + "preview": previews[0] if previews else None, + "tests": tests, + } + + +def current_file(ci: GitHub, repo: str, path: str): + try: + return ci.get(f"/repos/{repo}/contents/{q(path)}?ref=main") + except ApiError as e: + if e.status == 404: + return None + raise + + +def publish_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str, path: str): + for attempt in range(4): + catalog = build_catalog(source, ci, source_repo, ci_repo) + content = (json.dumps(catalog, indent=2, sort_keys=False) + "\n").encode() + old = current_file(ci, ci_repo, path) + body = { + "message": "ci: refresh DroidDeck update catalog", + "content": base64.b64encode(content).decode(), + "branch": "main", + } + if old: + body["sha"] = old["sha"] + try: + old_bytes = base64.b64decode(old["content"]) + old_json = json.loads(old_bytes) + old_json.pop("generatedAt", None) + new_json = dict(catalog) + new_json.pop("generatedAt", None) + if old_json == new_json: + print("catalog contents are already current") + return catalog + except Exception: + pass + try: + ci.put(f"/repos/{ci_repo}/contents/{q(path)}", body) + print(f"published https://raw.githubusercontent.com/{ci_repo}/main/{path}") + return catalog + except ApiError as e: + if e.status not in (409, 422) or attempt == 3: + raise + time.sleep(1 + attempt) + raise RuntimeError("catalog publish retry loop exhausted") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--source-repo", default="Droid-Deck/DroidDeck") + parser.add_argument("--ci-repo", default="Droid-Deck/DroidDeck-CI") + parser.add_argument("--path", default="catalog.json") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + source = GitHub(os.environ.get("SOURCE_GITHUB_TOKEN", "")) + ci = GitHub(os.environ.get("CI_GITHUB_TOKEN", os.environ.get("SOURCE_GITHUB_TOKEN", ""))) + if args.dry_run: + print(json.dumps(build_catalog(source, ci, args.source_repo, args.ci_repo), indent=2)) + else: + if not os.environ.get("CI_GITHUB_TOKEN"): + raise SystemExit("CI_GITHUB_TOKEN is required to publish") + publish_catalog(source, ci, args.source_repo, args.ci_repo, args.path) + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test_update_catalog.py b/tools/tests/test_update_catalog.py new file mode 100644 index 000000000..2e316a262 --- /dev/null +++ b/tools/tests/test_update_catalog.py @@ -0,0 +1,80 @@ +import unittest + +from tools.release.update_catalog import ( + asset_for_suffix, + ci_description, + parse_gradle, + parse_variants, + release_body_value, +) + + +class UpdateCatalogTest(unittest.TestCase): + def test_gradle_metadata(self): + code, name = parse_gradle("""android { + defaultConfig { + versionCode 12 + versionName "0.3.1" + } +} +""") + self.assertEqual(12, code) + self.assertEqual("0.3.1", name) + + def test_variants(self): + rows = parse_variants(""" +# name package suffix +standard com.droiddeck.launcher - +pubg com.tencent.ig -pubg +""") + self.assertEqual( + [("com.droiddeck.launcher", ""), ("com.tencent.ig", "-pubg")], + rows, + ) + + def test_standard_asset_does_not_take_a_variant(self): + assets = [ + { + "name": "DroidDeck-main-deadbee-pubg.apk", + "size": 10, + "digest": "sha256:" + "a" * 64, + "browser_download_url": "https://example/pubg", + }, + { + "name": "DroidDeck-main-deadbee.apk", + "size": 11, + "digest": "sha256:" + "b" * 64, + "browser_download_url": "https://example/standard", + }, + ] + standard = asset_for_suffix(assets, "", ["", "-pubg"]) + pubg = asset_for_suffix(assets, "-pubg", ["", "-pubg"]) + self.assertEqual("DroidDeck-main-deadbee.apk", standard["name"]) + self.assertEqual("DroidDeck-main-deadbee-pubg.apk", pubg["name"]) + + def test_ci_description(self): + title, summary = ci_description( + { + "tag_name": "main-deadbee", + "name": "main deadbee", + "body": ( + "**Merged to main** - [PR #9](https://github.com/Droid-Deck/DroidDeck/pull/9): " + "fix: keep Steam alive (merged 2026-09-30)\n\n" + "Steam no longer **exits** on resume.\n\n" + "Signed build of https://example" + ), + } + ) + self.assertEqual("fix: keep Steam alive", title) + self.assertEqual("Steam no longer exits on resume.", summary) + + def test_release_body_metadata(self): + tick = chr(96) + body = f"Commit: {tick}abcdef1234567{tick}\nBase: 1234567\nVersionCode: 42\n" + self.assertEqual("abcdef1234567", release_body_value(body, "Commit")) + self.assertEqual("1234567", release_body_value(body, "Base")) + self.assertEqual("42", release_body_value(body, "VersionCode")) + + +if __name__ == "__main__": + unittest.main() From 79f04eb69e2058b2217f73d0e2efae0ee3f03848 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 22:54:32 -0400 Subject: [PATCH 14/28] docs: call main builds Preview --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea1242bb2..2c89fa28b 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ 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. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md). +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/Droid-Deck/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. ## Community -Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, nightly builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it. +Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it. ## Build From aece4b16bc0fc5395ffaf715e39b4f253886ca0a Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 22:57:43 -0400 Subject: [PATCH 15/28] fix: bypass stale update catalog caches --- .../main/java/com/droiddeck/launcher/update/AppUpdates.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 3310951d0..fe0498b62 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -194,10 +194,11 @@ object AppUpdates { /** One static request, rather than several unauthenticated GitHub API requests. */ fun refresh(context: Context): Catalog { + val checkedAt = System.currentTimeMillis() val catalog = readPublishedCatalog( - JSONObject(get(CATALOG_URL)), + JSONObject(get("$CATALOG_URL?checked=$checkedAt")), context.packageName, - System.currentTimeMillis(), + checkedAt, ) prefs(context).edit().putString(KEY_CATALOG, writeCatalog(catalog).toString()).apply() return catalog From 9fe3ce7a8681f51edca9708490bcf0e29578ae7c Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 23:04:34 -0400 Subject: [PATCH 16/28] fix: reflect republished PR test freshness --- tools/release/update_catalog.py | 22 ++++++++++++++++++---- tools/tests/test_update_catalog.py | 11 +++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/tools/release/update_catalog.py b/tools/release/update_catalog.py index f4d543ad5..ee0799273 100755 --- a/tools/release/update_catalog.py +++ b/tools/release/update_catalog.py @@ -105,8 +105,10 @@ def parse_variants(text: str) -> list[tuple[str, str]]: return out -def published_millis(release: dict) -> int: - value = release.get("published_at") or release.get("created_at") +def published_millis(release: dict, prefer_updated: bool = False) -> int: + value = ( + release.get("updated_at") if prefer_updated else None + ) or release.get("published_at") or release.get("created_at") if not value: return 0 return int(dt.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) @@ -197,6 +199,7 @@ def release_entry( signer: str, title: str, summary: str, + prefer_updated: bool = False, ): suffixes = [suffix for _, suffix in variants] apks = {} @@ -220,7 +223,7 @@ def release_entry( "pr": pr, "version": version, "versionCode": version_code, - "publishedAt": published_millis(release), + "publishedAt": published_millis(release, prefer_updated), "url": release.get("html_url") or "", "apks": apks, } @@ -324,7 +327,18 @@ def build_catalog(source: GitHub, ci: GitHub, source_repo: str, ci_repo: str) -> pass title, summary = ci_description(release) tests.append( - release_entry(release, commit, pr, None, version_code, variants, signer, title, summary) + release_entry( + release, + commit, + pr, + None, + version_code, + variants, + signer, + title, + summary, + prefer_updated=True, + ) ) previews.sort(key=lambda r: r["publishedAt"], reverse=True) diff --git a/tools/tests/test_update_catalog.py b/tools/tests/test_update_catalog.py index 2e316a262..f0b77cb7f 100644 --- a/tools/tests/test_update_catalog.py +++ b/tools/tests/test_update_catalog.py @@ -5,6 +5,7 @@ from tools.release.update_catalog import ( ci_description, parse_gradle, parse_variants, + published_millis, release_body_value, ) @@ -75,6 +76,16 @@ pubg com.tencent.ig -pubg self.assertEqual("1234567", release_body_value(body, "Base")) self.assertEqual("42", release_body_value(body, "VersionCode")) + def test_republished_pr_uses_updated_time_but_other_channels_use_publish_time(self): + release = { + "published_at": "2026-09-30T10:00:00Z", + "updated_at": "2026-09-30T12:00:00Z", + } + self.assertLess( + published_millis(release), + published_millis(release, prefer_updated=True), + ) + if __name__ == "__main__": unittest.main() From 3b849607f68f3e4b3923a42eb692363c3e5a0807 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 23:06:09 -0400 Subject: [PATCH 17/28] fix: allow catalog to inspect active tests --- .github/workflows/pr-release-cleanup.yml | 1 + .github/workflows/update-catalog.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/pr-release-cleanup.yml b/.github/workflows/pr-release-cleanup.yml index 29ebbdefc..0cd4867e6 100644 --- a/.github/workflows/pr-release-cleanup.yml +++ b/.github/workflows/pr-release-cleanup.yml @@ -11,6 +11,7 @@ on: permissions: contents: read + pull-requests: read jobs: cleanup: diff --git a/.github/workflows/update-catalog.yml b/.github/workflows/update-catalog.yml index 83b948d40..25b40a399 100644 --- a/.github/workflows/update-catalog.yml +++ b/.github/workflows/update-catalog.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: update-app-catalog From df730c472acbbc1a5b20aa5f66794419faa4d16c Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 23:25:47 -0400 Subject: [PATCH 18/28] fix: finish PR test publish after signing --- .github/workflows/pr-release-publish.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index d8caa090d..f78700eef 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -16,7 +16,7 @@ permissions: actions: read contents: read issues: write - pull-requests: read + pull-requests: write concurrency: group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} @@ -130,7 +130,7 @@ jobs: actions: read contents: read issues: write - pull-requests: read + pull-requests: write steps: - uses: actions/download-artifact@v4 with: @@ -211,6 +211,14 @@ jobs: echo "release=$release" >> "$GITHUB_OUTPUT" echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT" + - name: Clear the publish request label + if: ${{ github.event_name == 'pull_request_target' && steps.publish.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + run: | + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/publish-test" + - name: Report the signed build on the PR env: GH_TOKEN: ${{ github.token }} @@ -243,15 +251,8 @@ jobs: gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null fi - - name: Clear the publish request label - if: github.event_name == 'pull_request_target' - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} - run: | - gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/publish-test" - - name: Post to Discord + if: ${{ always() && steps.publish.outcome == 'success' }} env: WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }} GH_TOKEN: ${{ github.token }} From ff4f6edf080dd4c0b7da4721ce13851a881e232d Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 30 Sep 2026 23:27:27 -0400 Subject: [PATCH 19/28] fix: show QAM battery life and charge time estimates --- .../launcher/session/BatteryComponent.kt | 23 ++++++++++++++++++- .../launcher/session/SessionService.kt | 6 ++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt index f15a8517b..9b0834e17 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt @@ -18,11 +18,15 @@ import kotlin.math.abs * from Android's own battery API - the same on every device - into a directory the session binds * over `/sys/class/power_supply`, refreshed every few seconds while the session runs. * + * Steam's Quick Access Menu takes its time estimates from neither: it reads the files in `/run/vpower` (as on + * SteamOS), and with those absent showed "?h ?m" for projected battery life and for time to full. + * The same estimates are written there too, into [vpowerDir] in the guest's own `/run`. + * * Values: charge in µAh from the charge counter; energy in µWh from charge × voltage; power in µW * from |current| × voltage; time to empty from charge ÷ |average current| while discharging. Where * a device reports no current, the client still gets status and percentage. */ -class BatteryComponent(val dir: File) : SessionPart() { +class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart() { @Volatile private var running = false private var thread: Thread? = null @@ -42,6 +46,8 @@ class BatteryComponent(val dir: File) : SessionPart() { running = false thread?.interrupt() thread = null + // Stale estimates would outlive the session in the rootfs; Steam falls back to "?h ?m". + for (name in VPOWER_FILES) File(vpowerDir, name).delete() } private fun write() { @@ -115,6 +121,17 @@ class BatteryComponent(val dir: File) : SessionPart() { for ((k, v) in attrs) uevent.append("POWER_SUPPLY_").append(k.uppercase()).append('=').append(v).append('\n') put(File(bat, "uevent"), uevent.toString()) } + val plugged = (sticky?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0) != 0 + vpowerDir.mkdirs() + val vpower = linkedMapOf( + "ac_status" to if (plugged) "Connected" else "Disconnected", + "battery_percent" to "%.2f".format(java.util.Locale.ROOT, pct.toDouble()), + "battery_status" to statusText, + // Steam reads a separate file for each direction; -1 = no estimate. + "secs_until_battery_full" to (if (timeToFullS > 0) timeToFullS else -1L).toString(), + "secs_until_shutdown_request" to (if (timeToEmptyS > 0) timeToEmptyS else -1L).toString(), + ) + for ((k, v) in vpower) put(File(vpowerDir, k), v + "\n") } catch (t: Throwable) { Log.w(TAG, "battery: could not write", t) } @@ -130,5 +147,9 @@ class BatteryComponent(val dir: File) : SessionPart() { companion object { private const val TAG = "SessionService" private const val PERIOD_MS = 5_000L + private val VPOWER_FILES = listOf( + "ac_status", "battery_percent", "battery_status", + "secs_until_battery_full", "secs_until_shutdown_request", + ) } } 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 4114c627e..2bca7f669 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -674,7 +674,11 @@ class SessionService : Service() { // /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 // of our own, written from Android's battery API every few seconds, is bound over it. - val battery = BatteryComponent(File(filesDir, "session/sys/power_supply")) + // Steam's time estimates come from /run/vpower instead, written straight into the rootfs. + val battery = BatteryComponent( + File(filesDir, "session/sys/power_supply"), + File(LinuxRuntime.rootDir(this), "run/vpower"), + ) battery.attach(this) components.add(battery) binds.add(battery.dir.path + ":/sys/class/power_supply") From 9a424aabb7a1a9bd12905954acf9fbdee2502385 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 06:54:05 -0400 Subject: [PATCH 20/28] ci: wait for the PR build before publishing a test build instead of failing --- .github/workflows/pr-release-publish.yml | 30 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index f78700eef..9ee75852b 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -28,6 +28,7 @@ jobs: (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') runs-on: ubuntu-24.04 + timeout-minutes: 45 outputs: pr_number: ${{ steps.gate.outputs.pr_number }} run_id: ${{ steps.gate.outputs.run_id }} @@ -66,13 +67,28 @@ jobs: head=$(jq -r '.head.sha' <<<"$pr") base=$(jq -r '.base.sha' <<<"$pr") - run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \ - --event pull_request --commit "$head" \ - --json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 | - jq -r --arg head "$head" '[.[] | select(.headSha == $head)] | - sort_by([.createdAt, .attempt]) | last | - select(.status == "completed" and .conclusion == "success") | .databaseId // empty') - [[ -n "$run_id" ]] || { echo '::error::The latest PR build is not green'; exit 1; } + # The label is often added while the PR's build is still running, or just after a push + # before it has started: wait for the latest build of the head commit to finish (a PR + # build takes about 4 minutes) rather than fail and need the label added again. + run_id= + for attempt in $(seq 1 60); do + latest=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \ + --event pull_request --commit "$head" \ + --json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 | + jq -c --arg head "$head" '[.[] | select(.headSha == $head)] | + sort_by([.createdAt, .attempt]) | last // empty') + if [[ -n "$latest" && $(jq -r .status <<<"$latest") == completed ]]; then + conclusion=$(jq -r .conclusion <<<"$latest") + [[ "$conclusion" == success ]] || { echo "::error::The latest PR build is not green ($conclusion)"; exit 1; } + run_id=$(jq -r .databaseId <<<"$latest") + break + fi + status=not-started + [[ -z "$latest" ]] || status=$(jq -r .status <<<"$latest") + echo "Waiting for the PR build of ${head:0:7} to finish ($status)" + sleep 30 + done + [[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 30 minutes'; exit 1; } run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id") jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" ' .event == "pull_request" and .head_sha == $head and .conclusion == "success" and From fb7c1d2f70945186ab1bd03339eb86c8f03c2085 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 07:43:21 -0400 Subject: [PATCH 21/28] feat: name session log folders by day, number and mode, keep the newest 30, and add Clear logs --- .../com/droiddeck/launcher/MainActivity.kt | 11 +++ .../com/droiddeck/launcher/SessionActivity.kt | 9 +- .../launcher/runtime/GuestCommand.kt | 7 +- .../launcher/session/SessionArtifacts.kt | 38 ++++++++- .../launcher/session/SessionEvents.kt | 5 +- .../launcher/session/SessionLogShare.kt | 5 +- .../launcher/session/SessionPaths.kt | 85 ++++++++++++++++--- .../droiddeck/launcher/ui/FrontEndScreen.kt | 1 + .../droiddeck/launcher/ui/FrontEndSetup.kt | 1 + .../launcher/session/SessionPathsTest.kt | 50 +++++++++++ 10 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 8c82ed818..3628375b5 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -475,6 +475,16 @@ class MainActivity : ComponentActivity() { } }, "share-logs").start() }, + onClearLogs = { + Thread({ + val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0) + ui.post { + android.widget.Toast.makeText(this, + if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.", + android.widget.Toast.LENGTH_SHORT).show() + } + }, "clear-logs").start() + }, onOffline = { OfflineMode.setEnabled(this, !OfflineMode.enabled(this)) offline = OfflineMode.enabled(this) @@ -624,6 +634,7 @@ class MainActivity : ComponentActivity() { if (!SessionState.running) Thread({ SessionArtifacts.finishAbandoned(this) SessionArtifacts.scrubOlder(this) + SessionArtifacts.prune(this) }, "finish-abandoned").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 49160f70c..ddda9c7dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -227,7 +227,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { - SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM + // A Flatpak app or AppImage is named by its arguments, which the name lookup reads here. + SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() + SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM))) } SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() @@ -586,7 +589,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun shareCurrentSessionLogs() { - val folder = SessionPaths.current() + // Once a session has ended its folder has been handed on (SessionPaths.take), and the + // ended screen's Share logs is for that one. + val folder = SessionPaths.current() ?: SessionPaths.lastEnded() if (folder == null || !folder.isDirectory) { Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show() return diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index d420d5771..1f8a7233d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -5,6 +5,7 @@ import android.os.Environment import com.droiddeck.launcher.core.HostProcess import com.droiddeck.launcher.session.OrphanReaper import com.droiddeck.launcher.session.SessionFiles +import com.droiddeck.launcher.session.SessionPaths import com.droiddeck.launcher.session.SessionPrefs import java.io.File @@ -16,13 +17,15 @@ object GuestCommand { /** * Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is * for the package tools, which refuse any uid but 0. With [logName], everything the command - * says also goes to Download/DroidDeck/.log beside the session logs, so a problem can + * says also goes to Download/DroidDeck/tools/.log beside the session logs, so a problem can * be handed over like a session's. */ fun run(context: Context, argv: List, fakeRoot: Boolean = false, logName: String? = null, onLine: (String) -> Unit): Int { val log = logName?.let { - try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null } + try { + File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter() + } catch (e: Exception) { null } } log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}") try { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 5f8726e41..20e04141f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -66,7 +66,7 @@ object SessionArtifacts { fun scrubOlder(context: Context) { val current = SessionPaths.current() val dirs = LinuxRuntime.debugLogDir().listFiles { f -> - f.isDirectory && f.name.startsWith("session-") && f != current && !File(f, SCRUBBED_MARKER).exists() + SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists() } ?: return if (dirs.isEmpty()) return LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) @@ -124,8 +124,8 @@ object SessionArtifacts { fun finishAbandoned(context: Context) { val parent = LinuxRuntime.debugLogDir() val abandoned = parent.listFiles { f -> - f.isDirectory && f.name.startsWith("session-") && !File(f, COMPLETE_MARKER).exists() - }?.sortedBy { it.name } ?: return + SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() + }?.sortedWith(SessionPaths.chronological) ?: return if (abandoned.isEmpty()) return val current = SessionPaths.current() LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) @@ -157,5 +157,37 @@ object SessionArtifacts { } } + /** + * Keeps the newest [SessionPaths.KEEP_SESSIONS] session folders and deletes the rest: a few + * days of testing left hundreds, and the one a report needed was lost among them. Only folders + * that are finished; the session in progress is never touched. Runs at app start after + * [finishAbandoned], on its worker thread. + */ + @Synchronized + fun prune(context: Context) { + val current = SessionPaths.current() + val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() } + val old = finished.dropLast(SessionPaths.KEEP_SESSIONS) + old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) } + if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}") + } + + /** + * Every session folder but the one in progress, and the one-off command logs (tools/, or loose + * beside the folders from before it): the Setup page's Clear logs. Returns how many session + * folders went. + */ + @Synchronized + fun clearAll(context: Context): Int { + val current = SessionPaths.current() + val gone = SessionPaths.sessionFolders(context).filter { it != current } + gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) } + com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR)) + // Before tools/, those logs sat loose beside the session folders. + LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() } + Log.i(TAG, "cleared ${gone.size} session folder(s)") + return gone.size + } + private fun now(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt index cd7955a57..5f8f2bf96 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt @@ -11,11 +11,12 @@ object SessionEvents { private const val TAG = "SessionEvents" private val lock = Any() - fun begin(context: Context, mode: String) { + /** [label] names the session's folder for what is being run (SessionPaths.label). */ + fun begin(context: Context, mode: String, label: String? = null) { // A runtime-install failure can leave the folder claimed before the service ever starts. // Keep that folder, but give the next attempt its own session directory. SessionPaths.take() - val dir = SessionPaths.beginOrCurrent(context) + val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null)) synchronized(SessionState) { SessionState.phase = SessionPhase.PREPARING SessionState.mode = mode diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 37b766df1..33267be5d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -17,10 +17,7 @@ import java.util.zip.ZipOutputStream */ object SessionLogShare { /** The newest session folder in either place logs are written, or null if there is none. */ - fun latest(context: Context): File? = - listOf(LinuxRuntime.debugLogDir(), File(context.filesDir, "logs")) - .flatMap { dir -> dir.listFiles { f -> f.isDirectory && f.name.startsWith("session-") }?.toList() ?: emptyList() } - .maxByOrNull { it.name } + fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull() /** Builds the zip (blocking). Returns null when there is no session to share. */ fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index 485bdc457..896d08a19 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -8,18 +8,24 @@ import java.util.Date import java.util.Locale /** - * One folder per session under `Download/DroidDeck/`, holding everything that session recorded: + * One folder per session under `Download/DroidDeck/`, holding everything that session recorded, + * named for the day, that day's session number and what was run, so the one a report is about can + * be picked out without opening any: * * ``` - * Download/DroidDeck/session-20260921-161256/ + * Download/DroidDeck/2026-09-30-03-steam/ * device.txt what this device is, and every setting the session ran with * session.log the guest session: proot, gamescope, the client's stdout * wayland.log the app's compositor * steam.log the Steam client's own log, scrubbed (Steam mode) * steam/ the rest of the client's logs, scrubbed (Steam mode) * desktop.log labwc, the panel and the programs on it (desktop mode) + * Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports * ``` * + * Folders from before this naming (`session-20260930-180642`) are still recognised, shared and + * pruned with the rest. + * * The folder is claimed by whoever starts first - the activity starts the compositor before the * service starts the session - so both write into the same one, and a recreated activity (a * foldable opening mid-session) joins the folder in progress instead of opening another. @@ -27,24 +33,44 @@ import java.util.Locale object SessionPaths { private const val TAG = "SessionPaths" + /** How many session folders are kept; older ones go at the next app start. */ + const val KEEP_SESSIONS = 30 + + /** Beside the session folders: the logs of one-off commands (GuestCommand's logName). */ + const val TOOLS_DIR = "tools" + + /** `2026-09-30-03-steam`: day, that day's session number, what was run. */ + private val NAMED = Regex("""^(\d{4})-(\d{2})-(\d{2})-(\d{2,})-.+$""") + /** `session-20260930-180642`, from before. */ + private val LEGACY = Regex("""^session-(\d{8})-(\d{6})(-\d+)?$""") + @Volatile private var dir: File? = null + @Volatile + private var ended: File? = null + /** * The folder for the session now starting, or the one already in progress. With logs turned * off on the main screen the folder lives in the app's cache instead of Downloads - the * scripts and the compositor still need somewhere to write - and [release] deletes it. + * [label] says what is being run (see [label]); without one it comes from the session's state. */ @Synchronized - fun beginOrCurrent(context: Context): File { + fun beginOrCurrent(context: Context, label: String? = null): File { dir?.let { if (it.isDirectory) return it } - val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context) else File(context.cacheDir, "session-logs") - val baseName = "session-$stamp" - var made = File(parent, baseName) - var suffix = 2 - while (made.exists()) made = File(parent, "$baseName-${suffix++}") + val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + // The day's highest number plus one, not a count: pruning removes the oldest folders, and + // a count would hand out a number already in use. + val last = parent.listFiles()?.mapNotNull { f -> + NAMED.matchEntire(f.name)?.takeIf { f.name.startsWith("$day-") }?.groupValues?.get(4)?.toIntOrNull() + }?.maxOrNull() ?: 0 + val what = slug(label ?: label(SessionState.mode, SessionState.program)) + var number = last + 1 + var made = File(parent, "$day-${"%02d".format(Locale.US, number)}-$what") + while (made.exists()) made = File(parent, "$day-${"%02d".format(Locale.US, ++number)}-$what") if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made") dir = made Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)") @@ -53,6 +79,9 @@ object SessionPaths { fun current(): File? = dir + /** The folder of the session that ended last, while it is still there: what its Share logs sends. */ + fun lastEnded(): File? = ended?.takeIf { it.isDirectory } + /** * The ending session takes its folder with it: the next session (one that replaces this one * in place) then claims a folder of its own instead of sharing, and this one's collector @@ -60,9 +89,10 @@ object SessionPaths { */ @Synchronized fun take(): File? { - val ended = dir + val taken = dir dir = null - return ended + if (taken != null) ended = taken + return taken } fun file(name: String): File? = dir?.let { File(it, name) } @@ -78,4 +108,39 @@ object SessionPaths { com.droiddeck.launcher.core.FileUtils.delete(ended) } } + + /** + * What a session's folder is named for: Steam, the desktop, or the program started from the + * rail by its own name (`2026-10-01-02-retroarch`). + */ + fun label(mode: String, program: String?): String = when (mode) { + SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(program) + ?: program?.substringAfterLast('/') + ?: mode + SessionService.MODE_DESKTOP -> "desktop" + else -> mode + } + + /** Whether [f] is a session's folder, in either naming. */ + fun isSessionFolder(f: File): Boolean = + f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name)) + + /** + * Oldest first: by day, then by when in the day. A folder from before the new naming sorts + * ahead of a new one from the same day, which it always predates. + */ + val chronological: Comparator = compareBy { f -> + NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" } + ?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" } + ?: f.name + } + + /** Every session folder in the places logs are written, oldest first. */ + fun sessionFolders(context: Context): List = + listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs")) + .flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() } + .sortedWith(chronological) + + internal fun slug(text: String): String = + text.lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24).trim('-').ifEmpty { "session" } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 74f5caaf3..e94d9edb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -153,6 +153,7 @@ class FrontEndActions( val onBrowseFiles: (File) -> Unit = {}, val onLogs: () -> Unit, val onShareLogs: () -> Unit = {}, + val onClearLogs: () -> Unit = {}, val onOffline: () -> Unit, val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 697e04b3c..e7ab00989 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -289,6 +289,7 @@ internal fun SetupPanel( } ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() } ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs) + ActionRow("Saved session logs", "The newest ${com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS} are kept", "Clear logs", a.onClearLogs) ToggleRow( host, "offline", "Offline mode", s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first", diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt new file mode 100644 index 000000000..4cc7562f2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class SessionPathsTest { + @get:Rule val tmp = TemporaryFolder() + + private fun dir(name: String) = tmp.newFolder(name) + + @Test fun bothNamingsAreSessionFolders() { + assertTrue(SessionPaths.isSessionFolder(dir("2026-10-01-03-steam"))) + assertTrue(SessionPaths.isSessionFolder(dir("2026-10-01-12-retroarch"))) + assertTrue(SessionPaths.isSessionFolder(dir("session-20260930-180642"))) + assertTrue(SessionPaths.isSessionFolder(dir("session-20260930-180642-2"))) + } + + @Test fun otherFoldersAndFilesAreNot() { + assertFalse(SessionPaths.isSessionFolder(dir("tools"))) + assertFalse(SessionPaths.isSessionFolder(dir("backup-test"))) + assertFalse(SessionPaths.isSessionFolder(dir("2026-10-01"))) + assertFalse(SessionPaths.isSessionFolder(tmp.newFile("2026-10-01-01-steam.zip"))) + } + + @Test fun foldersSortByDayThenNumber() { + val folders = listOf( + dir("2026-10-01-10-desktop"), dir("session-20260930-180642"), dir("2026-10-01-02-steam"), + dir("session-20261001-063000"), dir("2026-09-30-01-steam"), dir("2026-10-01-01-steam"), + ) + assertEquals( + listOf( + "session-20260930-180642", "2026-09-30-01-steam", "session-20261001-063000", + "2026-10-01-01-steam", "2026-10-01-02-steam", "2026-10-01-10-desktop", + ), + folders.sortedWith(SessionPaths.chronological).map { it.name }, + ) + } + + @Test fun labelsBecomeShortLowercaseWords() { + assertEquals("steam", SessionPaths.slug("steam")) + assertEquals("dolphin-emulator", SessionPaths.slug("Dolphin Emulator")) + assertEquals("org-telegram-desktop", SessionPaths.slug("org.telegram.desktop")) + assertEquals("a-very-long-program-name", SessionPaths.slug("A very long program name that keeps going")) + assertEquals("session", SessionPaths.slug("!!!")) + } +} From a53c4a3707d1b1320df2b59077a0899e3bd4a5aa Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 08:18:24 -0400 Subject: [PATCH 22/28] fix: keep touch scrolling the Steam interface when the client finds no controller at start --- .../com/droiddeck/launcher/SessionActivity.kt | 15 ++++++++++++++- .../overlay/usr/local/bin/bannerlator-session | 5 +++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index ddda9c7dd..10a7c8087 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -98,7 +98,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var padMotion: com.droiddeck.launcher.input.PadMotion? = null /** Between onResume and onPause. */ private var resumed = false - private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } } + private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing() } } + + /** + * A Deck's controller is there from the moment it boots, and the client looks for it once, as + * it starts. The ring behind the Deck (libfakeinput's /dev/hidraw16) opens on the pad's first + * input otherwise - on a phone with nothing attached, the first touch of the on-screen + * controls, seconds after the client had looked, found no controller and never set the touch + * mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a + * Deck instead. + */ + private fun openDeckRing() { + if (SessionState.deckPad) padBridge?.start() + } /** 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). */ @@ -272,6 +284,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 } SessionState.deckPadListener = deckPadListener + openDeckRing() // 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 { diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 01fb68198..92107b7fa 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -150,6 +150,11 @@ if [ -z "${BL_INSIDE:-}" ]; then args+=(-r "$BL_REFRESH") fi [ "$mode" = steam ] && args+=(-e) + # Touch passes through to the client as touch until the client says otherwise, as SteamOS's + # gamescope-session starts it (TOUCH_MODE=4). The client sets the mode itself (STEAM_TOUCH_CLICK_MODE) + # only once it has a controller; until then gamescope's own default made a finger a left-click + # mouse, so the Steam interface took taps but would not scroll on a phone with no pad attached. + [ "$mode" = steam ] && args+=(--default-touch-mode 4) # A game that resizes its own window when it loses focus (the Steam menu opening over it) comes # back smaller, and gamescope draws a window that is not flagged fullscreen at its own size in # the top-left corner. Forcing every window to the output's size keeps it filling the screen. From cbeed44dc5fa3753df7a6a60b8f2f591da46233c Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 09:15:03 -0400 Subject: [PATCH 23/28] ci: publish signed test builds automatically for PRs from this repo's branches --- .github/workflows/build.yml | 7 ++- .github/workflows/pr-release-publish.yml | 58 +++++++++++++++++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7b73e870..338dce61c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -374,6 +374,7 @@ jobs: PR: ${{ github.event.pull_request.number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} BUILD_OUTCOME: ${{ steps.build.outcome }} + SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} run: | set -uo pipefail MARK="" @@ -389,7 +390,11 @@ jobs: echo echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." echo - echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + if [ "$SAME_REPO" = true ]; then + echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that." + else + echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing." + fi echo echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." ;; diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 9ee75852b..f2b6b9941 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -1,5 +1,5 @@ name: PR Release Publish -run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} with the release key" +run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} with the release key" on: workflow_dispatch: @@ -11,6 +11,11 @@ on: pull_request_target: types: [labeled] branches: [main] + # A PR from a branch of this repo is signed on its own after each green build: only people who + # can push here can change it. A fork's PR waits for a maintainer's publish-test label. + workflow_run: + workflows: [Build APK] + types: [completed] permissions: actions: read @@ -19,14 +24,18 @@ permissions: pull-requests: write concurrency: - group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} + group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} cancel-in-progress: false jobs: authorize: if: >- (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || - (github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') + (github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') || + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.pull_requests[0] != null) runs-on: ubuntu-24.04 timeout-minutes: 45 outputs: @@ -36,6 +45,8 @@ jobs: base_sha: ${{ steps.gate.outputs.base_sha }} version_code: ${{ steps.gate.outputs.version_code }} label: ${{ steps.gate.outputs.label }} + skip: ${{ steps.gate.outputs.skip }} + automatic: ${{ steps.gate.outputs.automatic }} variants: ${{ steps.variants.outputs.list }} steps: - uses: actions/checkout@v4 @@ -51,10 +62,24 @@ jobs: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} REQUESTER: ${{ github.actor }} - PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || inputs.pr_number }} + PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} + BUILT_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number '$PR_NUMBER'"; exit 1; } + # The automatic path stands down quietly, never failing: a draft, a PR labelled + # no-test-build, or a build that a later push has already overtaken. + skip() { echo "::notice::Not publishing PR #$PR_NUMBER: $1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } + if [ "$EVENT_NAME" = workflow_run ]; then + echo "automatic=true" >> "$GITHUB_OUTPUT" + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + [ "$(jq -r .state <<<"$pr")" = open ] || skip "it is not open" + [ "$(jq -r .draft <<<"$pr")" = false ] || skip "it is a draft" + [ "$(jq -r .base.ref <<<"$pr")" = main ] || skip "it does not target main" + [ "$(jq -r .head.repo.full_name <<<"$pr")" = "$GITHUB_REPOSITORY" ] || skip "it comes from a fork" + jq -e 'any(.labels[]; .name == "no-test-build")' <<<"$pr" >/dev/null && skip "it is labelled no-test-build" + [ "$(jq -r .head.sha <<<"$pr")" = "$BUILT_SHA" ] || skip "a newer push is building" + fi if [ "$EVENT_NAME" = pull_request_target ]; then permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$REQUESTER/permission" --jq .permission) case "$permission" in @@ -138,6 +163,7 @@ jobs: label: ${{ needs.authorize.outputs.label }} artifact: droiddeck-pr-signed secrets: inherit + if: ${{ needs.authorize.outputs.skip != 'true' }} publish: needs: [authorize, sign] @@ -207,9 +233,12 @@ jobs: \`\`\` $(cat signed/SHA256SUMS.txt) \`\`\`" + first=false if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease + first=true fi + echo "first=$first" >> "$GITHUB_OUTPUT" gh release upload "$tag" signed/* -R "$repo" --clobber gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease # Drop the previous build's files: anything this build did not just upload. @@ -227,6 +256,15 @@ jobs: echo "release=$release" >> "$GITHUB_OUTPUT" echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT" + - name: Whether the PR's branch is in this repo + id: same-repo + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + run: | + head_repo=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.repo.full_name) + echo "same=$([ "$head_repo" = "$GITHUB_REPOSITORY" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - name: Clear the publish request label if: ${{ github.event_name == 'pull_request_target' && steps.publish.outcome == 'success' }} env: @@ -242,10 +280,16 @@ jobs: HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} RELEASE: ${{ steps.publish.outputs.release }} APK: ${{ steps.publish.outputs.apk }} + SAME_REPO: ${{ steps.same-repo.outputs.same }} run: | set -euo pipefail short=${HEAD_SHA:0:7} mark='' + if [ "$SAME_REPO" = true ]; then + NEXT='Each green build of a new push is signed and published here on its own. Label the PR `no-test-build` to stop that.' + else + NEXT='Pushes to this PR do not re-sign automatically. After a new green build, a maintainer can add the `publish-test` label again.' + fi body=$(cat </dev/null fi + # Asked for (the label, a manual run): always announced. Signed on its own after a push: + # only a PR's first test build is, and later ones update its release quietly. - name: Post to Discord - if: ${{ always() && steps.publish.outcome == 'success' }} + if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }} env: WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }} GH_TOKEN: ${{ github.token }} From 7c0fc1cbfa521defd1a30dbc095f7936e4253c73 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 09:58:51 -0400 Subject: [PATCH 24/28] feat: log controller, touch-mode and pad-ring evidence for input reports --- .../com/droiddeck/launcher/SessionActivity.kt | 32 ++++++++++--- .../droiddeck/launcher/input/PadBridge.java | 48 ++++++++++++++++++- .../launcher/session/SessionLogShare.kt | 41 +++++++++++----- .../launcher/session/SessionService.kt | 13 +++++ .../overlay/usr/local/bin/bannerlator-session | 23 +++++++++ 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 10a7c8087..0f63ca6e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -98,7 +98,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var padMotion: com.droiddeck.launcher.input.PadMotion? = null /** Between onResume and onPause. */ private var resumed = false - private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing() } } + private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } } /** * A Deck's controller is there from the moment it boots, and the client looks for it once, as @@ -108,8 +108,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { * mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a * Deck instead. */ - private fun openDeckRing() { - if (SessionState.deckPad) padBridge?.start() + private fun openDeckRing(why: String) { + val deck = SessionState.deckPad + val opened = if (deck) padBridge?.start() else null + Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) { + null -> ", left for first input" + true -> ", ring open" + false -> ", ring NOT open" + }) } /** Motion is read while the session shows and only if the pad is a Deck controller - which @@ -231,8 +237,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { * press, and a user without one must not be left with no way to answer Big Picture. */ private val deviceListener = object : InputManager.InputDeviceListener { - override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls() - override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls() + override fun onInputDeviceAdded(deviceId: Int) { + logInputDevice("connected", deviceId) + updateOnScreenControls() + } + override fun onInputDeviceRemoved(deviceId: Int) { + Log.i(TAG, "input device $deviceId disconnected") + updateOnScreenControls() + } override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls() } @@ -284,7 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 } SessionState.deckPadListener = deckPadListener - openDeckRing() + openDeckRing("activity created") // A player on the pad or the on-screen controls has no use for the mouse arrow; the next // touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment. bridge.setOnPlayerInput { @@ -1544,6 +1556,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } /** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */ + /** Which controller the session has, so a pad report says what was plugged in and when. */ + private fun logInputDevice(what: String, deviceId: Int) { + val d = InputDevice.getDevice(deviceId) ?: return + if (!PadBridge.isFromController(d)) return + Log.i(TAG, String.format(java.util.Locale.ROOT, "controller %s: \"%s\" (%04x:%04x, id %d, sources 0x%x)", + what, d.name, d.vendorId, d.productId, d.id, d.sources)) + } + private fun updateOnScreenControls() { val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc") .takeIf { it.isFile } diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 9fe6f6ecd..2d75e9f38 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -66,6 +66,15 @@ public final class PadBridge { private volatile Runnable onPlayerInput; private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean(); + // What reached the pad over the last stats window, so a report of "the pad does nothing" or + // "touch and the pad don't work together" shows in the app log whether input arrived, from + // which device, and whether it got to the ring. Logged only for a window that had input. + private static final long STATS_WINDOW_MS = 10_000; + private int statButtons, statAxes, statOnScreen, statDropped; + private String statDevice; + private boolean statsScheduled; + private final java.util.Set seenDevices = new java.util.HashSet<>(); + public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); } @@ -134,6 +143,7 @@ public final class PadBridge { /** @return true when the event was a pad button and has been consumed. */ public synchronized boolean onKeyEvent(KeyEvent event) { if (!isFromController(event.getDevice())) return false; + noteDevice(event.getDevice()); boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; switch (event.getKeyCode()) { case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break; @@ -161,6 +171,8 @@ public final class PadBridge { default: return false; } if (pressed) notePlayerInput(); + statButtons++; + scheduleStats(); publish(); return true; } @@ -169,6 +181,9 @@ public final class PadBridge { public synchronized boolean onMotionEvent(MotionEvent event) { if (!isFromController(event.getDevice())) return false; if (event.getAction() != MotionEvent.ACTION_MOVE) return false; + noteDevice(event.getDevice()); + statAxes++; + scheduleStats(); state.leftX = axis(event, MotionEvent.AXIS_X); // Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the // pad reports as "down" is what the client is told. @@ -203,6 +218,8 @@ public final class PadBridge { public synchronized void applyTouch(java.util.function.Consumer mutation) { mutation.accept(state); notePlayerInput(); + statOnScreen++; + scheduleStats(); publish(); } @@ -279,8 +296,37 @@ public final class PadBridge { publish(); } + private void noteDevice(InputDevice device) { + statDevice = device.getName(); + if (seenDevices.add(device.getId())) { + Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b", + device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(), + open ? "open" : "not open yet", SessionState.getDeckPad())); + } + } + + private void scheduleStats() { + if (statsScheduled) return; + statsScheduled = true; + mainHandler.postDelayed(this::logStats, STATS_WINDOW_MS); + } + + private synchronized void logStats() { + statsScheduled = false; + Log.i(TAG, "[input] last 10 s: pad " + statButtons + " buttons, " + statAxes + " axis events" + + (statDevice != null ? " (\"" + statDevice + "\")" : "") + + ", on-screen " + statOnScreen + + (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "") + + "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad()); + statButtons = statAxes = statOnScreen = statDropped = 0; + statDevice = null; + } + private void publish() { - if (!open && !start()) return; + if (!open && !start()) { + statDropped++; + return; + } boolean guide = systemGuidePressed || qamChordActive; boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed); if (!guide && !qam) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 33267be5d..bbe627a2e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -33,21 +33,40 @@ object SessionLogShare { // session pass yet, and the redactor changes nothing in a line that is already clean. LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context)) ZipOutputStream(zip.outputStream().buffered()).use { z -> - files.forEach { f -> - z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path)) - if (LogRedactor.isText(f)) { - val w = z.bufferedWriter() - LogRedactor.scrubTo(f, w) - w.flush() - } else { - f.inputStream().use { it.copyTo(z) } - } - z.closeEntry() - } + files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) } + liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) } } return zip } + private fun addEntry(z: ZipOutputStream, name: String, f: File) { + z.putNextEntry(ZipEntry(name)) + if (LogRedactor.isText(f)) { + val w = z.bufferedWriter() + LogRedactor.scrubTo(f, w) + w.flush() + } else { + f.inputStream().use { it.copyTo(z) } + } + z.closeEntry() + } + + /** + * The client's own logs as they stand, for a session shared while it runs. The session script + * copies them into steam/ only as it exits, so a zip made from the drawer had none - and + * controller.txt (which pad the client opened, the touch mode it set) is what a controller or + * touch report needs most. Only for the running session: an older folder would get this + * session's logs. The same files the script copies; nothing holding credentials is in logs/. + */ + private fun liveSteamLogs(context: Context, folder: File): List { + if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList() + val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs") + return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty() + } + + /** The client's content and bootstrap logs grow large over months and say nothing about a session. */ + private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024 + fun shareIntent(context: Context, zip: File): Intent { val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip) val send = Intent(Intent.ACTION_SEND) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 2bca7f669..6e3e4d8ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -642,6 +642,7 @@ class SessionService : Service() { deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList() SessionState.deckPad = deckBinds.isNotEmpty() if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller") + logControllersAtStart() if (uinput) { // /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a // game becomes a node the game reads, carrying the player's layout, as on a Deck. @@ -1190,6 +1191,18 @@ class SessionService : Service() { getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification()) } + /** The pads attached as the session starts; the activity logs the ones that come and go after. */ + private fun logControllersAtStart() { + val pads = ArrayList() + for (id in android.view.InputDevice.getDeviceIds()) { + val d = android.view.InputDevice.getDevice(id) ?: continue + if (!com.droiddeck.launcher.input.PadBridge.isFromController(d)) continue + pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId)) + } + Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) + + "; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller") + } + companion object { private const val TAG = "SessionService" /** proot's tracer: above everything in the guest, under the compositor thread's -8. */ diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 92107b7fa..b4724941f 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -436,6 +436,29 @@ case $mode in prev=$app done ) & + # Evidence for touch reports: the touch mode the client asks gamescope for (STEAM_TOUCH_CLICK_MODE, + # set once it has a controller, per focused app) and which app has focus, logged as they change. + # A finger that clicks but won't scroll, or touch that stops once a pad is used, shows here as + # the mode in force at that moment. 4 is touch passed through, 1 a left-click mouse. + [ -z "${BL_DESKTOP:-}" ] && ( + tmode= app= + xprop -spy -root STEAM_TOUCH_CLICK_MODE GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do + case $line in + STEAM_TOUCH_CLICK_MODE*) + v=${line##*= }; case $line in *'not found'*) v=unset ;; esac + [ "$v" = "$tmode" ] && continue + tmode=$v + echo "== touch mode: $tmode (focused app ${app:-?}) at $(date +%T)" + ;; + GAMESCOPE_FOCUSED_APP*) + v=${line##*= }; case $line in *'not found'*) v=none ;; esac + [ "$v" = "$app" ] && continue + app=$v + echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)" + ;; + esac + done + ) & # The library beat. Both helpers are whole programs under proot (the compat one a python # start and a parse of the client's config), and nearly every beat has nothing new for them. # The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born From ac40989d4030b5ab30c7696dd5fd912c1fe6b763 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 09:59:13 -0400 Subject: [PATCH 25/28] perf: skip the per-event device lookup when input comes from the same pad --- .../main/java/com/droiddeck/launcher/input/PadBridge.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 2d75e9f38..a385650e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -74,6 +74,7 @@ public final class PadBridge { private String statDevice; private boolean statsScheduled; private final java.util.Set seenDevices = new java.util.HashSet<>(); + private int lastDeviceId = Integer.MIN_VALUE; public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); @@ -296,7 +297,10 @@ public final class PadBridge { publish(); } + /** Per event, so the common case - the same pad as last time - is a single compare. */ private void noteDevice(InputDevice device) { + if (device.getId() == lastDeviceId) return; + lastDeviceId = device.getId(); statDevice = device.getName(); if (seenDevices.add(device.getId())) { Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b", @@ -319,7 +323,6 @@ public final class PadBridge { + (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "") + "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad()); statButtons = statAxes = statOnScreen = statDropped = 0; - statDevice = null; } private void publish() { From f96e1b2b6e5a647ea571c542be92623f5cda0cc2 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 10:35:24 -0400 Subject: [PATCH 26/28] feat: always write the guest pad log to pad.log in the session folder --- app/src/main/cpp/fakeinput_steam.cpp | 88 +++++++++++++++++-- .../droiddeck/launcher/core/DeviceReport.kt | 2 +- .../launcher/session/SessionService.kt | 13 +-- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 0b12b3249..936268e5f 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -8,6 +8,7 @@ * a game become more of these nodes instead of vanishing. */ #include +#include #include #include #include @@ -38,6 +39,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -295,21 +299,52 @@ static void controller_fork_child() { namespace Logger { int log_enabled; +// FAKE_EVDEV_LOG_FILE: the session's pad.log, shared by every process the library is loaded in +// (the client, each game), so a pad report has the guest side in one file of the session folder. +// Opened with the raw syscall: open() is this library's own hook. +static int log_fd = -1; +static char log_name[17]; void init() { log_enabled = getenv("FAKE_EVDEV_LOG") && atoi(getenv("FAKE_EVDEV_LOG")); + const char *path = getenv("FAKE_EVDEV_LOG_FILE"); + if (log_enabled && path && *path) + log_fd = static_cast(syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644)); + if (prctl(PR_GET_NAME, log_name) != 0) strcpy(log_name, "?"); } void log(const char *message, ...) { if (!log_enabled) return; + if (log_fd < 0) { + va_list args; + va_start(args, message); + vfprintf(stderr, message, args); + va_end(args); + std::cerr.flush(); + return; + } + // One write per line: O_APPEND keeps lines from different processes whole. + char line[512]; + struct timespec now; + clock_gettime(CLOCK_REALTIME, &now); + struct tm local; + localtime_r(&now.tv_sec, &local); + int n = snprintf(line, sizeof(line), "%02d:%02d:%02d.%03ld %d %s: ", local.tm_hour, local.tm_min, + local.tm_sec, now.tv_nsec / 1000000, getpid(), log_name); va_list args; va_start(args, message); - vfprintf(stderr, message, args); + if (n > 0 && n < static_cast(sizeof(line))) + n += vsnprintf(line + n, sizeof(line) - n, message, args); va_end(args); - - std::cerr.flush(); + if (n <= 0) return; + if (n >= static_cast(sizeof(line))) { + n = sizeof(line) - 1; + line[n - 1] = '\n'; + } + ssize_t ignored = write(log_fd, line, n); + (void)ignored; } } // namespace Logger @@ -1268,6 +1303,14 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() { return known == 1; } +// What the client starts with, so a pad log says whether it was offered a Deck at all. +__attribute__((constructor)) static void log_client_start() { + config(); + if (process_is_steam_client()) + Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off", + get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str()); +} + __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH); } @@ -1376,12 +1419,16 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); if (!process_is_steam_client()) { + // Only the client reads the Deck; a game sees Steam Input's virtual pad instead. + Logger::log("deck: %s refused: not the Steam client\n", DECK_HIDRAW_PATH); errno = ENOENT; return -1; } std::string ring_path = get_ring_path_for_slot(0); int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDONLY | O_CLOEXEC); if (ring_fd < 0) { + Logger::log("deck: %s refused: ring %s not openable (%s)\n", DECK_HIDRAW_PATH, + ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno)); errno = ENODEV; return -1; } @@ -1394,12 +1441,15 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, ring_fd, 0); syscall(SYS_close, ring_fd); if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast(mapping))) { + Logger::log("deck: %s refused: ring %s %s\n", DECK_HIDRAW_PATH, ring_path.c_str(), + mapping == MAP_FAILED ? "could not be mapped" : "has no valid header"); if (mapping != MAP_FAILED) munmap(mapping, mapping_size); errno = ENODEV; return -1; } int pair[2]; if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) { + Logger::log("deck: %s refused: socketpair failed (%s)\n", DECK_HIDRAW_PATH, strerror(errno)); munmap(mapping, mapping_size); return -1; } @@ -1451,17 +1501,32 @@ __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { return is_deck_sysfs_path(path); } +// Once per process: proof the client's enumeration reached the Deck's sysfs at all. No line here +// and no open of /dev/hidraw16 means the client never looked; a line here and no open means it +// looked and turned the device down. +__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *how, int result) { + static std::atomic noted{false}; + if (!noted.exchange(true)) + Logger::log("deck: sysfs checked (%s%s)\n", how, result == 0 ? "" : ", failed"); +} + EXPORT int fstatfs(int fd, struct statfs *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstatfs")); int result = real(fd, buf); - if (result == 0 && fake_deck_enabled() && is_deck_sysfs_fd(fd)) buf->f_type = SYSFS_FS_MAGIC; + if (fake_deck_enabled() && is_deck_sysfs_fd(fd)) { + note_deck_sysfs("fstatfs", result); + if (result == 0) buf->f_type = SYSFS_FS_MAGIC; + } return result; } EXPORT int statfs(const char *path, struct statfs *buf) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "statfs")); int result = real(path, buf); - if (result == 0 && fake_deck_enabled() && is_deck_sysfs_path(path)) buf->f_type = SYSFS_FS_MAGIC; + if (fake_deck_enabled() && is_deck_sysfs_path(path)) { + note_deck_sysfs(path, result); + if (result == 0) buf->f_type = SYSFS_FS_MAGIC; + } return result; } @@ -1483,6 +1548,15 @@ __attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request } // Feature reports carry the report id (0) first, then the Valve message: type, length, payload. +// The client repeats some feature reports (settings, rumble) for as long as it has the pad; each +// is logged the first time and then at 2, 4, 8... so the log shows what it asked and how often +// without a line per report. +__attribute__((visibility("hidden"))) static void log_deck_feature(const char *what, uint8_t feature) { + static std::atomic counts[2][256]; + unsigned n = ++counts[what[0] == 's'][feature]; + if ((n & (n - 1)) == 0) Logger::log("deck: feature 0x%02x %s (x%u)\n", feature, what, n); +} + __attribute__((visibility("hidden"))) static int deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { size_t size = _IOC_SIZE(op); @@ -1496,7 +1570,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8; send_vibration(left, right, left || right ? 1000 : 0, 0); } - Logger::log("deck: feature 0x%02x set\n", buf[1]); + log_deck_feature("set", buf[1]); return static_cast(size); } uint8_t reply[DECK_REPORT_BYTES + 1] = {}; @@ -1526,7 +1600,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { reply[1] = deck.pending_feature; break; } - Logger::log("deck: feature 0x%02x read\n", deck.pending_feature); + log_deck_feature("read", deck.pending_feature); size_t length = std::min(size, sizeof(reply)); memcpy(buf, reply, length); return static_cast(length); 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 96e495b53..16a9598e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -173,7 +173,7 @@ object DeviceReport { h("Device switch files in Download") for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver", - "droiddeck-osc", "droiddeck-no-pad", "droiddeck-pad-log", + "droiddeck-osc", "droiddeck-no-pad", "droiddeck-no-hud", "droiddeck-wlr-renderer")) { val f = File(Environment.getExternalStorageDirectory(), "Download/$name") if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 6e3e4d8ff..a7832cf88 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -299,7 +299,7 @@ class SessionService : Service() { val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() SessionState.deckPad = false deckBinds = emptyList() - if (controllersOn) addControllerEnvironment(guest, fakeInputDir) + if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir) // The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render // node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the // default is pixman (software, shm), where a Vulkan program cannot draw at all; those run @@ -616,7 +616,7 @@ class SessionService : Service() { } /** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */ - private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File) { + private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File, sessionDir: File) { FakeInputWriter.prepareRingSlots(fakeInputDir, 4) // Virtual pads a client made last session (event16 and up, and their hidden rings) are // not there any more; a client that crashed never took its own down. @@ -660,9 +660,11 @@ class SessionService : Service() { // The client reads a Deck controller through SDL's HIDAPI; a hint in the environment // outranks the client's own. Nothing else is shown the Deck (libfakeinput). if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0") - if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) { - guest.add("FAKE_EVDEV_LOG=1") - } + // The guest side of the pads (libfakeinput: which pads were opened, the Deck's hidraw and why + // it was refused) in pad.log beside the session's other logs, so it is in every shared zip. + // Setup-time lines only, nothing per input event. + guest.add("FAKE_EVDEV_LOG=1") + guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path) SessionState.fakeInputDir = fakeInputDir } @@ -1232,7 +1234,6 @@ class SessionService : Service() { private const val STEAM_PICKUP_MS = 1500L private const val STEAM_EXIT_MS = 10_000L private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" - private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log" private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput" private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad" /** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */ From 2896e0a945b0c694e16173c7d55f71e697f7cdbe Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 10:37:58 -0400 Subject: [PATCH 27/28] fix: log an empty focused-app or touch-mode property as none --- .../overlay/usr/local/bin/bannerlator-session | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index b4724941f..89c35ce3e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -441,17 +441,25 @@ case $mode in # A finger that clicks but won't scroll, or touch that stops once a pad is used, shows here as # the mode in force at that moment. 4 is touch passed through, 1 a left-click mouse. [ -z "${BL_DESKTOP:-}" ] && ( + # What follows "=" in an xprop line, or $2 for a property that is missing or has no value + # yet ("NAME(CARDINAL) =", which read leaves with nothing after the "="). + xprop_value() { + case $1 in *'not found'*|*=) echo "$2"; return ;; esac + local v=${1#*=} + v=${v# } + echo "${v:-$2}" + } tmode= app= xprop -spy -root STEAM_TOUCH_CLICK_MODE GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do case $line in STEAM_TOUCH_CLICK_MODE*) - v=${line##*= }; case $line in *'not found'*) v=unset ;; esac + v=$(xprop_value "$line" unset) [ "$v" = "$tmode" ] && continue tmode=$v echo "== touch mode: $tmode (focused app ${app:-?}) at $(date +%T)" ;; GAMESCOPE_FOCUSED_APP*) - v=${line##*= }; case $line in *'not found'*) v=none ;; esac + v=$(xprop_value "$line" none) [ "$v" = "$app" ] && continue app=$v echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)" From 5fe5fa45c77c51d01ff17844328114efa1fd3da0 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 12:00:24 -0400 Subject: [PATCH 28/28] fix: let the Steam client find the Deck controller under an enforcing SELinux policy --- app/src/main/cpp/fakeinput_steam.cpp | 94 ++++++++++++++++++- .../launcher/session/SessionService.kt | 5 +- .../launcher/session/SteamDeckPad.kt | 14 ++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 936268e5f..128cb8d97 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1488,8 +1488,18 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { // into /sys, so those paths are said to be sysfs; nothing else is touched. static constexpr long SYSFS_FS_MAGIC = 0x62656572; +// FAKE_DECK_SYSFS_LISTING: stand-ins for the listings of /sys, /sys/class and /sys/bus +// (SteamDeckPad.listingDir), for a client that is not allowed to list the real ones. +__attribute__((visibility("hidden"))) static const char *deck_listing_dir() { + static const char *dir = getenv("FAKE_DECK_SYSFS_LISTING"); + return dir && *dir ? dir : nullptr; +} + __attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) { - return path && (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)); + if (!path) return false; + if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true; + const char *listing = deck_listing_dir(); + return listing && !strncmp(path, listing, strlen(listing)); } __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { @@ -1644,6 +1654,80 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) { } } +// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any +// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy +// denies directories a permissive device lets through. The client's /sys failures that are not +// ENOENT are logged, each path once, so pad.log names the directory that stopped the scan. +__attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) { + int saved_errno = errno; + std::string path; + if (saved_errno == ENOENT || !pathname || !process_is_steam_client()) { + errno = saved_errno; + return path; + } + if (pathname[0] == '/' || dirfd == AT_FDCWD) { + path = pathname; + } else { + char link[64], dir[PATH_MAX]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd); + ssize_t length = readlink(link, dir, sizeof(dir) - 1); + if (length <= 0) { + errno = saved_errno; + return path; + } + path.assign(dir, length); + path += '/'; + path += pathname; + } + if (path.compare(0, 5, "/sys/") != 0 && path != "/sys") { + errno = saved_errno; + return {}; + } + static std::mutex mutex; + static std::vector *seen = new std::vector(); + bool first = false; + { + std::lock_guard guard(mutex); + if (seen->size() < 64 && std::find(seen->begin(), seen->end(), path) == seen->end()) { + seen->push_back(path); + first = true; + } + } + if (first) Logger::log("sysfs: %s not readable (%s)\n", path.c_str(), strerror(saved_errno)); + errno = saved_errno; + return path; +} + +// An enforcing SELinux policy (every retail phone; the Thor only with it forced on) refuses an app +// a listing of /sys, /sys/class and /sys/bus. libudev lists /sys/class to find the subsystems to +// descend into and gives up its whole scan when it cannot, so the client never reached hidraw and +// found no Deck. The client is listed the stand-ins instead - hidraw under class - and opens +// /sys/class/hidraw itself, which is ours and readable. Only on that refusal, only for the client. +__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path) { + const char *listing = deck_listing_dir(); + if (!path || !listing || errno != EACCES || !fake_deck_enabled() || !process_is_steam_client()) return {}; + std::string p(path); + while (p.size() > 1 && p.back() == '/') p.pop_back(); + int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1; + if (which < 0) return {}; + static const char *const names[] = {"sys", "class", "bus"}; + static std::atomic logged{0}; + if (!(logged.fetch_or(1 << which) & (1 << which))) + Logger::log("sysfs: %s listed from the Deck's stand-in\n", p.c_str()); + return std::string(listing) + "/" + names[which]; +} + +EXPORT DIR *opendir(const char *name) { + static auto real = reinterpret_cast

(dlsym(RTLD_NEXT, "opendir")); + DIR *dir = real(name); + if (!dir) { + note_sys_failure(AT_FDCWD, name); + std::string listing = deck_listing_for(name); + if (!listing.empty()) dir = real(listing.c_str()); + } + return dir; +} + EXPORT int open(const char *pathname, int flags, ...) { va_list va; mode_t mode; @@ -1707,6 +1791,10 @@ EXPORT int open(const char *pathname, int flags, ...) { fd = my_open(pathname, flags, mode); else fd = my_open(pathname, flags); + if (fd < 0) { + std::string listing = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str()); + if (!listing.empty()) fd = my_open(listing.c_str(), flags); + } if (fake_path) free(fake_path); @@ -1777,6 +1865,10 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { fd = my_openat(dirfd, pathname, flags, mode); else fd = my_openat(dirfd, pathname, flags); + if (fd < 0) { + std::string listing = deck_listing_for(note_sys_failure(dirfd, pathname).c_str()); + if (!listing.empty()) fd = my_openat(AT_FDCWD, listing.c_str(), flags); + } if (fake_path) free(fake_path); diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index a7832cf88..bb4a7dd89 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -647,7 +647,10 @@ class SessionService : Service() { // /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a // game becomes a node the game reads, carrying the player's layout, as on a Deck. guest.add("FAKE_EVDEV_UINPUT=1") - if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1") + if (SessionState.deckPad) { + guest.add("FAKE_EVDEV_DECK=1") + guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path) + } } else if (SessionState.mode == MODE_STEAM) { // Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity - // for games the client starts only. Everywhere else (the desktop, a program from the diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt index 39747df8f..c4e3b06d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -23,7 +23,11 @@ import java.io.File * - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the * runtime already binds there for the GPU); * - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without - * which libudev's enumeration leaves a device node out. + * which libudev's enumeration leaves a device node out; + * - [listingDir] - stand-ins for the listings of `/sys`, `/sys/class` and `/sys/bus`. An app under + * an enforcing SELinux policy (every retail phone) may not list those directories, and libudev + * abandons its whole scan when it cannot, so the client never reached `hidraw`; libfakeinput + * lists these to the client instead when the real listing is refused (FAKE_DECK_SYSFS_LISTING). * * Symlinks are absolute guest paths; proot resolves them inside the session. */ @@ -47,6 +51,9 @@ object SteamDeckPad { 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, ).map { it.toByte() }.toByteArray() + /** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */ + fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") + /** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */ fun prepare(context: Context, sessionRoot: File): List { val base = File(sessionRoot, "sys/deck") @@ -89,6 +96,11 @@ object SteamDeckPad { hidrawClass.mkdirs() link(File(hidrawClass, NODE), HIDRAW) + + // Only the names matter: libudev reads a listing for the subsystems to descend into and + // then opens /sys/class/hidraw itself, which is the bind above. + val listing = listingDir(sessionRoot) + for (name in listOf("sys/bus", "sys/class", "sys/devices", "class/hidraw", "bus")) File(listing, name).mkdirs() // The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode). link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)