Compare commits

...
Author SHA1 Message Date
The412Banner 2d5358fa85 TEST ONLY: components-manager with the launch-time check removed from the Proton wrappers (crash bisect) 2026-09-26 00:20:43 -04:00
The412Banner d7ff92106a Remove a stray __pycache__ from the overlay scripts and ignore it 2026-09-26 00:20:41 -04:00
The412Banner 110aec0b6f Proton wrappers: drop the per-tool prefix for Steam's tool-0 runs - the client segfaulted right after its probe started (first in 25 sessions); keep the launch-time component check 2026-09-26 00:03:45 -04:00
The412Banner 06502bb49b Components: choices enforced at every game launch; Steam's tool-0 probes get their own prefix per Proton
Steam runs its graphics probe through every registered Proton at once after login, all in
compatdata/0. Prefix system32 links point into the Proton that set it up last, and the other
Protons' Wines, running outside Proton's pfx.lock, wrote their builtin DLLs through them into that
Proton's tree (device: GE's FEX DLLs became Proton Experimental's at session start).

- bannerlator-steam-compat: both wrappers (Valve's depot launcher and the third-party
  bannerlator-proton-wrap) give a tool-0 run STEAM_COMPAT_DATA_PATH=.../compatdata/0-<tool>, and
  before every other launch check the Proton's files against the app's desired.tsv and put back
  anything that differs (same Proton build only), logging to reapplied.log.
- ComponentsManager writes desired.tsv plus unpacked copies of the packages in use into the
  runtime after every change; the page and the drawer show 're-applied at launch'.
2026-09-25 23:54:10 -04:00
The412Banner c982d65675 Session drawer: tab spacing fits the room between LB and RB, so all four tabs show 2026-09-25 22:59:50 -04:00
The412Banner f4e606fb15 Components: the pad's LB / RB step through FEX, DXVK and VKD3D-Proton (wrapping), with LB / RB badges beside the tabs 2026-09-25 22:49:10 -04:00
The412Banner 9dbdb5e771 Components: fixed columns so details and Download buttons line up; focused button is a solid blue fill with a white outline 2026-09-25 22:37:07 -04:00
The412Banner fa12478fdd Components: compact layout - one toolbar line (title, Proton drop-down, component tabs, in use, (i), refresh) and one list of short rows for installed and available packages 2026-09-25 22:27:25 -04:00
The412Banner cbb9a168e6 Components: FEX, DXVK and VKD3D-Proton per Proton
A Components entry under Desktop opens a page built like the driver pages: a
Proton drop-down, FEX / DXVK / VKD3D-Proton tabs, what is in use, the installed
rows (each Proton build's original bundle and the stored packages; tap to
swap in, trash to delete), downloads from the Nightlies -Linux releases behind
the refresh button, and Import .wcp.

The session drawer gets a fourth tab, Components, for quick swaps between
what is installed. A swap into the Proton a running game uses waits until the
game closes and is applied then (drawer refresh, or the main screen on resume).

