Compare commits

...
Author SHA1 Message Date
The412Banner 911ad29004 Progress log: 2026-09-25 2026-09-25 17:29:12 -04:00
The412Banner 8cdedbe18a Merge pull request #22 from Droid-Deck/feat/steam-touch
Steam client: real touch in Big Picture, and a touchpad cursor that hovers and clicks
2026-09-25 17:06:19 -04:00
The412Banner 0f39d71f95 Merge remote-tracking branch 'org/main' into feat/steam-touch 2026-09-25 16:58:18 -04:00
The412Banner 1dc49b527b gamescope: stage 3.16.29-p3 (0111, pointer warps in Passthrough) 2026-09-25 16:38:50 -04:00
The412Banner cc3fec0768 gamescope: the nested pointer moves Steam's pointer in Passthrough too
In touchpad mode the cursor moved but Big Picture highlighted nothing and a tap-to-click
selected nothing: the nested pointer's motion reaches wlserver as touch 0, and in Passthrough
(the touch mode Big Picture sets) a motion for a touch that is not down moves nothing, so the X
pointer stayed where it was. 0111 passes bAlwaysWarpCursor, as the other touch modes already warp.
2026-09-25 16:34:22 -04:00
Kurt Himebauch 5270574e5a Add native Decky Loader support (#16)
* Add native Decky Loader management

* Enable Decky Steam frontend injection

* Disable unavailable stable Decky channel

* Decky: tie CEF debugging to session setting

* Decky: simplify Steam settings controls
2026-09-25 16:26:29 -04:00
The412Banner a8af973726 gamescope: stage 3.16.29-p2 (0110, touch from the host) 2026-09-25 16:06:31 -04:00
The412Banner ed1f8dd2b6 gamescope: take the host's touch in the nested Wayland backend
3.16.29's Wayland backend bound only wl_pointer and wl_keyboard; touch from DroidDeck's
compositor was never read, so Steam could not be touched. 0110 binds wl_touch in the input
thread and feeds each finger to wlserver_touchdown/touchmotion/touchup, the Steam Deck
touchscreen's path, so Big Picture's Passthrough touch mode gets real touches.
2026-09-25 15:55:26 -04:00
The412Banner e8aaae1343 Touch: a lost finger-up no longer blocks every later touch
On the pointer path (a client that takes no wl_touch - gamescope), touch_down counted the
fingers still down before clearing a sequence whose up had been lost, so the stale point left
any_active set and every later tap or swipe was dropped: in Direct touch mode Big Picture
ignored the screen while the pad worked. The count is taken after the clean-up, and the app
ends whatever the compositor still holds when a new gesture's first finger lands (Android has
ended every earlier gesture by then). The compositor logs once per gesture whether the window
got touch or the pointer.
2026-09-25 15:55:26 -04:00
11 changed files with 561 additions and 6 deletions

No files matched your search

