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
Kurt Himebauch 036317f371 Build local APK with CI payload parity (#20) 2026-09-25 13:20:51 -04:00
The412Banner ce0da01163 Merge pull request #19 from Droid-Deck/fix/log-privacy
Session logs: scrub public addresses, device id, Steam account names, and every file
2026-09-25 12:53:30 -04:00
The412Banner 75ae619e21 Logs: scrub Steam account and persona names for every user
The client's UI log names the signed-in account on every login change ("Login:
OnLoginStateChange <account> ...", "OnLoginUsersChanged <account>"), ~200 lines on one device.
An email account was caught by the email rule; a plain account name was not, so a user signing in
with one had it in every shared log. Those two lines now lose the account, and every
AccountName and PersonaName in the client's loginusers.vdf is learned (with the device's own
addresses, learnFromRuntime) and replaced wherever it appears. The one-time pass over older
folders runs again for this (marker .scrubbed-2).
2026-09-25 11:51:37 -04:00
The412Banner 3efa5acfa6 Logs: no public addresses or device id, and every session file scrubbed
A privacy pass over the session folders found three leaks:
- the Steam client's IPv6 check logs "external address <the device's public IPv6>" about
  twenty times a session, and the redactor kept IP addresses on purpose, so steam/
  connection_log.txt carried it;
- network.txt listed the device's addresses (public IPv6 included) and the session's made-up
  MAC, which is seeded from ANDROID_ID and so stable per device;
- session.log, app.log, desktop.log, steam-desktop.log, crash.log were never scrubbed (clean on
  device, by luck).