ComponentsManager keeps the same package format, originals layout and state as
the Decky Components Manager plugin and takes its data over once. A swap clears
only the component's own DLLs, so files a Proton keeps beside them stay.
2026-09-25 22:04:38 -04:00
The412Banner 73e2fc58a0 Merge pull request #24 from Droid-Deck/feat/drawer-tab-icons
Session drawer: QAM-style page tabs (Display, Controls, Settings)
2026-09-25 21:55:16 -04:00
8 changed files with 1401 additions and 18 deletions

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
!keystore/testkey.p12
droiddeck-release-key/
password.txt
__pycache__/
@@ -12,6 +12,7 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -34,6 +35,8 @@ import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.ProtonPage
@@ -89,6 +92,16 @@ class MainActivity : ComponentActivity() {
private var steamDeckMode by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
private var compCatalogAt by mutableStateOf(0L)
private var compProton by mutableStateOf<String?>(null)
private var compComp by mutableStateOf("dxvk")
private var compChecking by mutableStateOf(false)
private var compBusy by mutableStateOf<String?>(null)
private var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
private var deckyInstalled by mutableStateOf<String?>(null)
private var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
private var deckyChecking by mutableStateOf(false)
@@ -140,6 +153,9 @@ class MainActivity : ComponentActivity() {
// The app's own picker (files/), once per kind of pick: the two driver lists validate
// differently, and the reason a zip is refused names the list it belongs in.
private val pickComponent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) }
}
private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = true) }
}
@@ -247,6 +263,7 @@ class MainActivity : ComponentActivity() {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
showMapping -> { { MappingHost() } }
else -> null
}
@@ -260,7 +277,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showMapping) "controller-mapping" else null,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -315,7 +332,8 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
},
onProtons = { openProtons() },
onPerformance = { refreshCores(); showProtons = false; showMapping = false; showPerformance = true },
onComponents = { openComponents() },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onLogs = {
@@ -336,7 +354,7 @@ class MainActivity : ComponentActivity() {
offline = OfflineMode.enabled(this)
},
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = false },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
@@ -360,7 +378,7 @@ class MainActivity : ComponentActivity() {
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = true },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
@@ -417,8 +435,30 @@ class MainActivity : ComponentActivity() {
}, "finish-abandoned").start()
}
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
compComp = all[(all.indexOf(compComp).coerceAtLeast(0) + step) % all.size]
}
return true
}
return super.dispatchKeyEvent(event)
}
override fun onResume() {
super.onResume()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (showComponents) refreshComponents()
}
}, "components-queue").start()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
@@ -492,6 +532,7 @@ class MainActivity : ComponentActivity() {
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
showProtons = true
refreshProtons()
@@ -543,6 +584,106 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents() {
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
refreshComponents(snapshotFirst = true)
}
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
private fun refreshComponents(snapshotFirst: Boolean = false) {
Thread({
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(this) }
runCatching { ComponentsManager.applyQueued(this) }
val snap = runCatching { ComponentsManager.snapshot(this) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(this, false) }.getOrNull()
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
}
}, "components").start()
}
private fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-action").start()
}
private fun refreshComponentCatalog() {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(this, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(this, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
}
private fun downloadComponent(item: ComponentsManager.CatalogItem) {
if (compDownloads.containsKey(item.file)) return
compDownloads = compDownloads + (item.file to -1)
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(this, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
}, "components-download").start()
}
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
componentAction("Importing") {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
}
}
@Composable
private fun ComponentsHost() {
ComponentsPage(
snapshot = compSnapshot,
catalog = compCatalog,
catalogAt = compCatalogAt,
protonId = compProton,
comp = compComp,
checking = compChecking,
busy = compBusy,
downloads = compDownloads,
onProton = { compProton = it },
onComp = { compComp = it },
onSwap = { file -> compProton?.let { pid -> componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> compProton?.let { pid -> componentAction("Restoring") { ComponentsManager.restore(this, pid, compComp, version) } } },
onCancelQueued = { compProton?.let { pid -> componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> compProton?.let { pid -> componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, compComp, version) } } },
onDownload = { downloadComponent(it) },
onRefresh = { refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
)
}
@Composable
private fun ProtonHost() {
ProtonPage(
@@ -781,6 +922,7 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
@@ -1108,5 +1250,6 @@ class MainActivity : ComponentActivity() {
private const val TAG = "MainActivity"
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
}
}
@@ -69,6 +69,8 @@ import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import kotlin.math.abs
/**
@@ -140,6 +142,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
@@ -342,6 +346,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
))
if (SessionState.suspended) SessionPausedOverlay {
SessionService.resume(this@SessionActivity)
@@ -424,6 +431,40 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
}
/** Re-reads the Protons for the drawer's Components tab, first running swaps a closed game was holding back. */
private fun refreshDrawerComponents() {
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
uiHandler.post {
drawerComponents = snap
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
}
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
Thread({
val message = runCatching {
val snap = ComponentsManager.snapshot(this)
val queued = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.queued
val active = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.activeFile
val build = snap.protons.firstOrNull { it.proton.id == protonId }?.proton?.version?.let(ComponentsManager::safeName)
val current = active ?: "orig:$build"
if (queued != null && value == current) {
// Picking what is already in place again cancels the swap waiting for the game.
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
else ComponentsManager.swap(this, protonId, value)
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
}
}, "drawer-components-swap").start()
}
private fun shareCurrentSessionLogs() {
val folder = SessionPaths.current()
if (folder == null || !folder.isDirectory) {
@@ -854,7 +895,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else 2) % 3
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
releaseDrawerDirection()
}
return true
@@ -0,0 +1,654 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client.
*
* Every Proton the client runs games with keeps its own copy of these three inside its tree. This
* lists them, stores packages (.wcp: tar + zstd with a profile.json) downloaded from the Nightlies
* "-Linux" releases or imported, swaps one into a Proton, keeps the Proton's shipped files as
* "original bundles" - one per Proton build, so a Steam update adds a new one instead of replacing
* the last - restores those, and deletes stored packages.
*
* Where a Proton reads each component (its own `proton` script, ARM64 host):
* DXVK files/lib/wine/dxvk/{aarch64,i386}-windows -> the prefix's system32 / syswow64
* VKD3D files/lib/wine/vkd3d-proton/{aarch64,i386}-windows -> the same, on every launch
* FEX files/lib/wine/aarch64-windows/lib{arm64ec,wow64}fex.dll
* files/lib/wine/aarch64-unix/lib{arm64ec,wow64}fex.so (FEX 2607 and newer)
* Proton copies DXVK and VKD3D into the prefix on every normal launch, so a swap applies the next
* time a game starts. A swap into a Proton a running game is using waits until that game exits.
*
* Same formats and data layout as the Decky "Components Manager" plugin, whose data is taken over
* once on first use.
*/
object ComponentsManager {
private const val TAG = "Components"
private const val NIGHTLIES = "The412Banner/Nightlies"
/** Release tag -> component. Only the Linux repacks: the Android releases carry bionic unixlibs. */
private val RELEASES = linkedMapOf(
"FexCore-Linux" to "fex",
"Dxvk-Linux" to "dxvk",
"Dxvk-arm64ec-Linux" to "dxvk",
"Dxvk-gplasync-Linux" to "dxvk",
"Vkd3d-proton-Linux" to "vkd3d",
)
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
val LABEL = mapOf("fex" to "FEX", "dxvk" to "DXVK", "vkd3d" to "VKD3D-Proton")
private const val WINE = "files/lib/wine"
private val FEX_FILES = listOf(
"$WINE/aarch64-windows/libarm64ecfex.dll",
"$WINE/aarch64-windows/libwow64fex.dll",
"$WINE/aarch64-unix/libarm64ecfex.so",
"$WINE/aarch64-unix/libwow64fex.so",
)
private val COMP_DIR = mapOf("dxvk" to "$WINE/dxvk", "vkd3d" to "$WINE/vkd3d-proton")
/** The folders a Proton reads on ARM64. */
private val PE_ARCHES = listOf("aarch64-windows", "i386-windows")
/**
* The DLLs that ARE the component. A swap clears exactly these before unpacking, so no file of
* the previous version is left mixed in; anything else a Proton keeps beside them - GE's
* openvr_api_dxvk.dll, which its launcher copies into every prefix and aborts without - stays.
*/
private val COMP_DLLS = mapOf(
"dxvk" to setOf("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"),
"vkd3d" to setOf("d3d12.dll", "d3d12core.dll"),
)
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
data class ProtonView(
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
val reappliedAt: Long = 0,
)
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
private val lock = Any()
// ------------------------------------------------------------------ paths
private fun root(context: Context) = LinuxRuntime.rootDir(context)
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() }
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
/**
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
*/
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
// ------------------------------------------------------------------ state
private fun loadState(context: Context): JSONObject =
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
if (!has("active")) put("active", JSONObject())
if (!has("queued")) put("queued", JSONObject())
}
private fun saveState(context: Context, state: JSONObject) {
val f = stateFile(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(state.toString(2))
tmp.renameTo(f)
// Every change of choice reaches the launch wrappers at once.
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
/** Takes over the Decky plugin's packages, originals and swap records, once. */
private fun migratePlugin(context: Context) {
val marker = File(dataDir(context), ".plugin-imported")
if (marker.isFile) return
val plugin = File(root(context), PLUGIN_DATA)
if (plugin.isDirectory) {
runCatching {
File(plugin, "packages").listFiles()?.forEach { f ->
if (f.isFile && f.name.endsWith(".wcp")) {
val dst = File(packagesDir(context), f.name)
if (!dst.exists()) f.copyTo(dst)
}
}
val originals = File(plugin, "originals")
if (originals.isDirectory) originals.copyRecursively(originalsDir(context), overwrite = false)
val ps = File(plugin, "state.json")
if (ps.isFile && !stateFile(context).isFile) ps.copyTo(stateFile(context))
Log.i(TAG, "took over the Decky plugin's data from $plugin")
}.onFailure { Log.w(TAG, "plugin data import", it) }
}
marker.writeText("1\n")
}
// ------------------------------------------------------------------ Protons
private fun libraryRoots(context: Context): List<File> {
val roots = mutableListOf(steam(context))
val vdf = File(steam(context), "steamapps/libraryfolders.vdf")
runCatching {
Regex("\"path\"\\s+\"([^\"]+)\"").findAll(vdf.readText()).forEach {
val h = host(context, it.groupValues[1])
if (roots.none { r -> r.canonicalPath == h.canonicalPath }) roots.add(h)
}
}
return roots
}
private fun displayName(dir: File): String = runCatching {
Regex("\"display_name\"\\s+\"([^\"]+)\"").find(File(dir, "compatibilitytool.vdf").readText())?.groupValues?.get(1)
}.getOrNull() ?: dir.name
private fun protonVersion(dir: File): String = runCatching {
val parts = File(dir, "version").useLines { it.firstOrNull() ?: "" }.trim().split(Regex("\\s+"))
if (parts.size > 1) parts[1] else parts.firstOrNull()?.ifEmpty { null } ?: "unknown"
}.getOrDefault("unknown")
/** Every Proton tree with a Wine inside. Launch wrappers pointing at another Proton are left out. */
fun protons(context: Context): List<Proton> {
val found = LinkedHashMap<String, Proton>()
val bases = listOf(File(steam(context), "compatibilitytools.d") to false) +
libraryRoots(context).map { File(it, "steamapps/common") to true }
// Not canonical: /data/user/0 is a link to /data/data on many devices, and the guest path is
// what is left after removing the rootfs prefix exactly as the paths were built.
val rootPath = root(context).absolutePath.trimEnd('/')
for ((base, valve) in bases) {
val entries = base.listFiles()?.sortedBy { it.name } ?: continue
for (dir in entries) {
if (!File(dir, WINE).isDirectory || !File(dir, "proton").isFile) continue
val real = runCatching { dir.canonicalPath }.getOrDefault(dir.path)
if (found.containsKey(real)) continue
val guest = if (dir.absolutePath.startsWith(rootPath)) dir.absolutePath.removePrefix(rootPath) else dir.absolutePath
found[real] = Proton(safeName(dir.name), displayName(dir), dir, guest, protonVersion(dir), valve)
}
}
return found.values.toList()
}
private fun proton(context: Context, id: String) = protons(context).firstOrNull { it.id == id }
?: throw IllegalStateException("No Proton with id $id")
/** Is a game (or anything) running on this Proton right now? Read from the processes' command lines. */
fun inUse(proton: Proton): Boolean {
val needle = proton.guestPath.trimEnd('/') + "/"
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (text.contains(needle + "proton ") || text.contains(needle + "files/bin")) return true
}
return false
}
// ------------------------------------------------------------------ component files
private fun readVersionFile(f: File): String = runCatching {
val text = f.readText().trim()
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
}.getOrDefault("")
private fun fexVersion(dir: File): String {
val dll = File(dir, FEX_FILES[0])
if (!dll.isFile) return ""
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
?: "FEX (build without a version name)"
}
private fun compFiles(dir: File, comp: String): List<String> {
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
val base = File(dir, COMP_DIR.getValue(comp))
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
}
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
private fun sha256(f: File): String {
val md = MessageDigest.getInstance("SHA-256")
FileInputStream(f).use { input ->
val buf = ByteArray(1 shl 16)
while (true) { val n = input.read(buf); if (n < 0) break; md.update(buf, 0, n) }
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun fingerprint(dir: File, comp: String): Map<String, String> =
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { sha256(File(dir, it)) }
// ------------------------------------------------------------------ packages
private fun normalize(name: String) = name.removePrefix("./").trimStart('/')
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
}
throw IllegalArgumentException("no profile.json in ${f.name}")
}
private fun packageInfo(f: File): Package {
val prof = readProfile(f)
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
private fun installFiles(context: Context, dir: File, comp: String, wcp: File): Map<String, String> {
val staged = File(dataDir(context), "staging-${System.nanoTime()}")
try {
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/")) continue
require(rel.split('/').none { it == ".." }) { "unsafe path $rel" }
val out = File(staged, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
COMP_DIR[comp]?.let { compDir ->
for (arch in PE_ARCHES) {
val target = File(dir, "$compDir/$arch")
if (!File(staged, "$compDir/$arch").isDirectory || !target.isDirectory) continue
target.listFiles()?.filter { it.name.lowercase() in COMP_DLLS.getValue(comp) }?.forEach { it.delete() }
}
}
val hashes = LinkedHashMap<String, String>()
File(staged, "files").walkTopDown().filter { it.isFile }.forEach { src ->
val rel = src.relativeTo(staged).path
val dst = File(dir, rel)
dst.parentFile?.mkdirs()
val tmp = File(dst.parentFile, dst.name + ".dcm-new")
src.copyTo(tmp, overwrite = true)
if (rel.endsWith(".so")) tmp.setExecutable(true, false)
tmp.setReadable(true, false)
if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } }
hashes[rel] = sha256(dst)
}
return hashes
} finally {
staged.deleteRecursively()
}
}
private fun writeWcp(dest: File, profile: JSONObject, dir: File, rels: List<String>) {
val tmp = File(dest.parentFile, dest.name + ".part")
TarArchiveOutputStream(ZstdOutputStream(BufferedOutputStream(FileOutputStream(tmp), 1 shl 16), 9)).use { tar ->
tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX)
val bytes = profile.toString(2).toByteArray()
tar.putArchiveEntry(TarArchiveEntry("./profile.json").apply { size = bytes.size.toLong() })
tar.write(bytes); tar.closeArchiveEntry()
for (rel in rels) {
val f = File(dir, rel)
tar.putArchiveEntry(TarArchiveEntry(f, "./$rel"))
FileInputStream(f).use { it.copyTo(tar) }
tar.closeArchiveEntry()
}
}
tmp.renameTo(dest)
}
// ------------------------------------------------------------------ originals
private fun originalFile(context: Context, p: Proton, comp: String) =
File(originalsDir(context), "${p.id}/${safeName(p.version)}/$comp.wcp").apply { parentFile?.mkdirs() }
fun originals(context: Context, protonId: String): List<Original> {
val base = File(originalsDir(context), protonId)
val versions = base.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.lastModified() } ?: return emptyList()
return versions.flatMap { v ->
COMPONENTS.mapNotNull { comp ->
val f = File(v, "$comp.wcp")
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
}
}
}
/**
* Saves a Proton's shipped files for this component before they are first replaced in this
* Proton build. A build already swapped into is not saved again: its files are ours.
*/
private fun ensureOriginal(context: Context, p: Proton, comp: String, state: JSONObject) {
val dest = originalFile(context, p, comp)
if (dest.isFile) return
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
if (active != null && active.optString("protonVersion") == p.version) return
val rels = compFiles(p.dir, comp)
if (rels.isEmpty()) return
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val profile = JSONObject()
.put("type", TYPE_OF.getValue(comp))
.put("versionName", "original-${p.version}")
.put("versionCode", 0)
.put("description", "Original · ${p.name} · ${p.version} · ${LABEL.getValue(comp)} $current".trim())
.put("files", JSONArray(rels.map { JSONObject().put("source", it).put("target", "\${proton}/$it") }))
writeWcp(dest, profile, p.dir, rels)
Log.i(TAG, "saved original $comp of ${p.name} ${p.version}")
}
/** Saves the originals of every Proton build not seen yet (first open, and after a Steam update). */
fun snapshotAll(context: Context) = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
for (p in protons(context)) for (comp in COMPONENTS) {
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
}
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
// ------------------------------------------------------------------ view
fun snapshot(context: Context): Snapshot = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
val reapplied = HashMap<String, Long>()
runCatching {
File(launchDir(context), "reapplied.log").forEachLine { line ->
val parts = line.split('\t')
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
}
}
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
val inUseLabel = when {
active == null -> "Original"
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
else -> {
val want = active.optJSONObject("files") ?: JSONObject()
val fp = fingerprint(p.dir, comp)
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
else "Changed outside Components"
}
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { "not present" }, detail, inUseLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.optString("label"),
)
}
ProtonView(p, comps, originals(context, p.id), inUse(p), reapplied[p.dir.name] ?: 0)
}
Snapshot(views, packages(context))
}
// ------------------------------------------------------------------ launch-time enforcement
/**
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
* what overwrote files in the first place.
*/
private fun syncLaunchState(context: Context, state: JSONObject) {
val dir = launchDir(context)
val store = File(dir, "store").apply { mkdirs() }
val all = protons(context)
val lines = StringBuilder()
val keep = HashSet<String>()
val active = state.sub("active")
for (pid in active.keys()) {
val p = all.firstOrNull { it.id == pid } ?: continue
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val a = comps.optJSONObject(comp) ?: continue
if (a.optString("protonVersion") != p.version) continue
val file = a.optString("file")
val (key, wcp) = if (file.startsWith("original ")) {
val v = file.removePrefix("original ")
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
if (!wcp.isFile) continue
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
unpacked.deleteRecursively()
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
val src = File(unpacked, rel)
if (!src.isFile) continue
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
}
}
}
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
val list = File(dir, "desired.tsv")
val tmp = File(dir, "desired.tsv.tmp")
tmp.writeText(lines.toString())
tmp.renameTo(list)
}
// ------------------------------------------------------------------ actions
/** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
}
applyPackage(context, p, info, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
saveState(context, state)
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
}
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
ensureOriginal(context, p, info.comp, state)
val hashes = installFiles(context, p.dir, info.comp, wcp)
state.sub("active").sub(p.id).put(info.comp, JSONObject()
.put("file", wcp.name).put("label", info.version).put("protonVersion", p.version)
.put("files", JSONObject(hashes.filterKeys { owned(info.comp, it) } as Map<*, *>))
.put("at", System.currentTimeMillis() / 1000))
Log.i(TAG, "swapped ${info.comp} ${info.version} into ${p.name} ${p.version}")
}
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
}
applyOriginal(context, p, comp, protonVersion, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(comp)
saveState(context, state)
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
installFiles(context, p.dir, comp, wcp)
val active = state.sub("active").sub(p.id)
if (current) active.remove(comp)
else active.put(comp, JSONObject().put("file", "original $protonVersion").put("label", "Original from $protonVersion")
.put("protonVersion", p.version).put("files", JSONObject(fingerprint(p.dir, comp) as Map<*, *>)).put("at", System.currentTimeMillis() / 1000))
}
/** Runs the swaps that waited for a game to close. Cheap when nothing is queued. */
fun applyQueued(context: Context): List<String> = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
if (queued.length() == 0) return emptyList()
val done = mutableListOf<String>()
val all = protons(context)
for (pid in queued.keys().asSequence().toList()) {
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
if (inUse(p)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val q = comps.optJSONObject(comp) ?: continue
runCatching {
if (q.optString("kind") == "original") {
val v = q.optString("protonVersion")
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
} else {
val wcp = File(packagesDir(context), q.optString("file"))
applyPackage(context, p, packageInfo(wcp), wcp, state)
}
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
comps.remove(comp)
}
if (comps.length() == 0) queued.remove(pid)
}
saveState(context, state)
done
}
fun cancelQueued(context: Context, protonId: String, comp: String) = synchronized(lock) {
val state = loadState(context)
state.sub("queued").optJSONObject(protonId)?.remove(comp)
saveState(context, state)
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
}
val f = File(packagesDir(context), name)
check(f.isFile) { "$name is not stored" }
f.delete()
"Deleted $name."
}
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(f.isFile) { "Not stored" }
f.delete()
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
"Deleted the $protonVersion original of ${LABEL[comp]}."
}
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
info.copy(file = dest.name)
}
// ------------------------------------------------------------------ catalog
/** The Nightlies listing: cached on disk, fetched again only when [force] (the refresh button). */
fun catalog(context: Context, force: Boolean): Catalog {
val cache = catalogFile(context)
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
for ((tag, comp) in RELEASES) {
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
if (!name.endsWith(".wcp")) continue
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
return Catalog(items, o.optLong("fetchedAt"))
}
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (item.digest.startsWith("sha256:")) {
if (!sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
}
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
}
}
@@ -0,0 +1,450 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
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.Info
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.LaunchedEffect
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
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.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
import java.text.DateFormat
import java.util.Date
/** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */
private class InstalledItem(
val name: String, val detail: String, val tag: String,
val selected: Boolean, val removable: Boolean,
/** Stored package file, or null for an original. */
val file: String?,
/** The Proton build an original belongs to. */
val protonVersion: String?,
)
private val ROW_HEIGHT: Dp = 40.dp
private val DETAIL_WIDTH: Dp = 260.dp
private val ACTION_WIDTH: Dp = 112.dp
private val GOLD = Color(0xFFF2C66D)
/**
* FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry.
* Compact on purpose (layout A of the mock): title, Proton drop-down, component tabs, what is in
* use and refresh share one toolbar line, the explanation sits behind the (i), and installed rows
* and the Nightlies' downloads run as one list of short rows - a landscape screen shows a dozen or
* more instead of two. Tapping an installed row swaps it in; the refresh button is the only thing
* that goes online.
*/
@Composable
fun ComponentsPage(
snapshot: Snapshot?,
catalog: List<CatalogItem>,
catalogAt: Long,
protonId: String?,
comp: String,
checking: Boolean,
busy: String?,
downloads: Map<String, Int>,
onProton: (String) -> Unit,
onComp: (String) -> Unit,
onSwap: (String) -> Unit,
onRestore: (String) -> Unit,
onCancelQueued: () -> Unit,
onDeletePackage: (String) -> Unit,
onDeleteOriginal: (String) -> Unit,
onDownload: (CatalogItem) -> Unit,
onRefresh: () -> Unit,
onImport: () -> Unit,
onBack: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
BackHandler(onBack = onBack)
var confirm by remember { mutableStateOf<Pair<String, () -> Unit>?>(null) }
var confirmTitle by remember { mutableStateOf("") }
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
val label = ComponentsManager.LABEL[comp] ?: comp
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp)) {
// ---- the toolbar: one line ------------------------------------------------------------
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)) {
FocusText("‹ Back", colors.onSurfaceVariant, onClick = onBack)
Text("Components", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
if (view != null) {
Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 260.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
views.forEachIndexed { i, v ->
val swaps = v.components.values.count { it.activeFile != null }
MenuItem(
v.proton.name, checked = v.proton.id == view.proton.id,
detail = v.proton.version + (if (swaps > 0) " · $swaps swapped" else " · all original") + (if (v.inUseByGame) " · game running" else ""),
focusRequester = if (i == 0) first else null,
) { onProton(v.proton.id); protonMenu = false }
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BumperHint("LB")
Segmented(ComponentsManager.COMPONENTS.map { it to ComponentsManager.LABEL.getValue(it) }, comp, onComp)
BumperHint("RB")
}
val st = view.components.getValue(comp)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape))
Spacer(Modifier.width(6.dp))
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
Text(
(if (st.queued != null) "Next: ${st.queued} · after the game" else "In use: ${st.inUse}") + fixedAt,
fontSize = 12.5.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
if (st.queued != null) { Spacer(Modifier.width(8.dp)); FocusText("Cancel", pal.signal, onClick = onCancelQueued) }
}
} else Spacer(Modifier.weight(1f))
Text(if (busy != null) "$busy…" else "Nightlies · $checked", fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
// ---- the list ---------------------------------------------------------------------------
when {
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant)
}
view == null -> Text(
"No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(16.dp),
)
else -> {
val p = view.proton
val st = view.components.getValue(comp)
val build = ComponentsManager.safeName(p.version)
val originals = view.originals.filter { it.comp == comp }
val stored = snapshot.packages.filter { it.comp == comp }
val activeIsOriginal = st.activeFile == null
val rows = originals.map { o ->
val isCurrent = o.protonVersion == build
InstalledItem(
"Original · ${o.protonVersion}",
o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · earlier build",
"ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion,
)
} + stored.map { s ->
InstalledItem(s.version, String.format("%.1f MB", s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null)
}
val storedNames = snapshot.packages.map { it.file }.toSet()
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
val first = remember { FocusRequester() }
LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } }
LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
item { SectionLabel("Installed · ${rows.size}") }
if (rows.isEmpty()) item {
Text("Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp))
}
items(rows.size) { i ->
val item = rows[i]
InstalledLine(
item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier,
onSelect = {
if (item.selected) return@InstalledLine
val waits = if (view.inUseByGame) " A game is running on this Proton, so it waits until the game closes." else " It applies the next time a game starts."
if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle.$waits") { onSwap(item.file) }
else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?$waits") { onRestore(item.protonVersion!!) }
},
onDelete = {
if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) }
else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) }
},
)
}
item { SectionLabel("Available on the Nightlies · ${available.size}") }
if (available.isEmpty()) item {
Text(if (catalogAt == 0L) "Tap refresh to list the packages on the Nightlies." else "Nothing new from the last check.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp))
}
items(available.size) { i ->
val d = available[i]
AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) }
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp, vertical = 10.dp)) {
SmallButton("Import .wcp", onClick = onImport)
if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText("Restore this Proton's original", pal.signal) {
ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(build) }
}
}
}
}
}
}
}
if (about) AlertDialog(
onDismissRequest = { about = false },
title = { Text("Components") },
text = {
Text(
"FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" +
"Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" +
"Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\n\n" +
"Each Proton build's own files are kept as its ORIGINAL, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.",
fontSize = 13.sp,
)
},
confirmButton = { FocusText("OK", pal.signal) { about = false } },
)
confirm?.let { (body, action) ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(confirmTitle) },
text = { Text(body, fontSize = 13.sp) },
// Opens on Cancel, so a stray A press on a controller never swaps or deletes anything.
confirmButton = {
val del = confirmTitle.startsWith("Delete")
FocusText(if (del) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", if (del) colors.error else pal.signal) { confirm = null; action() }
},
dismissButton = {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
},
)
}
}
/** The pad's bumper that turns the component tabs, as a small badge beside them. */
@Composable
private fun BumperHint(text: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Text(
text, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, color = colors.onSurfaceVariant, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(5.dp)).border(1.dp, pal.line2, RoundedCornerShape(5.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
)
}
@Composable
private fun SectionLabel(text: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 8.dp, top = 10.dp, bottom = 4.dp)) {
Text(text.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
}
}
@Composable
private fun Segmented(options: List<Pair<String, String>>, selected: String, onPick: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(Modifier.clip(RoundedCornerShape(9.dp)).background(colors.surfaceVariant).border(1.dp, pal.line2, RoundedCornerShape(9.dp)).padding(2.dp)) {
for ((key, text) in options) {
val on = key == selected
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pick = { onPick(key) }
Text(
text, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, maxLines = 1,
color = if (on) Color.White else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(7.dp))
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.18f) else Color.Transparent)
.border(if (hot && !on) 1.dp else 0.dp, if (hot && !on) pal.signal else Color.Transparent, RoundedCornerShape(7.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
}
}
@Composable
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(34.dp).clip(RoundedCornerShape(9.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(9.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(18.dp))
}
}
/**
* A small button. Resting: grey outline (blue text when [accent]). Focused or hovered: a solid blue
* fill, white text and a white outline - unmistakable in a column of identical buttons.
*/
@Composable
private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled
Box(
contentAlignment = Alignment.Center,
modifier = modifier.clip(RoundedCornerShape(8.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) Color.White else pal.line2, RoundedCornerShape(8.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 5.dp),
) {
Text(
text, fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant else if (hot) Color.White else if (accent) Color(0xFF5E9BFF) else colors.onBackground,
)
}
}
@Composable
private fun Tag(text: String) {
val colors = MaterialTheme.colorScheme
val (fg, bg) = when (text) {
"ORIGINAL" -> GOLD to Color(0x22F2C66D)
"IN USE" -> Color.White to LocalPalette.current.signal
"NEW" -> LocalPalette.current.good to Color(0x224CD37F)
else -> colors.onSurfaceVariant to Color.White.copy(alpha = 0.07f)
}
Text(
text, fontSize = 9.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp, color = fg, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(bg).padding(horizontal = 5.dp, vertical = 1.dp),
)
}
@Composable
private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = ROW_HEIGHT).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.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, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.weight(1f).heightIn(min = ROW_HEIGHT).then(modifier)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(horizontal = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(16.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) {
if (item.selected) Box(Modifier.size(7.dp).background(pal.signal, CircleShape))
}
Text(
item.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (item.selected) Color(0xFF5E9BFF) else colors.onBackground, modifier = Modifier.weight(1f),
)
Box(Modifier.width(72.dp)) { Tag(item.tag) }
Text(item.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(DETAIL_WIDTH))
Box(Modifier.width(56.dp), contentAlignment = Alignment.CenterEnd) { if (item.selected) Tag("IN USE") }
}
// The trash column is always there (empty when the row can't be deleted) so rows line up.
if (!item.removable) Spacer(Modifier.padding(end = 4.dp).size(32.dp))
else {
val delSrc = remember { MutableInteractionSource() }
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 4.dp).size(32.dp).clip(RoundedCornerShape(8.dp))
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(8.dp))
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
.controllerConfirm(onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(17.dp)) }
}
}
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f)))
}
@Composable
private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = ROW_HEIGHT).padding(horizontal = 10.dp),
) {
// Fixed columns so every row's details and button line up: name | details | button.
Tag("NEW")
Text(d.file.removeSuffix(".wcp"), fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text("${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 11.5.sp, color = colors.onSurfaceVariant,
fontFamily = FontFamily.Default, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(DETAIL_WIDTH))
if (progress == null) SmallButton("Download", enabled = enabled, accent = true, modifier = Modifier.width(ACTION_WIDTH), onClick = onDownload)
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(ACTION_WIDTH)) {
Text(if (progress < 0) "…" else "$progress%", fontSize = 11.sp, color = colors.onSurfaceVariant)
Spacer(Modifier.height(3.dp))
if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
}
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).height(1.dp).background(pal.line.copy(alpha = 0.5f)))
}
@@ -221,7 +221,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
/** 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) {
internal 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)
@@ -193,6 +193,8 @@ class FrontEndActions(
val onRuntime: () -> Unit,
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: () -> Unit,
val onPerformance: () -> Unit,
val onRoms: () -> Unit,
val onFiles: () -> Unit,
@@ -374,8 +376,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
else -> s.pageKey ?: selected
}
val onRailSelect: (String) -> Unit = { key ->
if (s.pageKey != null) a.onPageBack()
selected = key
// Components is a full page like Protons or Performance, opened over the current rail
// selection rather than replacing it.
if (key == "components") a.onComponents()
else {
if (s.pageKey != null) a.onPageBack()
selected = key
}
navOpen = false
}
// Start controllers on the current page's main action; the rail is initially collapsed.
@@ -580,6 +587,7 @@ private fun Rail(
}
NavItem("Steam", "steam", selected == "steam", count = s.steamGames.size, register = register, unregister = unregister) { onSelect("steam") }
NavItem("Desktop", "desktop", selected == "desktop", count = s.emulators.count { it.installed }, register = register, unregister = unregister) { onSelect("desktop") }
NavItem("Components", "components", selected == "components", register = register, unregister = unregister) { onSelect("components") }
NavItem("Setup", "setup", selected == "setup", register = register, unregister = unregister) { onSelect("setup") }
Spacer(Modifier.height(6.dp))
}
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material3.Icon
@@ -91,18 +92,19 @@ import androidx.compose.ui.text.style.TextOverflow
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
private val drawerPageTitles = listOf("Display", "Controls", "Settings")
private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings")
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "touch", "suspend")
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend")
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(3)
private val last = arrayOfNulls<String>(DRAWER_PAGES)
var focused by mutableStateOf<String?>(null)
private set
@@ -240,6 +242,12 @@ class DrawerActions(
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
/** Swaps [value] ("orig:<build>" or a stored package file) into a Proton's component. */
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
)
@OptIn(ExperimentalComposeUiApi::class)
@@ -251,7 +259,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
val pageScroll = remember { List(3) { ScrollState(0) } }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
@@ -261,6 +269,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
@@ -393,11 +402,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
DrawerOutlineButton("LB ‹", modifier = Modifier.height(48.dp).then(focus.track(page, "prev"))) {
host.open = null; onPageChange((page + 2) % 3)
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
}
DrawerPageTabs(page = page, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerOutlineButton("› RB", modifier = Modifier.height(48.dp).then(focus.track(page, "next"))) {
host.open = null; onPageChange((page + 1) % 3)
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
}
}
@@ -430,6 +439,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
1 -> {
SettingsGroup("Controls") {
ChoiceRow(host, "touch", "Touch", null,
@@ -638,11 +648,17 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (
val count = drawerPageIcons.size
val middle = (count - 1) / 2f
val motion = tween<Float>(durationMillis = 340, easing = FastOutSlowInEasing)
val lean by animateFloatAsState(-(page - middle) * DRAWER_TAB_LEAN_DP, motion, label = "tabLean")
Box(
androidx.compose.foundation.layout.BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier.height(64.dp).clipToBounds(),
) {
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
// (56 dp) and pushed outward by the lean, which scales with the spacing. So
// (count-1) * spacing * (1 + lean/64) + 56 <= width
// - never more than the mock's 64 dp, never tighter than 32 dp.
val spacing = ((maxWidth.value - 56f) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
drawerPageIcons.forEachIndexed { index, icon ->
val selected = index == page
val source = remember { MutableInteractionSource() }
@@ -655,7 +671,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * DRAWER_TAB_SPACING_DP + lean).dp)
.offset(x = ((index - middle) * spacing + lean).dp)
.size(56.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
@@ -676,6 +692,76 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (
}
}
/** Display, Controls, Components, Settings. */
const val DRAWER_PAGES = 4
private const val DRAWER_PAGE_COMPONENTS = 2
/**
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
* deleting stay on the Components page in the app.
*/
@Composable
private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val colors = MaterialTheme.colorScheme
val snap = a.components
if (snap == null) {
SettingsGroup("Components") {
Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
}
return
}
val running = snap.protons.filter { it.inUseByGame }
var pick by rememberSaveable { mutableStateOf<String?>(null) }
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
SettingsGroup("Running now") {
if (running.isEmpty()) SettingsRow("No game is running on a Proton", "Swaps apply the next time a game starts.") {}
else for (r in running) {
val c = r.components
SettingsRow(
r.proton.name,
ComponentsManager.COMPONENTS.joinToString(" · ") { "${ComponentsManager.LABEL[it]} ${c.getValue(it).inUse}" },
) {}
}
}
if (view == null) {
SettingsGroup("Components") {
Text("No Proton is installed yet.", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
}
return
}
val p = view.proton
SettingsGroup("Components") {
ChoiceRow(
host, "cmp-proton", "Proton", p.version + (if (view.inUseByGame) " · in use by the running game" else "") +
(if (view.reappliedAt > 0) " · re-applied at launch " + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(view.reappliedAt * 1000)) else ""),
snap.protons.map { it.proton.id to it.proton.name }, p.id,
chipModifier = track("cmp-proton"), onPick = { pick = it },
)
val build = ComponentsManager.safeName(p.version)
for (comp in ComponentsManager.COMPONENTS) {
val st = view.components.getValue(comp)
val originals = view.originals.filter { it.comp == comp }
val options = originals.map { "orig:${it.protonVersion}" to (if (it.protonVersion == build) "Original" else "Original · ${it.protonVersion}") } +
snap.packages.filter { it.comp == comp }.map { it.file to it.version }
if (options.isEmpty()) continue
val current = st.activeFile ?: "orig:$build"
ChoiceRow(
host, "cmp-$comp", ComponentsManager.LABEL.getValue(comp),
st.queued?.let { "Next: $it · after the game closes" } ?: st.inUse,
options, current,
note = if (view.inUseByGame) "A game is running on this Proton: the change waits until it closes." else "Applies the next time a game starts.",
chipModifier = track("cmp-$comp"),
onPick = { v -> if (v != current || st.queued != null) a.onComponentSwap(p.id, comp, v) },
)
}
}
Text(
"Downloads, importing and deleting are on the Components page in the app.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
)
}
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
private const val DRAWER_TAB_LEAN_DP = 12f