mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 11:01:07 +00:00
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.
This commit is contained in:
1 parent
12ed8fdf2d
commit
fe00cae540
7 files changed
+1186
-15
No files matched your search
@@ -34,6 +34,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 +91,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 +152,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 +262,7 @@ class MainActivity : ComponentActivity() {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
showPerformance -> { { PerformanceHost() } }
|
||||
showProtons -> { { ProtonHost() } }
|
||||
showComponents -> { { ComponentsHost() } }
|
||||
showMapping -> { { MappingHost() } }
|
||||
else -> null
|
||||
}
|
||||
@@ -260,7 +276,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 +331,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 +353,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 +377,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() },
|
||||
),
|
||||
),
|
||||
@@ -419,6 +436,15 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
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 +518,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun openProtons() {
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
showProtons = true
|
||||
refreshProtons()
|
||||
@@ -543,6 +570,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 +908,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 +1236,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,572 @@
|
||||
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)
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ view
|
||||
|
||||
fun snapshot(context: Context): Snapshot = synchronized(lock) {
|
||||
migratePlugin(context)
|
||||
val state = loadState(context)
|
||||
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))
|
||||
}
|
||||
Snapshot(views, packages(context))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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,342 @@
|
||||
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.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.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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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 key: String, 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?,
|
||||
)
|
||||
|
||||
/**
|
||||
* FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry.
|
||||
* Built like the driver pages: pick a Proton (drop-down) and a component (tabs); the installed
|
||||
* rows are the Proton's original bundles and the stored packages, the ● is what that Proton uses,
|
||||
* tapping another row swaps it in; downloads come from the Nightlies "-Linux" releases, and the
|
||||
* refresh button beside the title 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 host = rememberMenuHost()
|
||||
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("") }
|
||||
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.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
|
||||
val status = buildString {
|
||||
append("Nightlies last checked $checked")
|
||||
if (busy != null) append(" · $busy…")
|
||||
}
|
||||
|
||||
val refreshSrc = remember { MutableInteractionSource() }
|
||||
val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value
|
||||
SettingsPage(
|
||||
host, title = "Components", onBack = onBack,
|
||||
lede = "FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with. Tap a row to swap it in; it applies the next time a game starts.\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))
|
||||
.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 the Nightlies for new packages", tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (snapshot == null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 20.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)
|
||||
}
|
||||
return@SettingsPage
|
||||
}
|
||||
if (view == null) {
|
||||
SettingsGroup("Proton") {
|
||||
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 = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
}
|
||||
return@SettingsPage
|
||||
}
|
||||
val p = view.proton
|
||||
SettingsGroup("Proton") {
|
||||
ChoiceRow(
|
||||
host, "components-proton", p.name,
|
||||
p.version + (if (view.inUseByGame) " · a game is running on it" else "") + (if (p.valve) " · updated by Steam" else ""),
|
||||
views.map { v ->
|
||||
val swaps = v.components.values.count { it.activeFile != null }
|
||||
v.proton.id to (v.proton.name + if (swaps > 0) " · $swaps swapped" else "")
|
||||
},
|
||||
p.id,
|
||||
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.",
|
||||
onPick = onProton,
|
||||
)
|
||||
}
|
||||
|
||||
// Component tabs.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) {
|
||||
for (c in ComponentsManager.COMPONENTS) CompTab(ComponentsManager.LABEL.getValue(c), c == comp) { onComp(c) }
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
|
||||
val state = view.components.getValue(comp)
|
||||
SettingsGroup("In use") {
|
||||
SettingsRow(
|
||||
state.inUse,
|
||||
"Detected: ${state.detected}" + (if (state.detail.isNotEmpty()) " · ${state.detail}" else "") +
|
||||
(state.queued?.let { "\nNext: $it, when the game running on this Proton closes" } ?: ""),
|
||||
) {
|
||||
if (state.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued)
|
||||
else Box(Modifier.size(10.dp).background(pal.good, CircleShape))
|
||||
}
|
||||
}
|
||||
|
||||
val originals = view.originals.filter { it.comp == comp }
|
||||
val currentBuild = ComponentsManager.safeName(p.version)
|
||||
val stored = snapshot.packages.filter { it.comp == comp }
|
||||
val activeIsOriginal = state.activeFile == null
|
||||
val items = originals.map { o ->
|
||||
val isCurrent = o.protonVersion == currentBuild
|
||||
InstalledItem(
|
||||
"orig:${o.protonVersion}", "Original · ${o.protonVersion}",
|
||||
o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · an earlier build",
|
||||
"ORIGINAL", selected = activeIsOriginal && isCurrent, removable = !isCurrent, file = null, protonVersion = o.protonVersion,
|
||||
)
|
||||
} + stored.map { s ->
|
||||
InstalledItem(
|
||||
"pkg:${s.file}", s.version, s.file + " · " + String.format("%.1f MB", s.size / 1048576.0),
|
||||
"STORED", selected = s.file == state.activeFile, removable = true, file = s.file, protonVersion = null,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Installed") {
|
||||
if (items.isEmpty()) Text(
|
||||
"Nothing stored for $label yet. The Proton's own files are saved as its original the first time this page opens.",
|
||||
fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
)
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } }
|
||||
items.forEachIndexed { i, item ->
|
||||
ComponentRow(
|
||||
item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier,
|
||||
onSelect = {
|
||||
if (item.selected) return@ComponentRow
|
||||
if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle." +
|
||||
if (view.inUseByGame) " A game is running on this Proton, so the swap waits until it closes." else " It applies the next time a game starts.") { onSwap(item.file) }
|
||||
else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?" +
|
||||
if (view.inUseByGame) " A game is running on this Proton, so this waits until it closes." else "") { 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!!) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val storedNames = snapshot.packages.map { it.file }.toSet()
|
||||
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
|
||||
SettingsGroup("Available to download") {
|
||||
if (available.isEmpty()) Text(
|
||||
if (catalogAt == 0L) "Tap the refresh button to list the packages on the Nightlies." else "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 available) {
|
||||
SettingsRow(d.file.removeSuffix(".wcp"), "${d.release} · " + String.format("%.1f MB", d.size / 1048576.0)) {
|
||||
val pr = downloads[d.file]
|
||||
if (pr == null) SecondaryButton("Download", enabled = busy == null) { onDownload(d) }
|
||||
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) {
|
||||
Text(if (pr < 0) "…" else "$pr%", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (pr < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
else LinearProgressIndicator(progress = { pr / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
|
||||
SecondaryButton("Import .wcp", onClick = onImport)
|
||||
val currentOriginal = originals.any { it.protonVersion == currentBuild }
|
||||
if (!activeIsOriginal && currentOriginal) FocusText("Restore this Proton's original", pal.signal) {
|
||||
ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(currentBuild) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = { FocusText(if (confirmTitle.startsWith("Delete")) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap",
|
||||
if (confirmTitle.startsWith("Delete")) 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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompTab(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
text, fontSize = 14.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (selected) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
Box(Modifier.width(48.dp).height(3.dp).clip(RoundedCornerShape(2.dp)).background(if (selected) pal.signal else Color.Transparent))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComponentRow(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(10.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(3.dp).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,
|
||||
modifier = Modifier.weight(1f).then(modifier)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
|
||||
.controllerConfirm(onClick = onSelect)
|
||||
.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape),
|
||||
) { if (item.selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) }
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(
|
||||
item.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp,
|
||||
color = if (item.tag == "ORIGINAL") Color(0xFFF2C66D) else colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp))
|
||||
.background(if (item.tag == "ORIGINAL") Color(0x22F2C66D) else Color.White.copy(alpha = 0.07f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text(item.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (item.removable) {
|
||||
val delSrc = remember { MutableInteractionSource() }
|
||||
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(end = 6.dp).size(38.dp).clip(RoundedCornerShape(9.dp))
|
||||
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(9.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(20.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -676,6 +686,75 @@ 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 "",
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user