mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911ad29004 | ||
|
|
8cdedbe18a | ||
|
|
0f39d71f95 | ||
|
|
1dc49b527b | ||
|
|
cc3fec0768 | ||
|
|
5270574e5a | ||
|
|
a8af973726 | ||
|
|
ed1f8dd2b6 | ||
|
|
e8aaae1343 | ||
|
|
036317f371 | ||
|
|
ce0da01163 | ||
|
|
75ae619e21 | ||
|
|
3efa5acfa6 | ||
|
|
9369dc3031 |
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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}'
|
||||
@@ -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 )
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user