+12 -3
View File
@@ -3210,23 +3210,32 @@ static void touch_down(int id, double x, double y) {
struct surface *target = touch_target_at(x, y);
if (!target) return;
struct wl_client *client = wl_resource_get_client(target->resource);
/* A down on an id that is still held means that sequence's up was lost: end it first. The
* fingers still down are counted only after that - counted before, a lost up left any_active
* set, and on the pointer path every later touch was dropped (a tap or swipe did nothing). */
if (g_touch_points[id].surface) touch_cancel_client(client);
int any_active = 0;
for (int i = 0; i < MAX_TOUCH_POINTS; i++) {
struct surface *active = g_touch_points[i].surface;
if (!active) continue;
any_active = 1;
if (active != target) {
touch_cancel_all();
any_active = 0;
break;
}
any_active = 1;
}
if (g_touch_points[id].surface) touch_cancel_client(client);
struct seat_touch *st;
int has_touch = 0;
for_each_touch_of(client, st) has_touch = 1;
/* One line per gesture (first finger), so a test can see where a touch went. */
if (!any_active) {
char name[160];
describe(target, name, sizeof(name));
banner_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
}
if (!has_touch) {
if (any_active) return;
if (any_active) return; /* one finger drives the pointer; a second one is ignored */
g_touch_points[id].surface = target;
g_touch_points[id].pointer_fallback = 1;
pointer_event(x, y, BTN_LEFT, 1);
@@ -29,6 +29,7 @@ import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.session.OfflineMode
@@ -88,6 +89,13 @@ class MainActivity : ComponentActivity() {
private var steamDeckMode by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
private var deckyInstalled by mutableStateOf<String?>(null)
private var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
private var deckyChecking by mutableStateOf(false)
private var deckyStage by mutableStateOf<String?>(null)
private var deckyPercent by mutableIntStateOf(-1)
private var deckySupervisor by mutableStateOf(false)
private var deckyReleaseRequest = 0
private var showMapping by mutableStateOf(false)
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
@@ -416,6 +424,9 @@ class MainActivity : ComponentActivity() {
refreshHomeAppState()
refreshSecondScreenDisplays()
refresh()
deckyInstalled = DeckyManager.installed(this)
DeckyManager.syncCefMarker(this)
deckySupervisor = DeckyManager.supervisorEnabled(this)
// Added games' art (a store lookup for what the folders lack) starts here, not only when
// the cog opens.
refreshAddedGames()
@@ -486,6 +497,37 @@ class MainActivity : ComponentActivity() {
refreshProtons()
}
private fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(this) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
// Use the newest compatible stable build, or the newest compatible
// prerelease when the fork has not published a stable ARM64 asset.
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
private fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(this, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(this)
if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-decky").start()
}
private fun refreshController() {
controllerSettings = ControllerPrefs.read(this)
}
@@ -623,6 +665,10 @@ class MainActivity : ComponentActivity() {
addedGamesArt = addedGamesArt,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus,
releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) deckyReleases.firstOrNull() else null,
deckyChecking = deckyChecking, deckyStage = deckyStage, deckyPercent = deckyPercent,
deckyEnabled = deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
@@ -672,6 +718,14 @@ class MainActivity : ComponentActivity() {
pendingAddedGame = folder
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
},
onDeckyInstall = { release -> installDecky(release) },
onDeckyCheck = { refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
deckyInstalled = null
deckySupervisor = false
},
onDismiss = { settingsMode = null },
),
)
@@ -749,6 +803,11 @@ class MainActivity : ComponentActivity() {
renderer = SessionPrefs.desktopRenderer(this)
gameStorage = SessionPrefs.gameStorage(this)
storageOptions = GameStorage.options(this).map { it.label to it.path }
if (mode == SessionService.MODE_STEAM) {
deckyInstalled = DeckyManager.installed(this)
deckySupervisor = DeckyManager.supervisorEnabled(this)
refreshDecky()
}
settingsMode = mode
}
@@ -1231,7 +1231,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendTouch(action, event.getPointerId(index), (x * 1919f).toInt(), (y * 1079f).toInt())
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex)
// A first finger starts a new gesture, and Android has ended every earlier one: whatever
// the compositor still holds (an up lost to the drawer opening mid-touch) goes first.
MotionEvent.ACTION_DOWN -> {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
sendTouch(0, event.actionIndex)
}
MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex)
MotionEvent.ACTION_MOVE -> for (index in 0 until event.pointerCount) sendTouch(1, index)
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> sendTouch(2, event.actionIndex)
MotionEvent.ACTION_CANCEL -> WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
@@ -0,0 +1,168 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
import org.json.JSONArray
/** Optional Decky Loader installation for the ARM64 Linux guest. */
object DeckyManager {
private const val TAG = "DeckyManager"
const val REPOSITORY = "Droid-Deck/decky-loader"
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
private const val PATH = "root/homebrew/services/PluginLoader"
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
fun installed(context: Context): String? {
val loader = loader(context)
if (!loader.isFile || !loader.canExecute()) return null
return File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: "Installed"
}
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
fun supervisorEnabled(context: Context) = File(LinuxRuntime.rootDir(context), "root/.droiddeck-decky-enabled").isFile
/** Remove the legacy install-time debugger marker unless the user opted into Decky. */
fun syncCefMarker(context: Context) {
val marker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
if (!supervisorEnabled(context)) {
marker.delete()
return
}
runCatching {
marker.parentFile?.mkdirs()
marker.createNewFile()
}
}
fun setSupervisorEnabled(context: Context, enabled: Boolean) {
val marker = File(LinuxRuntime.rootDir(context), "root/.droiddeck-decky-enabled")
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
if (enabled) {
marker.parentFile?.mkdirs()
marker.writeText("enabled\n")
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile()
} else {
marker.delete()
cefMarker.delete()
}
}
fun releases(context: Context, prerelease: Boolean): List<Release> {
val channels = releaseChannels(context)
return if (prerelease) channels.prerelease else channels.stable
}
fun releaseChannels(context: Context): ReleaseChannels {
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
return try {
val array = JSONArray(body)
val stable = mutableListOf<Release>()
val prerelease = mutableListOf<Release>()
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false)) continue
val isPrerelease = release.optBoolean("prerelease", false)
val assets = release.optJSONArray("assets") ?: continue
val rows = (0 until assets.length()).map { assets.getJSONObject(it) }
val arm = rows.firstOrNull { it.optString("name") == "PluginLoader-arm64" }
val guest = LinuxRuntime.rootDir(context)
val fexReady = File(guest, "usr/share/guestos/fex-mesa").isDirectory && File(guest, "usr/bin/FEX").canExecute()
val binary = arm ?: if (fexReady) rows.firstOrNull { it.optString("name") == "PluginLoader" } else null
val name = binary?.optString("name") ?: continue
val url = binary.optString("browser_download_url").takeIf { it.startsWith("https://") } ?: continue
val checksum = rows.firstOrNull {
val n = it.optString("name")
(n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://")
}?.optString("browser_download_url")?.takeIf { it.startsWith("https://") }
val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) }
if (digest == null && checksum == null) continue
val size = binary.optLong("size", 0)
if (size <= 0) continue
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
(if (isPrerelease) prerelease else stable).add(row)
}
ReleaseChannels(stable, prerelease)
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
}
/** Downloads to a sibling temp file and atomically renames only after size, digest and ELF checks. */
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing Decky Loader"
val target = loader(context)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "PluginLoader.download")
val ok = Downloader.downloadFile(release.url, temp, false) { f -> progress("Downloading ${release.tag}", if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }
if (!ok) { temp.delete(); return "Decky Loader download failed" }
if (temp.length() != release.size) { temp.delete(); return "Decky Loader size did not match the release metadata" }
val expected = release.digest?.substringAfter(':') ?: release.shaUrl?.let { checksumUrl ->
val checksumBody = Downloader.downloadString(checksumUrl) ?: run { temp.delete(); return "Could not download the release checksum" }
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
// Decky needs Steam's local CEF debugger only when its session supervisor is enabled.
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile() || cefMarker.isFile
}.getOrDefault(false)
if (!cefReady) { temp.delete(); return "Could not enable Steam CEF remote debugging" }
val staged = File(target.parentFile, "PluginLoader.new")
staged.delete()
if (!temp.renameTo(staged)) { temp.delete(); return "Could not stage PluginLoader" }
val old = File(target.parentFile, "PluginLoader.old")
old.delete()
if (target.exists() && !target.renameTo(old)) { staged.delete(); return "Could not preserve the existing PluginLoader" }
if (!staged.renameTo(target)) {
if (old.exists()) old.renameTo(target)
staged.delete()
return "Could not activate the new PluginLoader"
}
old.delete()
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
File(target.parentFile, ".droiddeck-decky-arch").writeText(if (release.machine == 183) "arm64\n" else "x86_64\n")
if (!supervisorEnabled(context)) cefMarker.delete()
return null
}
fun uninstall(context: Context, wipeData: Boolean) {
val root = LinuxRuntime.rootDir(context)
setSupervisorEnabled(context, false)
File(root, PATH).delete()
File(root, VERSION).delete()
File(root, "root/homebrew/services/.droiddeck-decky-arch").delete()
File(root, CEF_REMOTE_DEBUG_MARKER).delete()
if (wipeData) File(root, "root/homebrew").deleteRecursively()
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).buffered().use { input ->
val buffer = ByteArray(1024 * 1024)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun validElf(file: File, machine: Int): Boolean = runCatching {
file.inputStream().use { input ->
val h = ByteArray(20)
if (input.read(h) != h.size) return false
h[0] == 0x7f.toByte() && h[1] == 'E'.code.toByte() && h[2] == 'L'.code.toByte() && h[3] == 'F'.code.toByte() &&
h[4] == 2.toByte() && h[5] == 1.toByte() && (h[18].toInt() and 255) + ((h[19].toInt() and 255) shl 8) == machine
}
}.getOrDefault(false)
}
@@ -1,10 +1,16 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -15,6 +21,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionService
@@ -70,6 +77,14 @@ class ModeSettings(
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,
val deckyChecking: Boolean = false,
val deckyStage: String? = null,
val deckyPercent: Int = -1,
val deckyEnabled: Boolean = false,
val deckySessionRunning: Boolean = false,
)
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
@@ -109,6 +124,10 @@ class ModeSettingsActions(
val onAddedGamesArt: (Boolean) -> Unit = {},
val onAddedGameExe: (folderPath: String, path: String) -> Unit = { _, _ -> },
val onPickAddedGameExe: (folderPath: String) -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
val onDeckyCheck: () -> Unit = {},
val onDeckyEnabled: (Boolean) -> Unit = {},
val onDeckyUninstall: () -> Unit = {},
val onDismiss: () -> Unit,
)
@@ -116,6 +135,7 @@ class ModeSettingsActions(
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.
@@ -253,6 +273,58 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = a.onSuspendPolicy,
)
}
if (steam) SettingsGroup("Decky") {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
s.deckyStage != null -> s.deckyStage
s.deckyChecking -> "Checking compatible releases…"
s.deckyInstalled == null && s.deckyLatestRelease != null -> "Ready to install ${s.deckyLatestRelease.tag}."
s.deckyInstalled == null -> "No compatible build found. Check again later."
s.deckyLatestRelease == null -> "Installed · ${s.deckyInstalled}"
updateAvailable -> "Update available · ${s.deckyLatestRelease.tag}"
else -> "Up to date · ${s.deckyInstalled}"
}
SettingsRow("Loader", status) {
val action = when {
s.deckyStage != null -> "Working…"
s.deckyChecking -> "Checking…"
s.deckyInstalled == null && s.deckyLatestRelease != null -> "Install latest"
s.deckyInstalled != null && updateAvailable -> "Update"
else -> "Check"
}
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
SecondaryButton(
action,
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
) {
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
else s.deckyLatestRelease?.let(a.onDeckyInstall)
}
if (s.deckyInstalled != null) SecondaryButton(
"Uninstall",
enabled = s.deckyStage == null && !s.deckySessionRunning,
) { confirmDeckyRemoval = true }
}
}
if (s.deckyInstalled != null) SettingsRow(
"Decky",
if (s.deckyEnabled) "Starts with Steam. Other apps on this device may be able to control Steam while enabled."
else "Off. Decky stays off and Steam's remote debugging port stays closed.",
) {
Switch(
checked = s.deckyEnabled,
onCheckedChange = a.onDeckyEnabled,
enabled = !s.deckySessionRunning && s.deckyStage == null,
)
}
if (s.deckyStage != null && s.deckyPercent >= 0) {
LinearProgressIndicator(
progress = { s.deckyPercent / 100f },
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
if (steam && s.steamChannel != null) SettingsGroup("Client") {
ChoiceRow(
host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.",
@@ -349,6 +421,15 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (confirmDeckyRemoval) AlertDialog(
onDismissRequest = { confirmDeckyRemoval = false },
title = { Text("Uninstall Decky Loader?") },
text = { Text("Remove the loader and keep your plugins and settings.") },
confirmButton = {
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text("Uninstall") }
},
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text("Cancel") } },
)
}
/** The Resolution menu's "Custom…" entry. */
+38
View File
@@ -7,6 +7,44 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky
State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds)
unless marked. gamescope now staged from release **`gamescope-3.16.29-p3`**.
- **PR #18 → `9369dc3`** (`feat/melonds-preset`) - melonDS and the session:
- **melonDS preset** (`bannerlator-pad-defaults`): the top screen as big as the panel allows with the bottom
screen alone beside it, centred (Horizontal + Emphasize top; Hybrid repeats the top screen), sharp pixels;
full screen from the front end (`-f`) at the panel's own resolution; the guide button toggles full
screen, leaving to a 1x-wide 16:9 window whose menus gamescope stretches to a usable size; DS/DSi BIOS,
firmware and NAND found in `ROMs/nds/{bios,firmware}` (BIOS by checksum). Existing installs moved once.
- **Desktop**: `QT_QPA_PLATFORM="wayland;xcb"` (the melonDS AppImage ships only xcb and exited at once);
a labwc window rule opens melonDS at 800x600, centred.
- **HUD** follows the game window, not gamescope's 1x1 cursor or Qt menus (read 0 fps at 60).
- **Cursor**: hides on pad / on-screen-control input (Bannerlator's 1.2 s rule), and shows the program's own
shape - labwc's resize arrows, I-beams, a hide (Bannerlator's `wl_pointer.set_cursor` port; new here:
labwc's GPU cursor is a UBWC dma-buf, read back through `vkp_image_readback`).
- **Steam desktop switching**: "Steam Desktop UI" opens the desktop with Steam's desktop client in it (under
gamescope the client picks Big Picture regardless); the drawer's **DESKTOP** button does it from Big
Picture; relaunches restart the singleTop activity in place (`startActivity` from it was swallowed).
Big Picture's own Power → Switch to Desktop still hangs: it waits on SteamOS Manager before its
`steamos-session-select` fallback (stub included; a session bus did not help - tried and reverted).
- **PR #19 → `ce0da01`** (`fix/log-privacy`) - no credentials or identities in session logs: the device's
public addresses (the client's IPv6 check logs "external address" ~20x a session), `network.txt` addresses
as their kind and the MAC masked, every Steam AccountName/PersonaName (plain account names leaked in the
UI log's login lines), and every text file scrubbed at session end and in the share-logs zip. Verified:
0 hits across 83 session folders.
- **PR #16 → `5270574`** (Decky Loader, Kurt) - merged with the Steam CEF debugging marker tied to the
supervisor toggle (it was created at install; on Android any app can reach 127.0.0.1:8080 - proven from
another uid). Decky's own updater still points at the official project (no ARM64 build there).
- **PR #20 → `036317f`** (Kurt) - local builds carry the CI payload (NDK proot, patched gamescope).
- **PR #22 → `8cdedbe`** (`feat/steam-touch`) - touch in Big Picture: gamescope `0110` binds `wl_touch` in the
nested backend and feeds wlserver's touch path (Big Picture sets Passthrough: tap and swipe rows as on a
Deck); `0111` makes the nested pointer warp in Passthrough (touchpad mode: hover and tap-to-click work);
the compositor's pointer fallback no longer jams on a lost finger-up.
- **In design, not built**: the drawer's page dots as QAM-style icons (Display / Controls / Settings), the
selected one "stepping forward" in a line; mock `droiddeck-drawer-tabs-preview.html`.
## 2026-09-24 (afternoon) - main `94f9935`: PS1, controller focus, own signing key, CI hardening
State: **main = `94f9935`** (PR #41 merge), main build run 36036219740 - the first one signed with
+11
View File
@@ -16,6 +16,17 @@ library list, before anything is published.
3.16.29: realtime-priority Vulkan queues on request (`GAMESCOPE_FORCE_VULKAN_REALTIME=1`)
without CAP_SYS_NICE, which proot can never have; and the gamepad-driven cursor sprite following
the X pointer that XTest moves (it sat frozen).
- `0110-wayland-backend-touch.patch` - this app: the nested Wayland backend bound only the host's
pointer and keyboard, so a finger on the phone's screen never reached Steam. It now binds
`wl_touch` too and hands each finger to wlserver's touch path (`wlserver_touchdown` / `motion` /
`up`) - the one a Steam Deck's touchscreen drives - so what a touch does follows the client's
touch mode (Steam's Big Picture sets Passthrough: a real touch, rows scroll under a finger).
Finger ids are offset by one, since the nested pointer already moves wlserver's touch 0.
- `0111-wayland-pointer-warps-in-passthrough.patch` - this app: the nested pointer's motion goes to
wlserver as touch 0, and in Passthrough (Big Picture's touch mode) a motion for a touch that is not
down moves nothing - so in the app's touchpad mode the Steam client saw no hover and a click landed
wherever the pointer had last been. The motion now always warps the real pointer as well
(`bAlwaysWarpCursor`), which the other touch modes did already.
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
@@ -0,0 +1,121 @@
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
index d74268b..bf72267 100644
--- a/src/Backends/WaylandBackend.cpp
+++ b/src/Backends/WaylandBackend.cpp
@@ -601,6 +601,17 @@ namespace gamescope
void Wayland_Keyboard_RepeatInfo( wl_keyboard *pKeyboard, int32_t nRate, int32_t nDelay );
static const wl_keyboard_listener s_KeyboardListener;
+ // Touch from the host compositor (DroidDeck): each finger goes to wlserver's touch path,
+ // the one a Steam Deck's touchscreen drives, instead of being dropped.
+ void Wayland_Touch_Down( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, wl_surface *pSurface, int32_t nId, wl_fixed_t fX, wl_fixed_t fY );
+ void Wayland_Touch_Up( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, int32_t nId );
+ void Wayland_Touch_Motion( wl_touch *pTouch, uint32_t uTime, int32_t nId, wl_fixed_t fX, wl_fixed_t fY );
+ void Wayland_Touch_Frame( wl_touch *pTouch );
+ void Wayland_Touch_Cancel( wl_touch *pTouch );
+ static const wl_touch_listener s_TouchListener;
+ bool TouchToOutput( wl_surface *pSurface, wl_fixed_t fX, wl_fixed_t fY, double *pflX, double *pflY );
+ std::unordered_map<int32_t, wl_surface *> m_TouchSurfaces;
+
void Wayland_RelativePointer_RelativeMotion( zwp_relative_pointer_v1 *pRelativePointer, uint32_t uTimeHi, uint32_t uTimeLo, wl_fixed_t fDx, wl_fixed_t fDy, wl_fixed_t fDxUnaccel, wl_fixed_t fDyUnaccel );
static const zwp_relative_pointer_v1_listener s_RelativePointerListener;
};
@@ -636,6 +647,16 @@ namespace gamescope
.modifiers = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Keyboard_Modifiers ),
.repeat_info = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Keyboard_RepeatInfo ),
};
+ const wl_touch_listener CWaylandInputThread::s_TouchListener =
+ {
+ .down = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Down ),
+ .up = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Up ),
+ .motion = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Motion ),
+ .frame = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Frame ),
+ .cancel = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_Touch_Cancel ),
+ .shape = WAYLAND_NULL(),
+ .orientation = WAYLAND_NULL(),
+ };
const zwp_relative_pointer_v1_listener CWaylandInputThread::s_RelativePointerListener =
{
.relative_motion = WAYLAND_USERDATA_TO_THIS( CWaylandInputThread, Wayland_RelativePointer_RelativeMotion ),
@@ -3067,6 +3088,81 @@ namespace gamescope
wl_keyboard_add_listener( m_pKeyboard, &s_KeyboardListener, this );
}
}
+
+ if ( !!( uCapabilities & WL_SEAT_CAPABILITY_TOUCH ) != !!m_pTouch )
+ {
+ if ( m_pTouch )
+ {
+ wl_touch_release( m_pTouch );
+ m_pTouch = nullptr;
+ m_TouchSurfaces.clear();
+ }
+ else
+ {
+ m_pTouch = wl_seat_get_touch( m_pSeat );
+ wl_touch_add_listener( m_pTouch, &s_TouchListener, this );
+ }
+ }
+ }
+
+ // Touch
+
+ bool CWaylandInputThread::TouchToOutput( wl_surface *pSurface, wl_fixed_t fX, wl_fixed_t fY, double *pflX, double *pflY )
+ {
+ if ( !pSurface || !IsGamescopeToplevel( pSurface ) )
+ return false;
+ CWaylandPlane *pPlane = (CWaylandPlane *)wl_surface_get_user_data( pSurface );
+ if ( !pPlane )
+ return false;
+ auto oState = pPlane->GetCurrentState();
+ if ( !oState || !g_nOutputWidth || !g_nOutputHeight )
+ return false;
+ uint32_t uScale = oState->uFractionalScale;
+ *pflX = ( wl_fixed_to_double( fX ) * uScale / 120.0 + oState->nDestX ) / g_nOutputWidth;
+ *pflY = ( wl_fixed_to_double( fY ) * uScale / 120.0 + oState->nDestY ) / g_nOutputHeight;
+ return true;
+ }
+ // Touch ids are offset by one: the nested pointer already moves wlserver's touch 0.
+ void CWaylandInputThread::Wayland_Touch_Down( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, wl_surface *pSurface, int32_t nId, wl_fixed_t fX, wl_fixed_t fY )
+ {
+ double flX, flY;
+ if ( !TouchToOutput( pSurface, fX, fY, &flX, &flY ) )
+ return;
+ m_TouchSurfaces[ nId ] = pSurface;
+ wlserver_lock();
+ wlserver_touchdown( flX, flY, nId + 1, ++m_uFakeTimestamp );
+ wlserver_unlock();
+ }
+ void CWaylandInputThread::Wayland_Touch_Up( wl_touch *pTouch, uint32_t uSerial, uint32_t uTime, int32_t nId )
+ {
+ if ( m_TouchSurfaces.erase( nId ) == 0 )
+ return;
+ wlserver_lock();
+ wlserver_touchup( nId + 1, ++m_uFakeTimestamp );
+ wlserver_unlock();
+ }
+ void CWaylandInputThread::Wayland_Touch_Motion( wl_touch *pTouch, uint32_t uTime, int32_t nId, wl_fixed_t fX, wl_fixed_t fY )
+ {
+ auto it = m_TouchSurfaces.find( nId );
+ if ( it == m_TouchSurfaces.end() )
+ return;
+ double flX, flY;
+ if ( !TouchToOutput( it->second, fX, fY, &flX, &flY ) )
+ return;
+ wlserver_lock();
+ wlserver_touchmotion( flX, flY, nId + 1, ++m_uFakeTimestamp );
+ wlserver_unlock();
+ }
+ void CWaylandInputThread::Wayland_Touch_Frame( wl_touch *pTouch )
+ {
+ }
+ void CWaylandInputThread::Wayland_Touch_Cancel( wl_touch *pTouch )
+ {
+ wlserver_lock();
+ for ( auto &[ nId, pSurface ] : m_TouchSurfaces )
+ wlserver_touchup( nId + 1, ++m_uFakeTimestamp );
+ wlserver_unlock();
+ m_TouchSurfaces.clear();
}
void CWaylandInputThread::Wayland_Seat_Name( wl_seat *pSeat, const char *pName )
@@ -0,0 +1,16 @@
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
index bf72267..c400978 100644
--- a/src/Backends/WaylandBackend.cpp
+++ b/src/Backends/WaylandBackend.cpp
@@ -3223,7 +3223,10 @@ namespace gamescope
double flY = ( wl_fixed_to_double( fSurfaceY ) * uScale / 120.0 + oState->nDestY ) / g_nOutputHeight;
wlserver_lock();
- wlserver_touchmotion( flX, flY, 0, ++m_uFakeTimestamp );
+ // Always move the real pointer too: in Passthrough (what Steam's Big Picture asks for) a
+ // touch motion for an id that is not down moves nothing, so hover never reached the
+ // client and a click landed wherever the pointer had last been.
+ wlserver_touchmotion( flX, flY, 0, ++m_uFakeTimestamp, true );
wlserver_unlock();
}
void CWaylandInputThread::Wayland_Pointer_Button( wl_pointer *pPointer, uint32_t uSerial, uint32_t uTime, uint32_t uButton, uint32_t uState )
+2 -2
View File
@@ -1,2 +1,2 @@
GAMESCOPE_TAG=gamescope-3.16.29-p1
GAMESCOPE_SHA256=522d1835d84e4a64a843e095bbe341f68b10f8467f38c0e72ae4ef20b675b873
GAMESCOPE_TAG=gamescope-3.16.29-p3
GAMESCOPE_SHA256=566e3b93931f37c30375d440d15ee76452892b842d2dc854cd3c06b926c8451b
@@ -440,6 +440,42 @@ BUSCONF
# falls through to the host, and the guest shell dies on libreadline before the game is reached.
# FEX_ROOTFS names it outright and is read whatever the config situation.
[ -d /usr/share/guestos/fex-mesa ] && export FEX_ROOTFS=/usr/share/guestos/fex-mesa
# Decky is optional and session scoped: the app's marker opts in, and this supervisor is
# reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used
# only when its recorded architecture and this guest's existing FEX installation agree.
decky_loader=$HOME/homebrew/services/PluginLoader
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown)
if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && [ -x /usr/bin/FEX ] && [ -n "${FEX_ROOTFS:-}" ]; }; then
(
decky_child=
decky_stop() { [ -z "$decky_child" ] || kill "$decky_child" 2>/dev/null; [ -z "$decky_child" ] || wait "$decky_child" 2>/dev/null; exit 0; }
trap decky_stop TERM INT HUP
decky_exits=0
while :; do
if [ "$decky_arch" = x86_64 ]; then /usr/bin/FEX "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
else "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 &
fi
decky_child=$!
decky_status=0
wait "$decky_child" || decky_status=$?
decky_child=
decky_exits=$((decky_exits + 1))
if [ "$decky_exits" -ge 5 ]; then
echo "== Decky: stopped after $decky_exits loader exits (last status $decky_status)"
break
fi
decky_delay=$((decky_exits * 3))
echo "== Decky: loader exited with status $decky_status; restart $((decky_exits + 1))/5 in ${decky_delay}s"
sleep "$decky_delay"
done
) &
decky_supervisor=$!
echo "== Decky: session supervisor started ($decky_arch)"
else
echo "== Decky: installed loader architecture is unsupported by this guest; not starting"
fi
fi
# The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
# BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
@@ -521,10 +557,20 @@ BUSCONF
# script's parent, so reap_orphans - right under gamescope, where everything is the client's -
# is not for here. The helpers this run started (the cores and redistributables beats, the
# system bus and the NetworkManager stand-in) go with the client instead.
if [ -n "${decky_supervisor:-}" ]; then
kill "$decky_supervisor" 2>/dev/null || true
wait "$decky_supervisor" 2>/dev/null || true
echo "== Decky: session supervisor stopped"
fi
_bl_jobs=$(jobs -p)
[ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null
echo "== steam: the client on the desktop has exited"
else
if [ -n "${decky_supervisor:-}" ]; then
kill "$decky_supervisor" 2>/dev/null || true
wait "$decky_supervisor" 2>/dev/null || true
echo "== Decky: session supervisor stopped"
fi
reap_orphans
fi
exit "$rc"