mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 03:37:09 -04:00
Merge pull request #16 from The412Banner/feat/qcom-driver-and-deck-fixes
Deck mode performance overlay, killed-session cleanup, NIS
This commit is contained in:
11 files changed
+798
-56
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/mangoapp/*') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -191,6 +191,25 @@ jobs:
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
rm gamescope.tzst
|
||||
fi
|
||||
# Valve's aarch64 mangoapp for Deck mode's performance overlay, plus the five libraries the
|
||||
# runtime lacks, pinned by sha256 in tools/mangoapp/packages.txt. Stored under the SONAME
|
||||
# each is loaded by, as real files: assets cannot carry symlinks.
|
||||
M="$D/usr/local/lib/mangoapp"
|
||||
mkdir -p "$M" mango-pkgs
|
||||
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
|
||||
echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
|
||||
rm mango-pkgs/pkg.tar.zst
|
||||
done
|
||||
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
|
||||
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
|
||||
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
|
||||
done
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
# And the desktop's launcher and labwc defaults, staged over the desktop package's copies.
|
||||
install -Dm644 tools/linuxfs/desktop/steamdeck-desktop "$D/usr/local/bin/steamdeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.steamdeck.launcher.gpu.FrameGen
|
||||
import com.steamdeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.steamdeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.steamdeck.launcher.gpu.TurnipDriver
|
||||
import com.steamdeck.launcher.gpu.TurnipReleases
|
||||
import com.steamdeck.launcher.gpu.LsfgNative
|
||||
import com.steamdeck.launcher.runtime.LinuxRuntime
|
||||
import com.steamdeck.launcher.runtime.DesktopCatalog
|
||||
@@ -105,6 +106,14 @@ class MainActivity : ComponentActivity() {
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var mic by mutableStateOf(false)
|
||||
private var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
|
||||
private var linuxDownloads by mutableStateOf<List<com.steamdeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
private var androidDownloads by mutableStateOf<List<com.steamdeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
private var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
|
||||
private var releaseChecking by mutableStateOf(false)
|
||||
private var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
private val releaseProgress = HashMap<String, Int>()
|
||||
private var linuxSteam by mutableStateOf("")
|
||||
private var linuxDesktop by mutableStateOf("")
|
||||
private var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
@@ -517,6 +526,8 @@ class MainActivity : ComponentActivity() {
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus,
|
||||
releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled,
|
||||
),
|
||||
ModeSettingsActions(
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
@@ -525,10 +536,13 @@ class MainActivity : ComponentActivity() {
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
|
||||
onRemoveLinux = { id -> LinuxVulkanDriverManager(this).removeDriver(id); refreshDrivers() },
|
||||
onRemoveLinux = { id -> deleteDriver(id, linux = true) },
|
||||
onRefreshReleases = { checkLatestTurnip() },
|
||||
onDownloadDriver = { name -> downloadReleaseDriver(name) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); refreshDrivers() },
|
||||
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); refreshDrivers() },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
|
||||
onRemoveAndroid = { id -> TurnipDriver(this).remove(id); refreshDrivers() },
|
||||
onRemoveAndroid = { id -> deleteDriver(id, linux = false) },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
@@ -651,6 +665,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun refreshDrivers() {
|
||||
val lm = LinuxVulkanDriverManager(this)
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(this, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
linuxRows = LinuxVulkanDriver.optionValues(this).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else DriverRow(
|
||||
@@ -658,8 +673,8 @@ class MainActivity : ComponentActivity() {
|
||||
listOfNotNull(
|
||||
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
|
||||
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
|
||||
).joinToString(" · ").ifEmpty { "imported" },
|
||||
true,
|
||||
).joinToString(" · "),
|
||||
true, origin(id),
|
||||
)
|
||||
}
|
||||
linuxSteam = SessionPrefs.linuxDriver(this, SessionService.MODE_STEAM)
|
||||
@@ -672,10 +687,12 @@ class MainActivity : ComponentActivity() {
|
||||
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
|
||||
false,
|
||||
))
|
||||
for (id in TurnipDriver.BUNDLED) add(DriverRow(id, td.displayName(id), "bundled" + td.driverVersion(id).let { if (it.isEmpty()) "" else " · $it" }, false))
|
||||
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), "imported" + td.driverVersion(id).let { if (it.isEmpty()) "" else " · $it" }, true))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
}
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(this)
|
||||
refreshReleaseRows()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -705,6 +722,112 @@ class MainActivity : ComponentActivity() {
|
||||
}, "import-driver").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
|
||||
* session never starts on a driver that is gone; a release download is forgotten, so the menu
|
||||
* offers it again.
|
||||
*/
|
||||
private fun deleteDriver(id: String, linux: Boolean) {
|
||||
if (linux) {
|
||||
LinuxVulkanDriverManager(this).removeDriver(id)
|
||||
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
if (SessionPrefs.linuxDriver(this, mode) == id) SessionPrefs.setLinuxDriver(this, mode, "")
|
||||
}
|
||||
} else {
|
||||
val td = TurnipDriver(this)
|
||||
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
|
||||
if (SessionPrefs.androidDriver(this) == id) SessionPrefs.setAndroidDriver(this, TurnipDriver.AUTO)
|
||||
}
|
||||
TurnipReleases.forget(this, id)
|
||||
android.widget.Toast.makeText(this, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** The download entries and the refresh line, from what the last check found. */
|
||||
private fun refreshReleaseRows() {
|
||||
val check = TurnipReleases.cached(this)
|
||||
val lm = LinuxVulkanDriverManager(this)
|
||||
val td = TurnipDriver(this)
|
||||
fun rows(linux: Boolean) = check?.assets.orEmpty()
|
||||
.filter { it.linux == linux }
|
||||
.filter { a -> TurnipReleases.installedId(this, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
com.steamdeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
}
|
||||
linuxDownloads = rows(linux = true)
|
||||
androidDownloads = rows(linux = false)
|
||||
if (!releaseChecking) releaseStatus = when (check) {
|
||||
null -> "Not checked yet - tap refresh to look for new drivers"
|
||||
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
|
||||
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
|
||||
" · checked ${ago(check.checkedAt)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ago(t: Long): String {
|
||||
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
|
||||
return when {
|
||||
m < 1 -> "just now"
|
||||
m < 60 -> "$m min ago"
|
||||
m < 48 * 60 -> "${m / 60} h ago"
|
||||
else -> "${m / (24 * 60)} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
/** Only when the user taps refresh: nothing goes online on its own. */
|
||||
private fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip and WinNative…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(this); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
}
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
}
|
||||
|
||||
/** Download one release driver and import it through the same importer a picked zip uses. */
|
||||
private fun downloadReleaseDriver(assetName: String) {
|
||||
val asset = TurnipReleases.cached(this)?.assets?.firstOrNull { it.name == assetName } ?: return
|
||||
if (releaseProgress.containsKey(assetName)) return
|
||||
releaseProgress[assetName] = 0
|
||||
refreshReleaseRows()
|
||||
Thread({
|
||||
var file: java.io.File? = null
|
||||
val problem = try {
|
||||
file = TurnipReleases.download(this, asset) { pct ->
|
||||
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
|
||||
}
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = if (asset.linux) LinuxVulkanDriverManager(this).installDriver(uri, asset.name)
|
||||
else TurnipDriver(this).installFromZip(uri, asset.name)
|
||||
TurnipReleases.recordDownload(this, asset, id)
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
} finally {
|
||||
file?.let { com.steamdeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
android.widget.Toast.makeText(
|
||||
this, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "download-turnip").start()
|
||||
}
|
||||
|
||||
private fun displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
|
||||
@@ -52,6 +52,9 @@ public final class TurnipDriver {
|
||||
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX));
|
||||
/** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */
|
||||
private static final String OVERRIDE_FILE = "Download/steamdeck-driver";
|
||||
/** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */
|
||||
private static final String PREFS = "graphics_driver";
|
||||
private static final String KEY_HIDDEN = "hiddenBundled";
|
||||
/** Stored choice meaning "pick by GPU" - the default, and what a removed import falls back to. */
|
||||
public static final String AUTO = "";
|
||||
|
||||
@@ -111,6 +114,37 @@ public final class TurnipDriver {
|
||||
return library != null && !library.isEmpty() && new File(new File(contentDir, driverId), library).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled drivers the list shows: {@link #BUNDLED} minus the ones the user deleted. A deleted
|
||||
* one still lives in the apk and Auto still unpacks it on a GPU that needs it, so deleting can
|
||||
* never leave a device without a driver; it only leaves the list.
|
||||
*/
|
||||
public List<String> visibleBundled() {
|
||||
java.util.Set<String> hidden = hiddenBundled();
|
||||
ArrayList<String> out = new ArrayList<>();
|
||||
for (String id : BUNDLED) if (!hidden.contains(id)) out.add(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
public java.util.Set<String> hiddenBundled() {
|
||||
return new java.util.HashSet<>(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getStringSet(KEY_HIDDEN, java.util.Collections.<String>emptySet()));
|
||||
}
|
||||
|
||||
/** "Delete" for a bundled driver: remove its unpacked copy and hide it from the list. */
|
||||
public void hideBundled(String driverId) {
|
||||
if (!BUNDLED.contains(driverId)) return;
|
||||
FileUtils.delete(new File(contentDir, driverId));
|
||||
java.util.Set<String> hidden = hiddenBundled();
|
||||
hidden.add(driverId);
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY_HIDDEN, hidden).apply();
|
||||
Log.i(TAG, "bundled driver " + driverId + " hidden by the user");
|
||||
}
|
||||
|
||||
public void restoreBundled() {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_HIDDEN).apply();
|
||||
}
|
||||
|
||||
/** Imported drivers only - the bundled two are listed by {@link #BUNDLED}. */
|
||||
public List<String> enumerateImported() {
|
||||
ArrayList<String> ids = new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.steamdeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import com.steamdeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Driver downloads from the Turnip release repos, offered in both driver menus. Checked only when
|
||||
* the user taps refresh - the result is remembered, so the menu shows the last check without going
|
||||
* online. A download goes through the same importer as a zip picked by hand, so it gets the same
|
||||
* checks and lands as an ordinary imported driver.
|
||||
*
|
||||
* Each repo's recent releases are read newest first and every driver variant is offered from the
|
||||
* newest release that carries it: a repo that stops publishing one variant (Banners-Turnip's A8xx
|
||||
* Linux build after v26.3.0-20260923) still offers its last one, and a repo with two release lines
|
||||
* (WinNative's Android `v1.x` and Linux `linux-v0.x`) offers the newest of each.
|
||||
*/
|
||||
object TurnipReleases {
|
||||
private const val PREFS = "turnip_releases"
|
||||
private const val KEY_RELEASE = "latest"
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
|
||||
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
|
||||
/** [latest] = each source's newest release tag, for the refresh line. */
|
||||
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
|
||||
|
||||
private val SOURCES = listOf(
|
||||
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
|
||||
Source("Banners-Turnip", "The412Banner/Banners-Turnip") { name, tag ->
|
||||
val prefix = "Turnip-$tag"
|
||||
if (!name.startsWith(prefix) || !name.endsWith(".zip")) return@Source null
|
||||
var variant = name.removePrefix(prefix).removeSuffix(".zip")
|
||||
if (variant.endsWith("-Wayland")) return@Source null
|
||||
val linux = variant.endsWith("-Linux")
|
||||
variant = variant.removeSuffix("-Linux")
|
||||
linux to when {
|
||||
variant.isEmpty() -> "Adreno 6xx/7xx"
|
||||
variant == "-A8xx" -> "Adreno 8xx"
|
||||
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
|
||||
else -> variant.trimStart('-')
|
||||
}
|
||||
},
|
||||
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
|
||||
// b = Balanced, p = Performance, Axxx = every Adreno.
|
||||
Source("WinNative", "WinNative-Emu/Drivers") { name, _ ->
|
||||
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
|
||||
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
|
||||
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
|
||||
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
|
||||
},
|
||||
)
|
||||
|
||||
/** The result of the last check, or null when the user has never checked. */
|
||||
fun cached(context: Context): Check? {
|
||||
val raw = prefs(context).getString(KEY_RELEASE, null) ?: return null
|
||||
return runCatching { parse(JSONObject(raw)) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every source now and remember the result. A source that fails is named in [Check.failed]
|
||||
* and the others still count; throws only when every source failed.
|
||||
*/
|
||||
fun refresh(context: Context): Check {
|
||||
val assets = JSONArray()
|
||||
val latest = JSONArray()
|
||||
val failed = ArrayList<String>()
|
||||
var lastError: Exception? = null
|
||||
for (src in SOURCES) {
|
||||
try {
|
||||
val releases = JSONArray(get("https://api.github.com/repos/${src.repo}/releases?per_page=15"))
|
||||
val seen = HashSet<String>()
|
||||
var newest: String? = null
|
||||
for (i in 0 until releases.length()) {
|
||||
val r = releases.getJSONObject(i)
|
||||
if (r.optBoolean("draft")) continue
|
||||
val tag = r.getString("tag_name")
|
||||
if (newest == null) newest = tag
|
||||
val list = r.optJSONArray("assets") ?: continue
|
||||
for (j in 0 until list.length()) {
|
||||
val a = list.getJSONObject(j)
|
||||
val name = a.getString("name")
|
||||
val (linux, label) = src.classify(name, tag) ?: continue
|
||||
// Newest first: the first release carrying a variant is the one offered.
|
||||
if (!seen.add("$linux|$label")) continue
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
|
||||
.put("linux", linux).put("label", label))
|
||||
}
|
||||
}
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
} catch (e: Exception) {
|
||||
failed.add(src.label)
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
if (failed.size == SOURCES.size) throw lastError ?: IOException("no source answered")
|
||||
val stored = JSONObject().put("assets", assets).put("latest", latest)
|
||||
.put("failed", JSONArray(failed)).put("checkedAt", System.currentTimeMillis())
|
||||
prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply()
|
||||
return parse(stored)
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
c.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
c.setRequestProperty("User-Agent", "SteamDeck-app")
|
||||
try {
|
||||
val code = c.responseCode
|
||||
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
|
||||
if (code != 200) throw IOException("GitHub answered HTTP $code")
|
||||
return c.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(json: JSONObject): Check {
|
||||
val out = ArrayList<Asset>()
|
||||
val list = json.getJSONArray("assets")
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.getJSONObject(i)
|
||||
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
|
||||
}
|
||||
val latest = ArrayList<Pair<String, String>>()
|
||||
val l = json.optJSONArray("latest") ?: JSONArray()
|
||||
for (i in 0 until l.length()) l.getJSONObject(i).let { latest.add(it.getString("source") to it.getString("tag")) }
|
||||
val failed = ArrayList<String>()
|
||||
val f = json.optJSONArray("failed") ?: JSONArray()
|
||||
for (i in 0 until f.length()) failed.add(f.getString(i))
|
||||
return Check(out, latest, failed, json.optLong("checkedAt"))
|
||||
}
|
||||
|
||||
/** The id an asset was installed as, when it was downloaded before and is still installed. */
|
||||
fun installedId(context: Context, asset: Asset, isInstalled: (String) -> Boolean): String? =
|
||||
downloads(context).optString(asset.name, "").takeIf { it.isNotEmpty() && isInstalled(it) }
|
||||
|
||||
/** True when [id] came from a release download rather than a zip picked by hand. */
|
||||
fun isDownloaded(context: Context, id: String): Boolean {
|
||||
val d = downloads(context)
|
||||
return d.keys().asSequence().any { d.optString(it) == id }
|
||||
}
|
||||
|
||||
fun recordDownload(context: Context, asset: Asset, id: String) {
|
||||
prefs(context).edit().putString(KEY_DOWNLOADS, downloads(context).put(asset.name, id).toString()).apply()
|
||||
}
|
||||
|
||||
/** Forget a deleted driver, so its release offers the download again. */
|
||||
fun forget(context: Context, id: String) {
|
||||
val d = downloads(context)
|
||||
val keys = d.keys().asSequence().filter { d.optString(it) == id }.toList()
|
||||
if (keys.isEmpty()) return
|
||||
keys.forEach { d.remove(it) }
|
||||
prefs(context).edit().putString(KEY_DOWNLOADS, d.toString()).apply()
|
||||
}
|
||||
|
||||
/** Download an asset into the cache; the caller imports it and deletes the file. */
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 60_000
|
||||
c.setRequestProperty("User-Agent", "SteamDeck-app")
|
||||
c.instanceFollowRedirects = true
|
||||
try {
|
||||
if (c.responseCode != 200) throw IOException("download answered HTTP ${c.responseCode}")
|
||||
val total = c.contentLengthLong.takeIf { it > 0 } ?: asset.size
|
||||
c.inputStream.use { input ->
|
||||
FileOutputStream(target).use { out ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var done = 0L
|
||||
var last = -1
|
||||
while (true) {
|
||||
val r = input.read(buf)
|
||||
if (r <= 0) break
|
||||
out.write(buf, 0, r)
|
||||
done += r
|
||||
val pct = if (total > 0) (done * 100 / total).toInt() else 0
|
||||
if (pct != last) { last = pct; progress(pct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(target)
|
||||
throw e
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloads(context: Context): JSONObject =
|
||||
runCatching { JSONObject(prefs(context).getString(KEY_DOWNLOADS, "{}")!!) }.getOrDefault(JSONObject())
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -64,9 +64,20 @@ object SessionFiles {
|
||||
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
|
||||
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
|
||||
// apk carries it - a build without the asset leaves the runtime's copy alone.
|
||||
val optional = arrayOf(
|
||||
// Valve's mangoapp (tools/mangoapp) - Deck mode's performance overlay - with the five
|
||||
// libraries the runtime lacks beside it, and the wrapper on PATH that points it at them.
|
||||
val mangoapp = listOf(
|
||||
"usr/local/bin/mangoapp",
|
||||
"usr/local/lib/mangoapp/mangoapp",
|
||||
"usr/local/lib/mangoapp/libfmt.so.10",
|
||||
"usr/local/lib/mangoapp/libspdlog.so.1.13",
|
||||
"usr/local/lib/mangoapp/libglfw.so.3",
|
||||
"usr/local/lib/mangoapp/libtraceevent.so.1",
|
||||
"usr/local/lib/mangoapp/libtracefs.so.1",
|
||||
).map { it to it }
|
||||
val optional = (arrayOf(
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
).filter { (asset, _) ->
|
||||
) + mangoapp).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.steamdeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* One driver list as a full page - the Runtime driver or the Display driver - opened from the
|
||||
* session's settings page. Installed drivers are picked by tapping the row and each carries a trash
|
||||
* can (the "Auto"/"Runtime default" row is a setting, not a driver, so it has none); what the last
|
||||
* check of the release repos found is offered below; the refresh button beside the title is the
|
||||
* only thing that goes online.
|
||||
*/
|
||||
@Composable
|
||||
fun DriverPage(
|
||||
title: String,
|
||||
hint: String,
|
||||
rows: List<DriverRow>,
|
||||
selected: String,
|
||||
downloads: List<DownloadRow>,
|
||||
status: String,
|
||||
checking: Boolean,
|
||||
importLabel: String,
|
||||
canRestore: Boolean,
|
||||
onSelect: (String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onDownload: (String) -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onRestore: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var confirm by remember { mutableStateOf<DriverRow?>(null) }
|
||||
// Back (the controller's B too) closes this page only, back to the session's settings.
|
||||
BackHandler(onBack = onBack)
|
||||
// D-pad: up from the first driver lands on refresh; the page opens with the driver in use focused.
|
||||
val refreshFocus = remember { FocusRequester() }
|
||||
val selectedFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
|
||||
val refreshSrc = remember { MutableInteractionSource() }
|
||||
val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value
|
||||
SettingsPage(
|
||||
host, title = title, onBack = onBack, lede = "$hint\n$status",
|
||||
action = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp))
|
||||
.focusRequester(refreshFocus)
|
||||
.hoverable(refreshSrc)
|
||||
.clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh),
|
||||
) {
|
||||
if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
else Icon(Icons.Outlined.Refresh, contentDescription = "Check for new drivers", tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
},
|
||||
) {
|
||||
SettingsGroup("Installed") {
|
||||
val focusRow = rows.firstOrNull { it.id == selected } ?: rows.firstOrNull()
|
||||
rows.forEachIndexed { i, row ->
|
||||
InstalledRow(
|
||||
row, row.id == selected, onSelect = { onSelect(row.id) }, onDelete = { confirm = row },
|
||||
modifier = Modifier
|
||||
.then(if (row === focusRow) Modifier.focusRequester(selectedFocus) else Modifier)
|
||||
.then(if (i == 0) Modifier.focusProperties { up = refreshFocus } else Modifier),
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup("Available to download") {
|
||||
if (downloads.isEmpty()) Text(
|
||||
"Nothing new from the last check. Tap the refresh button to look for newer releases.",
|
||||
fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
) else for (d in downloads) {
|
||||
SettingsRow(d.label, d.detail) {
|
||||
val p = d.progress
|
||||
if (p == null) SecondaryButton("Download") { onDownload(d.key) }
|
||||
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) {
|
||||
Text("$p%", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
|
||||
SecondaryButton(importLabel, onClick = onImport)
|
||||
if (canRestore) FocusText("Restore built-in drivers", pal.signal, onClick = onRestore)
|
||||
}
|
||||
}
|
||||
confirm?.let { row ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirm = null },
|
||||
title = { Text("Delete ${row.name}?") },
|
||||
text = {
|
||||
Text(
|
||||
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
|
||||
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
},
|
||||
// Opens on Cancel, so a stray A press on a controller never deletes anything.
|
||||
confirmButton = { FocusText("Delete", colors.error) { confirm = null; onDelete(row.id) } },
|
||||
dismissButton = {
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
|
||||
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
// Two focus targets side by side - the driver itself, and its trash can - so a controller's
|
||||
// d-pad right reaches the trash can and left comes back. (Inside one clickable row the trash
|
||||
// can was a child of the focused area and "right" had nowhere to go.) The row's outline still
|
||||
// follows the driver part, so it looks the same.
|
||||
val mainFocus = remember { FocusRequester() }
|
||||
val delFocus = remember { FocusRequester() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f).then(modifier).focusRequester(mainFocus)
|
||||
.then(if (row.removable) Modifier.focusProperties { right = delFocus } else Modifier)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
|
||||
.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape),
|
||||
) { if (selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) }
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
row.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (row.tag.isNotEmpty()) Text(
|
||||
row.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
if (row.removable) {
|
||||
val delSrc = remember { MutableInteractionSource() }
|
||||
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp))
|
||||
.focusRequester(delFocus)
|
||||
.focusProperties { left = mainFocus }
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete),
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${row.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) }
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
/** A text button that shows where controller focus is: an outline in its own colour. */
|
||||
@Composable
|
||||
private fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Text(
|
||||
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color,
|
||||
modifier = modifier.clip(shape)
|
||||
.background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent)
|
||||
.border(2.dp, if (hot) color else Color.Transparent, shape)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -11,13 +11,24 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.steamdeck.launcher.core.FexPreset
|
||||
import com.steamdeck.launcher.session.SessionPrefs
|
||||
import com.steamdeck.launcher.session.SessionService
|
||||
|
||||
class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean)
|
||||
/** [tag] is shown beside the name: [BUNDLED], [DOWNLOADED], [IMPORTED], or "" for Auto / Runtime default. */
|
||||
class DriverRow(val id: String, val name: String, val detail: String, val removable: Boolean, val tag: String = "") {
|
||||
companion object {
|
||||
const val BUNDLED = "BUNDLED"
|
||||
const val DOWNLOADED = "DOWNLOADED"
|
||||
const val IMPORTED = "IMPORTED"
|
||||
}
|
||||
}
|
||||
|
||||
/** A release driver (Banners-Turnip, WinNative) that is not installed yet; [key] is its asset name. */
|
||||
class DownloadRow(val key: String, val label: String, val detail: String, val progress: Int? = null)
|
||||
|
||||
class ModeSettings(
|
||||
val mode: String,
|
||||
@@ -49,6 +60,13 @@ class ModeSettings(
|
||||
val addedGamesDirs: List<String>? = null,
|
||||
val addedGames: List<AddedGameRow> = emptyList(),
|
||||
val addedGamesArt: Boolean = true,
|
||||
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
|
||||
val linuxDownloads: List<DownloadRow> = emptyList(),
|
||||
val androidDownloads: List<DownloadRow> = emptyList(),
|
||||
val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers",
|
||||
val releaseChecking: Boolean = false,
|
||||
/** A bundled display driver was deleted: the page offers to restore it. */
|
||||
val canRestoreBundled: Boolean = false,
|
||||
)
|
||||
|
||||
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
|
||||
@@ -63,6 +81,10 @@ class ModeSettingsActions(
|
||||
val onSelectLinux: (String) -> Unit,
|
||||
val onImportLinux: () -> Unit,
|
||||
val onRemoveLinux: (String) -> Unit,
|
||||
val onRefreshReleases: () -> Unit = {},
|
||||
/** Asset name of the release driver to download. */
|
||||
val onDownloadDriver: (String) -> Unit = {},
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
val onSelectAndroid: (String) -> Unit,
|
||||
val onImportAndroid: () -> Unit,
|
||||
val onRemoveAndroid: (String) -> Unit,
|
||||
@@ -89,10 +111,57 @@ class ModeSettingsActions(
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
|
||||
// Coming back restores this page as it was left: the same scroll position, and controller focus
|
||||
// on the driver box that opened the page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
var returnTo by remember { mutableStateOf<String?>(null) }
|
||||
val pageScroll = androidx.compose.foundation.rememberScrollState()
|
||||
val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val displayChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
fun openDriverPage(key: String) { returnTo = key; driverPage = key }
|
||||
androidx.compose.runtime.LaunchedEffect(driverPage) {
|
||||
if (driverPage == null) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once; back
|
||||
// from a driver page, it returns to the driver box that opened it.
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip }
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
}
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver",
|
||||
hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
|
||||
rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads,
|
||||
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases,
|
||||
onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver",
|
||||
hint = "Used by the compositor in both modes. Restart the app to apply.",
|
||||
rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads,
|
||||
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…",
|
||||
canRestore = s.canRestoreBundled,
|
||||
onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases,
|
||||
onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled,
|
||||
onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
title = if (steam) "Steam session" else "Desktop session",
|
||||
onBack = a.onDismiss,
|
||||
scroll = pageScroll,
|
||||
) {
|
||||
SettingsGroup("Display") {
|
||||
val default = SessionPrefs.defaultResolutionCap(s.mode)
|
||||
@@ -104,6 +173,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) } +
|
||||
(CUSTOM to (custom?.let { "Custom · ${it.first}×${it.second}" } ?: "Custom…")),
|
||||
if (custom != null) CUSTOM else s.resolutionCap, note = "720p can improve menu responsiveness.",
|
||||
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
|
||||
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
|
||||
)
|
||||
ChoiceRow(
|
||||
@@ -126,18 +196,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
SettingsGroup("Drivers") {
|
||||
DriverRowMenu(
|
||||
host, "rt", "Runtime driver",
|
||||
(if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
|
||||
s.linuxRows, s.linuxSelected, importLabel = "Import Turnip zip…",
|
||||
onSelect = a.onSelectLinux, onRemove = a.onRemoveLinux, onImport = a.onImportLinux,
|
||||
)
|
||||
DriverRowMenu(
|
||||
host, "panel", "Display driver",
|
||||
"Used by the compositor in both modes. Restart the app to apply.",
|
||||
s.androidRows, s.androidSelected, importLabel = "Import an AdrenoTools zip…",
|
||||
onSelect = a.onSelectAndroid, onRemove = a.onRemoveAndroid, onImport = a.onImportAndroid,
|
||||
)
|
||||
SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") {
|
||||
ValueChip(
|
||||
s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false,
|
||||
modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip),
|
||||
) { openDriverPage("rt") }
|
||||
}
|
||||
SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") {
|
||||
ValueChip(
|
||||
s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false,
|
||||
modifier = androidx.compose.ui.Modifier.focusRequester(displayChip),
|
||||
) { openDriverPage("panel") }
|
||||
}
|
||||
}
|
||||
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
|
||||
ChoiceRow(
|
||||
@@ -259,32 +329,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriverRowMenu(
|
||||
host: MenuHost, key: String, label: String, hint: String, rows: List<DriverRow>, selected: String, importLabel: String,
|
||||
onSelect: (String) -> Unit, onRemove: (String) -> Unit, onImport: () -> Unit,
|
||||
) {
|
||||
val open = host.open == key
|
||||
val colors = MaterialTheme.colorScheme
|
||||
SettingsRow(label, hint, highlighted = open) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
ValueChip(rows.firstOrNull { it.id == selected }?.name ?: rows.firstOrNull()?.name ?: "-", open) { host.open = if (open) null else key }
|
||||
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label) {
|
||||
for (row in rows) MenuItem(
|
||||
row.name, checked = row.id == selected, detail = row.detail.ifEmpty { null },
|
||||
trailing = if (row.removable) ({
|
||||
Text(
|
||||
"✕", fontSize = 12.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).padding(4.dp).clickable { onRemove(row.id); host.open = null },
|
||||
)
|
||||
}) else null,
|
||||
) { onSelect(row.id); host.open = null }
|
||||
MenuItem(importLabel, checked = false) { host.open = null; onImport() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Resolution menu's "Custom…" entry. */
|
||||
private const val CUSTOM = -1
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -102,9 +103,13 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
|
||||
exit = fadeOut(Motion.tw(140)) + scaleOut(Motion.tw(140), targetScale = 0.96f, transformOrigin = TransformOrigin(1f, 0f)),
|
||||
label = "menu",
|
||||
) {
|
||||
// A long list (a driver menu with its downloads) must not run off the screen: the menu is
|
||||
// capped at most of the window's height and its entries scroll under the fixed title.
|
||||
val maxHeight = (androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp * 0.86f).dp
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 220.dp, max = 340.dp)
|
||||
.heightIn(max = maxHeight)
|
||||
.shadow(24.dp, RowShape, ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(RowShape)
|
||||
.background(pal.surfaceVariant.copy(alpha = 0.95f))
|
||||
@@ -115,7 +120,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
|
||||
title.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 6.dp),
|
||||
)
|
||||
content()
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { content() }
|
||||
if (note != null) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
@@ -160,7 +165,7 @@ fun MenuItem(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
@@ -169,7 +174,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, onClick: ()
|
||||
val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(min = 150.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(colors.surfaceVariant)
|
||||
@@ -221,12 +226,14 @@ fun SettingsRow(label: String, hint: String?, highlighted: Boolean = false, cont
|
||||
fun <T> ChoiceRow(
|
||||
host: MenuHost, key: String, label: String, hint: String?,
|
||||
options: List<Pair<T, String>>, selected: T, enabled: Boolean = true, note: String? = null,
|
||||
/** For the box itself - a page's FocusRequester for its first control. */
|
||||
chipModifier: Modifier = Modifier,
|
||||
onPick: (T) -> Unit,
|
||||
) {
|
||||
val open = host.open == key
|
||||
SettingsRow(label, hint, highlighted = open) {
|
||||
Box {
|
||||
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled) { host.open = if (open) null else key }
|
||||
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier) { host.open = if (open) null else key }
|
||||
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) {
|
||||
for ((value, text) in options) MenuItem(text, checked = value == selected) { onPick(value); host.open = null }
|
||||
}
|
||||
@@ -275,16 +282,28 @@ fun SettingsPage(
|
||||
onBack: () -> Unit,
|
||||
eyebrow: String? = null,
|
||||
lede: String? = null,
|
||||
/** A small control at the right of the title (the driver pages' refresh button). */
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
/** Pass one held above the page to keep its scroll position across a page opened over it. */
|
||||
scroll: androidx.compose.foundation.ScrollState? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollState = scroll ?: rememberScrollState()
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) {
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Outlined when a controller's focus is on it, like every other control here.
|
||||
val backSrc = remember { MutableInteractionSource() }
|
||||
val backHot = backSrc.collectIsFocusedAsState().value || backSrc.collectIsHoveredAsState().value
|
||||
Text(
|
||||
"‹ Back", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack).padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
"‹ Back", fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (backHot) LocalPalette.current.signal else colors.onSurfaceVariant,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||
.border(2.dp, if (backHot) LocalPalette.current.signal else Color.Transparent, RoundedCornerShape(8.dp))
|
||||
.hoverable(backSrc).clickable(interactionSource = backSrc, indication = null, onClick = onBack)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
)
|
||||
if (eyebrow != null) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
@@ -292,10 +311,15 @@ fun SettingsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
Rise(1) { Title(title) }
|
||||
Rise(1) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) { Title(title) }
|
||||
if (action != null) action()
|
||||
}
|
||||
}
|
||||
if (lede != null) Rise(2) { Lede(lede) }
|
||||
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = dim }.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { content() }
|
||||
Column(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = dim }.verticalScroll(scrollState).padding(bottom = 24.dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,19 @@ if [ -z "${BL_INSIDE:-}" ]; then
|
||||
# HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
|
||||
# gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
|
||||
[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
|
||||
# Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by
|
||||
# mangoapp, which gamescope starts beside the client; the client sets the level by rewriting
|
||||
# MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
|
||||
# still offers the levels and nothing appears. Starts hidden until the client writes a level.
|
||||
# BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip).
|
||||
if [ "$mode" = steam ] && [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
|
||||
&& [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then
|
||||
MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
|
||||
echo no_display > "$MANGOHUD_CONFIGFILE"
|
||||
export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1
|
||||
args+=(--mangoapp)
|
||||
echo "== gamescope: mangoapp on ($(command -v mangoapp), config $MANGOHUD_CONFIGFILE)"
|
||||
fi
|
||||
export BL_INSIDE=1
|
||||
exec gamescope "${args[@]}" -- "$0" "$mode" "$@"
|
||||
fi
|
||||
@@ -349,8 +362,24 @@ BUSCONF
|
||||
# the client calls in this mode are staged as no-op stubs under /usr/bin.
|
||||
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ]; then
|
||||
_bl_ui+=(-steamdeck)
|
||||
# The QAM's Scaling Filter lists NVIDIA Image Scaling only when told gamescope has it (it does).
|
||||
export STEAM_GAMESCOPE_NIS_SUPPORTED=1
|
||||
echo "== steam: Deck mode on (-steamdeck)"
|
||||
fi
|
||||
# What a killed session leaves behind. Android ends sessions without warning (backgrounding, the
|
||||
# low-memory killer), and the client then finds .crash - opening its update UI on the spinner -
|
||||
# and the web helper's Singleton* locks in htmlcache, owned by a process that is gone, which can
|
||||
# leave the Gamepad UI blank. No client or helper is running at this point in a session.
|
||||
if ! pgrep -f 'steamwebhelper|steamrtarm64/steam' >/dev/null 2>&1; then
|
||||
_stale=""
|
||||
for _f in "$steam_root/.crash" "$steam_root/steam.pid" \
|
||||
"$steam_root/config/htmlcache/SingletonLock" "$steam_root/config/htmlcache/SingletonCookie" \
|
||||
"$steam_root/config/htmlcache/SingletonSocket"; do
|
||||
if [ -e "$_f" ] || [ -L "$_f" ]; then rm -f "$_f" && _stale="$_stale ${_f##*/}"; fi
|
||||
done
|
||||
[ -n "$_stale" ] && echo "== steam: cleared what an earlier session left:$_stale"
|
||||
unset _stale _f
|
||||
fi
|
||||
# The client branch. publicbeta is what every session has run on; steamdeck_publicbeta is the
|
||||
# channel Armada bootstraps its ARM64 client from. Whatever wrote package/beta, the branch on
|
||||
# disk has to agree with the one forced here - a disagreement is the restart loop above, and
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# gamescope --mangoapp runs `mangoapp` from PATH; this puts Valve's build beside the libraries the
|
||||
# runtime lacks (fmt 10, spdlog 1.13, glfw, libtracefs, libtraceevent), staged by the app.
|
||||
LD_LIBRARY_PATH=/usr/local/lib/mangoapp${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} exec /usr/local/lib/mangoapp/mangoapp "$@"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Valve's aarch64 mangoapp (the Deck performance overlay) and the five libraries it needs that the
|
||||
# runtime does not have, at the versions it was built against (its .BUILDINFO: fmt 10.2.0,
|
||||
# spdlog 1.13.0). CI fetches each, checks the sha256 and stages the files named below into the apk
|
||||
# under usr/local/lib/mangoapp; SessionFiles copies them into the runtime at every launch.
|
||||
# sha256 url
|
||||
5b203a711e4e48f01fdffaa4f5a330f8fef22f1eab865f067a3e60bd99a28a5c https://holo-packages.steamos.cloud/archlinux-deckard-hotfixes/release/0.4.x/mangohud-20260902.1-1-aarch64.pkg.tar.zst
|
||||
ef7411229ea4e9b3ab4e36ac356a85b5072f362ee1de68b28d09c43f0b0ecc44 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/fmt-10.2.0-1-aarch64.pkg.tar.zst
|
||||
c9bfd2c3f15071329d381b9c2061d07503c4787ceb7001b9dfbf72a23c85cb04 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/spdlog-1.13.0-1-aarch64.pkg.tar.zst
|
||||
f9fae6c84a0382188f1cb20761424051593fd09b1456191cc250bd16e0a1d7a5 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/glfw-3.4-2-aarch64.pkg.tar.zst
|
||||
0cccdc9c92cb3cae1e37ebeb0ed20b93bb3b5000c8f955b63d0e07bd878c7018 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtraceevent-1%3A1.8.2-2-aarch64.pkg.tar.zst
|
||||
47ffd6a953959d6a0df44539bef30a67d0b8c95cce0053273908d9d70ef7abc4 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtracefs-1.8.0-1-aarch64.pkg.tar.zst
|
||||
Reference in new issue
Block a user