mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5358fa85 | ||
|
|
d7ff92106a | ||
|
|
110aec0b6f | ||
|
|
06502bb49b | ||
|
|
c982d65675 | ||
|
|
f4e606fb15 | ||
|
|
9dbdb5e771 | ||
|
|
fa12478fdd | ||
|
|
cbb9a168e6 | ||
|
|
73e2fc58a0 | ||
|
|
500f4c69eb |
No files matched your search
@@ -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))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
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
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import android.view.Display
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -82,16 +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("Now", "Controls", "Session")
|
||||
private val drawerPageEntries = listOf("hud", "touch", "suspend")
|
||||
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.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
|
||||
|
||||
@@ -229,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)
|
||||
@@ -240,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
|
||||
@@ -250,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)
|
||||
}
|
||||
@@ -382,36 +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)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
drawerPageTitles.forEachIndexed { index, title ->
|
||||
val dotSource = remember { MutableInteractionSource() }
|
||||
val dotFocused = dotSource.collectIsFocusedAsState().value
|
||||
val select = { host.open = null; onPageChange(index) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(38.dp)
|
||||
.semantics { contentDescription = "$title page" }
|
||||
.hoverable(dotSource)
|
||||
.clickable(interactionSource = dotSource, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(if (index == page) 16.dp else 10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (index == page) pal.signal else colors.onSurfaceVariant)
|
||||
.border(if (dotFocused) 2.dp else 0.dp, colors.onBackground, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +414,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
|
||||
) {
|
||||
when (page) {
|
||||
0 -> SettingsGroup("Now") {
|
||||
0 -> SettingsGroup("Display") {
|
||||
ToggleRow(host, "hud", "Performance HUD", null, a.hudOn,
|
||||
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
|
||||
if (a.fillScreen != null) ToggleRow(
|
||||
@@ -444,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,
|
||||
@@ -637,3 +633,138 @@ private fun ShareSessionLogsButton(modifier: Modifier = Modifier, onClick: () ->
|
||||
color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
|
||||
* selected one steps forward - full size, bright, a soft glow - while the others step back to
|
||||
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
|
||||
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
|
||||
* pad like the dots it replaces.
|
||||
*/
|
||||
@Composable
|
||||
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (Int) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val count = drawerPageIcons.size
|
||||
val middle = (count - 1) / 2f
|
||||
val motion = tween<Float>(durationMillis = 340, easing = FastOutSlowInEasing)
|
||||
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() }
|
||||
val focused = source.collectIsFocusedAsState().value
|
||||
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
|
||||
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
|
||||
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, tween(340), label = "tabTint")
|
||||
val select = { onSelect(index) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset(x = ((index - middle) * spacing + lean).dp)
|
||||
.size(56.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
|
||||
),
|
||||
)
|
||||
.border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp))
|
||||
.semantics { contentDescription = "${drawerPageTitles[index]} page" }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
|
||||
|
||||
Reference in new issue
Block a user