The redactor now learns the device's own addresses from the session's link file (a global IPv6
as its /64, a public IPv4 exactly) and removes them wherever they appear, along with anything
logged as an "external address"; Valve's server addresses stay. network.txt gives every address
as its kind ("<public IPv6>") and masks the MAC. Every text file in a session folder goes
through the redactor when the session ends (killed sessions at the next app start), and again on
its way into the share-logs zip. Folders written before this are scrubbed once at app start.
2026-09-25 11:30:16 -04:00
The412Banner 9369dc3031 Merge pull request #18 from Droid-Deck/feat/melonds-preset
melonDS out of the box, cursor shapes and auto-hide, Steam desktop switching
2026-09-25 11:15:19 -04:00
16 changed files with 934 additions and 76 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)
@@ -403,7 +411,10 @@ class MainActivity : ComponentActivity() {
}
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({ SessionArtifacts.finishAbandoned(this) }, "finish-abandoned").start()
if (!SessionState.running) Thread({
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
}, "finish-abandoned").start()
}
override fun onResume() {
@@ -413,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()
@@ -483,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)
}
@@ -620,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 },
@@ -669,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 },
),
)
@@ -746,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)
@@ -12,9 +12,16 @@ package com.droiddeck.launcher.core
* client logs; the account-name registration is left out because this app never learns it.
*
* What is deliberately KEPT, because it is what makes a log worth reading: EResult codes, CM host
* names and IP addresses, timings, token expiry dates, app IDs, pids, connection-state changes and
* file paths. A SteamID is masked rather than deleted - first four and last four digits survive -
* so a reader can still tell two accounts' lines apart without the value identifying either.
* names and Valve's server addresses, timings, token expiry dates, app IDs, pids, connection-state
* changes and file paths. A SteamID is masked rather than deleted - first four and last four digits
* survive - so a reader can still tell two accounts' lines apart without the value identifying either.
*
* The device's OWN addresses are not kept: the client's IPv6 check logs "external address <ours>"
* about twenty times a session, and a public IPv6 address is the user's. [learnOwnAddresses] reads
* the link the session was given (etc/bannerlator-net) and every address on the same /64 - privacy
* addresses rotate inside it - or an own public IPv4 is replaced wherever it appears. Likewise every
* Steam account and persona name on the device ([learnAccounts]), for users who sign in with an
* account name rather than an email.
*/
object LogRedactor {
private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}")
@@ -48,6 +55,145 @@ object LogRedactor {
private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}")
private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b")
private val STEAMID3 = Regex("\\[U:1:(\\d+)]")
/** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */
private val EXTERNAL_ADDR = Regex("(?i)(external\\s+(?:ip\\s+)?address\\s*[:=]?\\s*|external\\s+ip\\s*[:=]?\\s*)([0-9A-Fa-f:.]{7,})")
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
/**
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
* user who signs in with a plain account name, not an email, had it in every log. The single
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
*/
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
@Volatile
private var accounts: List<Regex> = emptyList()
/**
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
* and PersonaName - so they are replaced wherever a log mentions them, whoever the user is.
* Names under three characters are skipped: blanking every "a" would ruin a log.
*/
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Regex>()
try {
if (loginUsers.isFile) {
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
}
}
} catch (e: Exception) {
return
}
accounts = found
}
/** Everything the session's runtime can tell about whose logs these are: addresses and accounts. */
fun learnFromRuntime(root: java.io.File) {
learnOwnAddresses(java.io.File(root, "etc/bannerlator-net"))
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
}
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
@Volatile
private var own: List<Regex> = emptyList()
/**
* Learns the device's addresses from the link file the app writes for the session
* (`addr <address> <prefix>` lines). Global IPv6 become their /64, public IPv4 stay exact;
* private, link-local and loopback addresses identify nobody and are left out.
*/
fun learnOwnAddresses(linkFile: java.io.File) {
val found = ArrayList<Regex>()
try {
if (linkFile.isFile) linkFile.forEachLine { line ->
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
when (kind(addr)) {
"public IPv6" -> {
val groups = expand6(addr)?.take(4) ?: return@forEachLine
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
}
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
}
}
} catch (e: Exception) {
return
}
own = found
}
/** "public IPv6", "private IPv4", "link-local IPv6", ... for an address literal; null if it is not one. */
fun kind(address: String): String? {
val a = address.substringBefore('%')
IPV4.matchEntire(a)?.let { m ->
val o = m.groupValues.drop(1).map { it.toInt() }
if (o.any { it > 255 }) return null
return when {
o[0] == 127 -> "loopback IPv4"
o[0] == 10 || (o[0] == 172 && o[1] in 16..31) || (o[0] == 192 && o[1] == 168) -> "private IPv4"
o[0] == 169 && o[1] == 254 -> "link-local IPv4"
o[0] == 100 && o[1] in 64..127 -> "carrier-NAT IPv4"
else -> "public IPv4"
}
}
val g = expand6(a) ?: return null
val first = g[0].toInt(16)
return when {
g.all { it.toInt(16) == 0 } || (g.take(7).all { it.toInt(16) == 0 } && g[7].toInt(16) == 1) -> "loopback IPv6"
first and 0xffc0 == 0xfe80 -> "link-local IPv6"
first and 0xfe00 == 0xfc00 -> "private IPv6"
else -> "public IPv6"
}
}
/** The eight groups of an IPv6 literal, or null. */
private fun expand6(address: String): List<String>? {
val a = address.substringBefore('%')
if (!a.contains(':') || a.count { it == ':' } > 7 || a.contains(":::")) return null
if (!a.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == ':' }) return null
val parts = if (a.contains("::")) {
val (head, tail) = a.split("::", limit = 2)
val h = if (head.isEmpty()) emptyList() else head.split(':')
val t = if (tail.isEmpty()) emptyList() else tail.split(':')
if (h.size + t.size > 7) return null
h + List(8 - h.size - t.size) { "0" } + t
} else a.split(':')
if (parts.size != 8 || parts.any { it.isEmpty() || it.length > 4 }) return null
return parts.map { it.lowercase() }
}
/**
* Every IP address in [text] replaced by what kind it is ("<public IPv6>"), for reports whose
* point is the shape of the network, not its numbers.
*/
fun describeAddresses(text: String): String {
var out = IPV6.replace(text) { m -> kind(m.value)?.let { "<$it>" } ?: m.value }
out = IPV4.replace(out) { m -> kind(m.value)?.let { "<$it>" } ?: m.value }
return out
}
/** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
false
}
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
}
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
@@ -66,6 +212,11 @@ object LogRedactor {
val id = m.groupValues[1]
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
}
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
for (r in own) out = r.replace(out, "<redacted:ip>")
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
for (r in accounts) out = r.replace(out, "<redacted:account>")
out = EMAIL.replace(out, "<redacted:email>")
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
out = LONG_TOKEN.replace(out, "<redacted:token>")
@@ -27,7 +27,12 @@ object NetworkReport {
fun write(context: Context, target: File) {
try {
target.writeText(build(context))
// Addresses as their kind ("<public IPv6>"), never the numbers: a public address is the
// user's, and this file goes into shared session logs. The session's made-up hardware
// address is stable per device (seeded from ANDROID_ID), so it is masked too.
val text = LogRedactor.describeAddresses(build(context))
.replace(Regex("(?m)^(\\s*mac )\\S+"), "$1<masked>")
target.writeText(text)
} catch (e: Exception) {
Log.w(TAG, "could not write $target", e)
}
@@ -36,7 +41,7 @@ object NetworkReport {
private fun build(context: Context): String = buildString {
append("Network as the session starts\n")
append("=============================\n")
append("The SSID is deliberately not recorded. Transport, address families and DNS are.\n\n")
append("The SSID is deliberately not recorded, and addresses appear as their kind, not their numbers.\n\n")
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = cm?.activeNetwork
if (cm == null || network == null) {
@@ -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)
}
@@ -31,6 +31,9 @@ object SessionArtifacts {
/** Written last; a folder without it did not get its ending. */
const val COMPLETE_MARKER = ".complete"
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
fun collect(context: Context, dir: File, reason: String) {
try {
@@ -40,9 +43,12 @@ object SessionArtifacts {
src.copyTo(wayland, overwrite = true)
}
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
copySteamLogs(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -50,6 +56,47 @@ object SessionArtifacts {
}
}
/**
* Once, for session folders written before every file was scrubbed and before the device's own
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
* A marker records it. Runs at app start with [finishAbandoned].
*/
@Synchronized
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()
} ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
scrubFolder(dir)
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
}
/**
* The session's own files (session.log, app.log, desktop.log, the Steam desktop client's
* steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one
* through the redactor before the folder can be shared. A file is rewritten only if a line
* changed. steam/ was scrubbed on the way in.
*/
private fun scrubFolder(dir: File) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
try {
val tmp = File(dir, ".${f.name}.scrub")
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
}
}
}
/** Steam's logs: redacted into steam/, never copied verbatim. */
private fun copySteamLogs(context: Context, dir: File) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
@@ -81,6 +128,7 @@ object SessionArtifacts {
}?.sortedBy { it.name } ?: return
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
abandoned.forEachIndexed { i, dir ->
if (dir == current) return@forEachIndexed
val newest = i == abandoned.lastIndex
@@ -100,6 +148,7 @@ object SessionArtifacts {
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
scrubFolder(dir)
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
@@ -4,6 +4,7 @@ import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.zip.ZipEntry
@@ -31,10 +32,19 @@ object SessionLogShare {
if (files.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
val zip = File(out, "DroidDeck-${folder.name}.zip")
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
// 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))
f.inputStream().use { it.copyTo(z) }
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
}
@@ -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
+148 -63
View File
@@ -27,6 +27,66 @@ if ! command -v docker >/dev/null 2>&1; then
echo "Docker is required to cross-compile the glibc ARM64 preload libraries." >&2
exit 1
fi
for tool in curl tar zstd shasum unzip; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "${tool} is required to build the CI-equivalent APK." >&2
exit 1
fi
done
if [[ -f "${repo_root}/tools/gamescope/release.env" || -f "${repo_root}/tools/wlroots/release.env" ]] \
&& ! command -v gh >/dev/null 2>&1; then
echo "GitHub CLI is required to download the pinned Gamescope and wlroots release assets." >&2
exit 1
fi
ndk_version=${DROIDDECK_NDK_VERSION:-}
if [[ -z "${ndk_version}" ]]; then
ndk_path=$(find "${sdk_dir}/ndk" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
ndk_version=${ndk_path##*/}
fi
if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then
echo "No Android NDK found under ${sdk_dir}/ndk; set DROIDDECK_NDK_VERSION." >&2
exit 1
fi
export ANDROID_HOME="${sdk_dir}"
export ANDROID_SDK_ROOT="${sdk_dir}"
export JAVA_HOME="${java_dir}"
export NDK="${sdk_dir}/ndk/${ndk_version}"
staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/droiddeck-build.XXXXXX")
bundle_asset="${repo_root}/app/src/main/assets/pulseaudio.tzst"
bundle_backup="${staging_dir}/pulseaudio.original.tzst"
bundle_replaced=0
linuxfs_dir="${repo_root}/app/src/main/assets/linuxfs"
linuxfs_backup="${staging_dir}/linuxfs.original"
linuxfs_preexisting=0
linuxfs_replaced=0
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "${bundle_replaced}" == 1 ]]; then
cp -p "${bundle_backup}" "${bundle_asset}" || exit_code=1
fi
if [[ "${linuxfs_replaced}" == 1 ]]; then
rm -rf -- "${linuxfs_dir}" || exit_code=1
if [[ "${linuxfs_preexisting}" == 1 ]]; then
mv "${linuxfs_backup}" "${linuxfs_dir}" || exit_code=1
fi
fi
rm -rf -- "${staging_dir}" || exit_code=1
exit "${exit_code}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [[ -d "${linuxfs_dir}" ]]; then
cp -a "${linuxfs_dir}" "${linuxfs_backup}"
linuxfs_preexisting=1
fi
linuxfs_replaced=1
rm -rf -- "${linuxfs_dir}"
mkdir -p "${linuxfs_dir}"
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
docker build --platform linux/amd64 -t "${image_name}" \
@@ -49,7 +109,12 @@ docker run --rm --platform linux/amd64 \
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
install -Dm644 "$script" "$d/usr/local/bin/$(basename "$script")"
done
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
[ -f "$f" ] && install -Dm644 "$f" "$d/${f#tools/linuxfs/overlay/}"
done
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$d/usr/local/bin/droiddeck-desktop"
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$d/usr/local/bin/droiddeck-gpu"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$d/usr/local/bin/droiddeck-desktop-gpu"
install -Dm644 tools/linuxfs/desktop/autostart "$d/etc/xdg/labwc/autostart"
install -Dm644 tools/linuxfs/desktop/rc.xml "$d/etc/xdg/labwc/rc.xml"
install -Dm644 tools/linuxfs/desktop/panel.conf "$d/etc/xdg/lxqt/panel.conf"
@@ -82,42 +147,56 @@ docker run --rm --platform linux/amd64 \
test -f "$d/usr/local/bin/bannerlator-proton-extra"
'
export ANDROID_HOME="${sdk_dir}"
export ANDROID_SDK_ROOT="${sdk_dir}"
export JAVA_HOME="${java_dir}"
ndk_version=${DROIDDECK_NDK_VERSION:-}
if [[ -z "${ndk_version}" ]]; then
ndk_path=$(find "${sdk_dir}/ndk" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
ndk_version=${ndk_path##*/}
fi
if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then
echo "No Android NDK found under ${sdk_dir}/ndk; set DROIDDECK_NDK_VERSION." >&2
exit 1
github_repo=${DROIDDECK_GITHUB_REPOSITORY:-}
if [[ -z "${github_repo}" ]]; then
origin_url=$(git -C "${repo_root}" remote get-url origin)
case "${origin_url}" in
https://github.com/*) github_repo=${origin_url#https://github.com/} ;;
ssh://[email protected]/*) github_repo=${origin_url#ssh://[email protected]/} ;;
[email protected]:*) github_repo=${origin_url#[email protected]:} ;;
*)
echo "Cannot determine the GitHub repository from origin: ${origin_url}" >&2
echo "Set DROIDDECK_GITHUB_REPOSITORY=owner/repo." >&2
exit 1
;;
esac
github_repo=${github_repo%.git}
fi
for tool in curl tar zstd; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "${tool} is required to build the bundled audio modules." >&2
exit 1
fi
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
. "${repo_root}/tools/gamescope/release.env"
gamescope_archive="${staging_dir}/gamescope.tzst"
gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}"
printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c -
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
fi
if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then
. "${repo_root}/tools/wlroots/release.env"
wlroots_archive="${staging_dir}/wlroots.tzst"
gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}"
printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c -
zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
fi
mango_dir="${linuxfs_dir}/usr/local/lib/mangoapp"
mango_pkgs="${staging_dir}/mango-pkgs"
mkdir -p "${mango_dir}" "${mango_pkgs}"
while read -r package_sha256 package_url; do
[[ -n "${package_url}" ]] || continue
package_archive="${staging_dir}/$(basename "${package_url}")"
curl -fsSL --retry 3 -o "${package_archive}" "${package_url}"
printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c -
zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}"
done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt")
install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp"
for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
cp -L "${mango_pkgs}/usr/lib/${library}" "${mango_dir}/${library}"
done
staging_dir=$(mktemp -d "${TMPDIR:-/tmp}/droiddeck-build.XXXXXX")
bundle_asset="${repo_root}/app/src/main/assets/pulseaudio.tzst"
bundle_backup="${staging_dir}/pulseaudio.original.tzst"
bundle_replaced=0
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "${bundle_replaced}" == 1 ]]; then
cp -p "${bundle_backup}" "${bundle_asset}" || exit_code=1
fi
rm -rf -- "${staging_dir}" || exit_code=1
exit "${exit_code}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
mkdir -p "${linuxfs_dir}/usr/local/bin"
install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/bin/mangoapp"
pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then
@@ -132,7 +211,6 @@ if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
exit 1
fi
export NDK="${sdk_dir}/ndk/${ndk_version}"
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
@@ -155,7 +233,7 @@ bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
cd "${repo_root}"
./gradlew assembleRelease -PndkVersion="${ndk_version}"
./gradlew assembleRelease --console=plain -PndkVersion="${ndk_version}"
cp -p "${bundle_backup}" "${bundle_asset}"
bundle_replaced=0
@@ -173,33 +251,6 @@ for audio_file in \
fi
done
build_tools=$(find "${sdk_dir}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
if [[ ! -x "${build_tools}/zipalign" || ! -x "${build_tools}/apksigner" ]]; then
echo "Android build-tools with zipalign/apksigner are required under ${sdk_dir}/build-tools." >&2
exit 1
fi
"${build_tools}/zipalign" -p -f 4 "${apk}" "${staging_dir}/app-release.aligned.apk"
"${build_tools}/apksigner" sign \
--ks keystore/testkey.p12 --ks-type PKCS12 --ks-pass pass:android \
--ks-key-alias testkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out "${apk}" "${staging_dir}/app-release.aligned.apk"
signature_output=$("${build_tools}/apksigner" verify --min-sdk-version 21 --verbose --print-certs "${apk}")
printf '%s\n' "${signature_output}"
for scheme in \
'Verified using v1 scheme (JAR signing): true' \
'Verified using v2 scheme (APK Signature Scheme v2): true' \
'Verified using v3 scheme (APK Signature Scheme v3): true'; do
grep -qF "${scheme}" <<<"${signature_output}" || {
echo "APK signature check failed: ${scheme}" >&2
exit 1
}
done
grep -qF 'Signer #1 certificate DN: [email protected], CN=Android, OU=Android, O=Android' \
<<<"${signature_output}"
docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${image_name}" \
bash -lc '
set -euo pipefail
@@ -207,7 +258,7 @@ docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${imag
work=$(mktemp -d)
unzip -q "$apk" "lib/arm64-v8a/*" -d "$work"
cd "$work/lib/arm64-v8a"
system="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so
system="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
fail=0
@@ -223,6 +274,40 @@ docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${imag
echo "every NEEDED resolves"
'
build_tools=$(find "${sdk_dir}/build-tools" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1)
if [[ ! -x "${build_tools}/zipalign" || ! -x "${build_tools}/apksigner" ]]; then
echo "Android build-tools with zipalign/apksigner are required under ${sdk_dir}/build-tools." >&2
exit 1
fi
"${build_tools}/zipalign" -p -f 4 "${apk}" "${apk}.aligned"
"${build_tools}/apksigner" sign \
--ks "${repo_root}/keystore/testkey.p12" --ks-type PKCS12 --ks-pass pass:android \
--ks-key-alias testkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
--out "${apk}" "${apk}.aligned"
rm -f "${apk}.aligned" "${apk}.idsig"
signature_output=$("${build_tools}/apksigner" verify --min-sdk-version 21 --verbose --print-certs "${apk}")
printf '%s\n' "${signature_output}"
if ! unzip -l "${apk}" | grep -qE 'META-INF/.*\.(SF|RSA|DSA)$'; then
echo "APK signature check failed: JAR signature files are missing." >&2
exit 1
fi
for scheme in \
'Verified using v1 scheme (JAR signing): true' \
'Verified using v2 scheme (APK Signature Scheme v2): true' \
'Verified using v3 scheme (APK Signature Scheme v3): true'; do
grep -qF "${scheme}" <<<"${signature_output}" || {
echo "APK signature check failed: ${scheme}" >&2
exit 1
}
done
grep -qi 'CN=Android, OU=Android, O=Android' <<<"${signature_output}" || {
echo "APK signature check failed: the signer is not the AOSP testkey." >&2
exit 1
}
printf 'APK: %s\n' "${apk}"
printf 'SHA-256: '
shasum -a 256 "${apk}" | awk '{print $1}'
+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"