diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index beecb6007..a2a1accd3 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -34,6 +34,8 @@ import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.ui.PackageRow import com.droiddeck.launcher.session.OfflineMode import com.droiddeck.launcher.session.ProtonExtras +import com.droiddeck.launcher.session.ComponentsManager +import com.droiddeck.launcher.ui.ComponentsPage import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.ProtonPage @@ -89,6 +91,16 @@ class MainActivity : ComponentActivity() { private var steamDeckMode by mutableStateOf(false) private var showCredits by mutableStateOf(false) private var showProtons by mutableStateOf(false) + // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). + private var showComponents by mutableStateOf(false) + private var compSnapshot by mutableStateOf(null) + private var compCatalog by mutableStateOf>(emptyList()) + private var compCatalogAt by mutableStateOf(0L) + private var compProton by mutableStateOf(null) + private var compComp by mutableStateOf("dxvk") + private var compChecking by mutableStateOf(false) + private var compBusy by mutableStateOf(null) + private var compDownloads by mutableStateOf>(emptyMap()) private var deckyInstalled by mutableStateOf(null) private var deckyReleases by mutableStateOf>(emptyList()) private var deckyChecking by mutableStateOf(false) @@ -140,6 +152,9 @@ class MainActivity : ComponentActivity() { // The app's own picker (files/), once per kind of pick: the two driver lists validate // differently, and the reason a zip is refused names the list it belongs in. + private val pickComponent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) } + } private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = true) } } @@ -247,6 +262,7 @@ class MainActivity : ComponentActivity() { sm != null -> { { ModeSettingsHost(sm) } } showPerformance -> { { PerformanceHost() } } showProtons -> { { ProtonHost() } } + showComponents -> { { ComponentsHost() } } showMapping -> { { MappingHost() } } else -> null } @@ -260,7 +276,7 @@ class MainActivity : ComponentActivity() { steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), lsfgReady = LsfgNative.isInstalled(this), - pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showMapping) "controller-mapping" else null, + pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, @@ -315,7 +331,8 @@ class MainActivity : ComponentActivity() { frameGenLabel = FrameGen.label(this) }, onProtons = { openProtons() }, - onPerformance = { refreshCores(); showProtons = false; showMapping = false; showPerformance = true }, + onComponents = { openComponents() }, + onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onLogs = { @@ -336,7 +353,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onCredits = { showCredits = true }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = false }, + onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onHomeApp = { manageHomeApp() }, onHomeScreen = { on -> @@ -360,7 +377,7 @@ class MainActivity : ComponentActivity() { onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, - onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = true }, + onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -419,6 +436,15 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + // Swaps queued while a game ran on that Proton go in once nothing uses it (usually the + // session has just ended). Cheap when nothing is queued. + if (!SessionState.running) Thread({ + val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList()) + if (applied.isNotEmpty()) ui.post { + android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show() + if (showComponents) refreshComponents() + } + }, "components-queue").start() oscMode = SessionPrefs.oscMode(this) refreshController() refreshHomeAppState() @@ -492,6 +518,7 @@ class MainActivity : ComponentActivity() { private fun openProtons() { settingsMode = null showPerformance = false + showComponents = false showMapping = false showProtons = true refreshProtons() @@ -543,6 +570,106 @@ class MainActivity : ComponentActivity() { ) } + private fun openComponents() { + settingsMode = null + showPerformance = false + showProtons = false + showMapping = false + showComponents = true + refreshComponents(snapshotFirst = true) + } + + /** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */ + private fun refreshComponents(snapshotFirst: Boolean = false) { + Thread({ + if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(this) } + runCatching { ComponentsManager.applyQueued(this) } + val snap = runCatching { ComponentsManager.snapshot(this) }.onFailure { Log.w(TAG, "components", it) }.getOrNull() + val cat = runCatching { ComponentsManager.catalog(this, false) }.getOrNull() + ui.post { + compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList()) + if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } + if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id + } + }, "components").start() + } + + private fun componentAction(label: String, work: () -> String) { + if (compBusy != null) return + compBusy = label + Thread({ + val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" } + ui.post { + compBusy = null + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + refreshComponents() + } + }, "components-action").start() + } + + private fun refreshComponentCatalog() { + if (compChecking) return + compChecking = true + Thread({ + val cat = runCatching { ComponentsManager.catalog(this, true) }.getOrNull() + ui.post { + compChecking = false + if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(this, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show() + else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } + } + }, "components-catalog").start() + } + + private fun downloadComponent(item: ComponentsManager.CatalogItem) { + if (compDownloads.containsKey(item.file)) return + compDownloads = compDownloads + (item.file to -1) + Thread({ + val message = runCatching { + val pkg = ComponentsManager.download(this, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } } + "Stored ${pkg.version}" + }.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" } + ui.post { + compDownloads = compDownloads - item.file + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show() + refreshComponents() + } + }, "components-download").start() + } + + private fun importComponent(uri: Uri) { + val name = displayNameOf(uri) ?: "imported.wcp" + componentAction("Importing") { + val tmp = File(cacheDir, "component-import.wcp") + contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file") + try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() } + } + } + + @Composable + private fun ComponentsHost() { + ComponentsPage( + snapshot = compSnapshot, + catalog = compCatalog, + catalogAt = compCatalogAt, + protonId = compProton, + comp = compComp, + checking = compChecking, + busy = compBusy, + downloads = compDownloads, + onProton = { compProton = it }, + onComp = { compComp = it }, + onSwap = { file -> compProton?.let { pid -> componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } }, + onRestore = { version -> compProton?.let { pid -> componentAction("Restoring") { ComponentsManager.restore(this, pid, compComp, version) } } }, + onCancelQueued = { compProton?.let { pid -> componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, compComp); "The waiting swap was cancelled." } } }, + onDeletePackage = { file -> componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } }, + onDeleteOriginal = { version -> compProton?.let { pid -> componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, compComp, version) } } }, + onDownload = { downloadComponent(it) }, + onRefresh = { refreshComponentCatalog() }, + onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) }, + onBack = { showComponents = false }, + ) + } + @Composable private fun ProtonHost() { ProtonPage( @@ -781,6 +908,7 @@ class MainActivity : ComponentActivity() { private fun openModeSettings(mode: String) { showPerformance = false showProtons = false + showComponents = false showMapping = false refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) @@ -1108,5 +1236,6 @@ class MainActivity : ComponentActivity() { private const val TAG = "MainActivity" /** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */ private val ZIP_EXT = listOf("zip") + private val WCP_EXT = listOf("wcp") } } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 4e208eeed..2fb957f21 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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(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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt new file mode 100644 index 000000000..118051424 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -0,0 +1,572 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.core.Downloader +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.github.luben.zstd.ZstdInputStream +import com.github.luben.zstd.ZstdOutputStream +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest + +/** + * FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client. + * + * Every Proton the client runs games with keeps its own copy of these three inside its tree. This + * lists them, stores packages (.wcp: tar + zstd with a profile.json) downloaded from the Nightlies + * "-Linux" releases or imported, swaps one into a Proton, keeps the Proton's shipped files as + * "original bundles" - one per Proton build, so a Steam update adds a new one instead of replacing + * the last - restores those, and deletes stored packages. + * + * Where a Proton reads each component (its own `proton` script, ARM64 host): + * DXVK files/lib/wine/dxvk/{aarch64,i386}-windows -> the prefix's system32 / syswow64 + * VKD3D files/lib/wine/vkd3d-proton/{aarch64,i386}-windows -> the same, on every launch + * FEX files/lib/wine/aarch64-windows/lib{arm64ec,wow64}fex.dll + * files/lib/wine/aarch64-unix/lib{arm64ec,wow64}fex.so (FEX 2607 and newer) + * Proton copies DXVK and VKD3D into the prefix on every normal launch, so a swap applies the next + * time a game starts. A swap into a Proton a running game is using waits until that game exits. + * + * Same formats and data layout as the Decky "Components Manager" plugin, whose data is taken over + * once on first use. + */ +object ComponentsManager { + private const val TAG = "Components" + private const val NIGHTLIES = "The412Banner/Nightlies" + /** Release tag -> component. Only the Linux repacks: the Android releases carry bionic unixlibs. */ + private val RELEASES = linkedMapOf( + "FexCore-Linux" to "fex", + "Dxvk-Linux" to "dxvk", + "Dxvk-arm64ec-Linux" to "dxvk", + "Dxvk-gplasync-Linux" to "dxvk", + "Vkd3d-proton-Linux" to "vkd3d", + ) + private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d") + private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux") + val COMPONENTS = listOf("fex", "dxvk", "vkd3d") + val LABEL = mapOf("fex" to "FEX", "dxvk" to "DXVK", "vkd3d" to "VKD3D-Proton") + + private const val WINE = "files/lib/wine" + private val FEX_FILES = listOf( + "$WINE/aarch64-windows/libarm64ecfex.dll", + "$WINE/aarch64-windows/libwow64fex.dll", + "$WINE/aarch64-unix/libarm64ecfex.so", + "$WINE/aarch64-unix/libwow64fex.so", + ) + private val COMP_DIR = mapOf("dxvk" to "$WINE/dxvk", "vkd3d" to "$WINE/vkd3d-proton") + /** The folders a Proton reads on ARM64. */ + private val PE_ARCHES = listOf("aarch64-windows", "i386-windows") + /** + * The DLLs that ARE the component. A swap clears exactly these before unpacking, so no file of + * the previous version is left mixed in; anything else a Proton keeps beside them - GE's + * openvr_api_dxvk.dll, which its launcher copies into every prefix and aborts without - stays. + */ + private val COMP_DLLS = mapOf( + "dxvk" to setOf("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"), + "vkd3d" to setOf("d3d12.dll", "d3d12core.dll"), + ) + private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager" + + data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean) + data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?) + data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long) + data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long) + data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String) + data class ProtonView(val proton: Proton, val components: Map, val originals: List, val inUseByGame: Boolean) + data class Snapshot(val protons: List, val packages: List) + data class Catalog(val items: List, val fetchedAt: Long) + + private val lock = Any() + + // ------------------------------------------------------------------ paths + + private fun root(context: Context) = LinuxRuntime.rootDir(context) + private fun steam(context: Context) = File(root(context), "root/.local/share/Steam") + private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/")) + private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() } + private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() } + private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() } + private fun stateFile(context: Context) = File(dataDir(context), "state.json") + private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json") + + fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" } + + // ------------------------------------------------------------------ state + + private fun loadState(context: Context): JSONObject = + runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply { + if (!has("active")) put("active", JSONObject()) + if (!has("queued")) put("queued", JSONObject()) + } + + private fun saveState(context: Context, state: JSONObject) { + val f = stateFile(context) + val tmp = File(f.parentFile, f.name + ".tmp") + tmp.writeText(state.toString(2)) + tmp.renameTo(f) + } + + private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) } + + /** Takes over the Decky plugin's packages, originals and swap records, once. */ + private fun migratePlugin(context: Context) { + val marker = File(dataDir(context), ".plugin-imported") + if (marker.isFile) return + val plugin = File(root(context), PLUGIN_DATA) + if (plugin.isDirectory) { + runCatching { + File(plugin, "packages").listFiles()?.forEach { f -> + if (f.isFile && f.name.endsWith(".wcp")) { + val dst = File(packagesDir(context), f.name) + if (!dst.exists()) f.copyTo(dst) + } + } + val originals = File(plugin, "originals") + if (originals.isDirectory) originals.copyRecursively(originalsDir(context), overwrite = false) + val ps = File(plugin, "state.json") + if (ps.isFile && !stateFile(context).isFile) ps.copyTo(stateFile(context)) + Log.i(TAG, "took over the Decky plugin's data from $plugin") + }.onFailure { Log.w(TAG, "plugin data import", it) } + } + marker.writeText("1\n") + } + + // ------------------------------------------------------------------ Protons + + private fun libraryRoots(context: Context): List { + 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 { + val found = LinkedHashMap() + 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 { + 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 = + compFiles(dir, comp).filter { owned(comp, it) }.associateWith { sha256(File(dir, it)) } + + // ------------------------------------------------------------------ packages + + private fun normalize(name: String) = name.removePrefix("./").trimStart('/') + + private inline fun 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 = 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 { + 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() + 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) { + 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 { + val base = File(originalsDir(context), protonId) + val versions = base.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.lastModified() } ?: return emptyList() + return versions.flatMap { v -> + COMPONENTS.mapNotNull { comp -> + val f = File(v, "$comp.wcp") + if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length()) + } + } + } + + /** + * Saves a Proton's shipped files for this component before they are first replaced in this + * Proton build. A build already swapped into is not saved again: its files are ours. + */ + private fun ensureOriginal(context: Context, p: Proton, comp: String, state: JSONObject) { + val dest = originalFile(context, p, comp) + if (dest.isFile) return + val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp) + if (active != null && active.optString("protonVersion") == p.version) return + val rels = compFiles(p.dir, comp) + if (rels.isEmpty()) return + val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version")) + val profile = JSONObject() + .put("type", TYPE_OF.getValue(comp)) + .put("versionName", "original-${p.version}") + .put("versionCode", 0) + .put("description", "Original · ${p.name} · ${p.version} · ${LABEL.getValue(comp)} $current".trim()) + .put("files", JSONArray(rels.map { JSONObject().put("source", it).put("target", "\${proton}/$it") })) + writeWcp(dest, profile, p.dir, rels) + Log.i(TAG, "saved original $comp of ${p.name} ${p.version}") + } + + /** Saves the originals of every Proton build not seen yet (first open, and after a Steam update). */ + fun snapshotAll(context: Context) = synchronized(lock) { + migratePlugin(context) + val state = loadState(context) + for (p in protons(context)) for (comp in COMPONENTS) { + runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) } + } + } + + // ------------------------------------------------------------------ view + + fun snapshot(context: Context): Snapshot = synchronized(lock) { + migratePlugin(context) + val state = loadState(context) + val views = protons(context).map { p -> + val comps = COMPONENTS.associateWith { comp -> + val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version")) + val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else "" + val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp) + val inUseLabel = when { + active == null -> "Original" + active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)" + else -> { + val want = active.optJSONObject("files") ?: JSONObject() + val fp = fingerprint(p.dir, comp) + if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file")) + else "Changed outside Components" + } + } + val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp) + Component( + detected.ifEmpty { "not present" }, detail, inUseLabel, + active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version }, + q?.optString("label"), + ) + } + ProtonView(p, comps, originals(context, p.id), inUse(p)) + } + Snapshot(views, packages(context)) + } + + // ------------------------------------------------------------------ actions + + /** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */ + fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) { + val p = proton(context, protonId) + val wcp = File(packagesDir(context), safeName(file)) + val info = packageInfo(wcp) + val state = loadState(context) + if (inUse(p)) { + state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version)) + saveState(context, state) + return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes." + } + applyPackage(context, p, info, wcp, state) + state.sub("queued").optJSONObject(p.id)?.remove(info.comp) + saveState(context, state) + "${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts." + } + + private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) { + ensureOriginal(context, p, info.comp, state) + val hashes = installFiles(context, p.dir, info.comp, wcp) + state.sub("active").sub(p.id).put(info.comp, JSONObject() + .put("file", wcp.name).put("label", info.version).put("protonVersion", p.version) + .put("files", JSONObject(hashes.filterKeys { owned(info.comp, it) } as Map<*, *>)) + .put("at", System.currentTimeMillis() / 1000)) + Log.i(TAG, "swapped ${info.comp} ${info.version} into ${p.name} ${p.version}") + } + + fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) { + val p = proton(context, protonId) + val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp") + check(wcp.isFile) { "That original bundle is not stored" } + val state = loadState(context) + if (inUse(p)) { + state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion")) + saveState(context, state) + return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes." + } + applyOriginal(context, p, comp, protonVersion, wcp, state) + state.sub("queued").optJSONObject(p.id)?.remove(comp) + saveState(context, state) + "${LABEL[comp]} of ${p.name} restored from its $protonVersion original." + } + + private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) { + val current = protonVersion == safeName(p.version) + if (!current) ensureOriginal(context, p, comp, state) + installFiles(context, p.dir, comp, wcp) + val active = state.sub("active").sub(p.id) + if (current) active.remove(comp) + else active.put(comp, JSONObject().put("file", "original $protonVersion").put("label", "Original from $protonVersion") + .put("protonVersion", p.version).put("files", JSONObject(fingerprint(p.dir, comp) as Map<*, *>)).put("at", System.currentTimeMillis() / 1000)) + } + + /** Runs the swaps that waited for a game to close. Cheap when nothing is queued. */ + fun applyQueued(context: Context): List = synchronized(lock) { + val state = loadState(context) + val queued = state.sub("queued") + if (queued.length() == 0) return emptyList() + val done = mutableListOf() + 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt new file mode 100644 index 000000000..8f611bfb2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -0,0 +1,342 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.session.ComponentsManager +import com.droiddeck.launcher.session.ComponentsManager.CatalogItem +import com.droiddeck.launcher.session.ComponentsManager.Snapshot +import java.text.DateFormat +import java.util.Date + +/** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */ +private class InstalledItem( + val key: String, val name: String, val detail: String, val tag: String, + val selected: Boolean, val removable: Boolean, + /** Stored package file, or null for an original. */ + val file: String?, + /** The Proton build an original belongs to. */ + val protonVersion: String?, +) + +/** + * FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry. + * Built like the driver pages: pick a Proton (drop-down) and a component (tabs); the installed + * rows are the Proton's original bundles and the stored packages, the ● is what that Proton uses, + * tapping another row swaps it in; downloads come from the Nightlies "-Linux" releases, and the + * refresh button beside the title is the only thing that goes online. + */ +@Composable +fun ComponentsPage( + snapshot: Snapshot?, + catalog: List, + catalogAt: Long, + protonId: String?, + comp: String, + checking: Boolean, + busy: String?, + downloads: Map, + onProton: (String) -> Unit, + onComp: (String) -> Unit, + onSwap: (String) -> Unit, + onRestore: (String) -> Unit, + onCancelQueued: () -> Unit, + onDeletePackage: (String) -> Unit, + onDeleteOriginal: (String) -> Unit, + onDownload: (CatalogItem) -> Unit, + onRefresh: () -> Unit, + onImport: () -> Unit, + onBack: () -> Unit, +) { + val host = rememberMenuHost() + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + BackHandler(onBack = onBack) + var confirm by remember { mutableStateOf Unit>?>(null) } + var confirmTitle by remember { mutableStateOf("") } + fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action } + + val views = snapshot?.protons ?: emptyList() + val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() + val label = ComponentsManager.LABEL[comp] ?: comp + val checked = if (catalogAt > 0) DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never" + val status = buildString { + append("Nightlies last checked $checked") + if (busy != null) append(" · $busy…") + } + + val refreshSrc = remember { MutableInteractionSource() } + val refreshHot = refreshSrc.collectIsFocusedAsState().value || refreshSrc.collectIsHoveredAsState().value + SettingsPage( + host, title = "Components", onBack = onBack, + lede = "FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with. Tap a row to swap it in; it applies the next time a game starts.\n$status", + action = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) + .background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) + .border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp)) + .hoverable(refreshSrc) + .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), + ) { + if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + else Icon(Icons.Outlined.Refresh, contentDescription = "Check the Nightlies for new packages", tint = colors.onBackground, modifier = Modifier.size(20.dp)) + } + }, + ) { + if (snapshot == null) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 20.dp)) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(12.dp)) + Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant) + } + return@SettingsPage + } + if (view == null) { + SettingsGroup("Proton") { + Text( + "No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.", + fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp), + ) + } + return@SettingsPage + } + val p = view.proton + SettingsGroup("Proton") { + ChoiceRow( + host, "components-proton", p.name, + p.version + (if (view.inUseByGame) " · a game is running on it" else "") + (if (p.valve) " · updated by Steam" else ""), + views.map { v -> + val swaps = v.components.values.count { it.activeFile != null } + v.proton.id to (v.proton.name + if (swaps > 0) " · $swaps swapped" else "") + }, + p.id, + note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.", + onPick = onProton, + ) + } + + // Component tabs. + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) { + for (c in ComponentsManager.COMPONENTS) CompTab(ComponentsManager.LABEL.getValue(c), c == comp) { onComp(c) } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + + val state = view.components.getValue(comp) + SettingsGroup("In use") { + SettingsRow( + state.inUse, + "Detected: ${state.detected}" + (if (state.detail.isNotEmpty()) " · ${state.detail}" else "") + + (state.queued?.let { "\nNext: $it, when the game running on this Proton closes" } ?: ""), + ) { + if (state.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued) + else Box(Modifier.size(10.dp).background(pal.good, CircleShape)) + } + } + + val originals = view.originals.filter { it.comp == comp } + val currentBuild = ComponentsManager.safeName(p.version) + val stored = snapshot.packages.filter { it.comp == comp } + val activeIsOriginal = state.activeFile == null + val items = originals.map { o -> + val isCurrent = o.protonVersion == currentBuild + InstalledItem( + "orig:${o.protonVersion}", "Original · ${o.protonVersion}", + o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · an earlier build", + "ORIGINAL", selected = activeIsOriginal && isCurrent, removable = !isCurrent, file = null, protonVersion = o.protonVersion, + ) + } + stored.map { s -> + InstalledItem( + "pkg:${s.file}", s.version, s.file + " · " + String.format("%.1f MB", s.size / 1048576.0), + "STORED", selected = s.file == state.activeFile, removable = true, file = s.file, protonVersion = null, + ) + } + SettingsGroup("Installed") { + if (items.isEmpty()) Text( + "Nothing stored for $label yet. The Proton's own files are saved as its original the first time this page opens.", + fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), + ) + val first = remember { FocusRequester() } + LaunchedEffect(p.id, comp) { runCatching { first.requestFocus() } } + items.forEachIndexed { i, item -> + ComponentRow( + item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier, + onSelect = { + if (item.selected) return@ComponentRow + if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle." + + if (view.inUseByGame) " A game is running on this Proton, so the swap waits until it closes." else " It applies the next time a game starts.") { onSwap(item.file) } + else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?" + + if (view.inUseByGame) " A game is running on this Proton, so this waits until it closes." else "") { onRestore(item.protonVersion!!) } + }, + onDelete = { + if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) } + else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) } + }, + ) + } + } + + val storedNames = snapshot.packages.map { it.file }.toSet() + val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames } + SettingsGroup("Available to download") { + if (available.isEmpty()) Text( + if (catalogAt == 0L) "Tap the refresh button to list the packages on the Nightlies." else "Nothing new from the last check. Tap the refresh button to look for newer releases.", + fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), + ) else for (d in available) { + SettingsRow(d.file.removeSuffix(".wcp"), "${d.release} · " + String.format("%.1f MB", d.size / 1048576.0)) { + val pr = downloads[d.file] + if (pr == null) SecondaryButton("Download", enabled = busy == null) { onDownload(d) } + else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) { + Text(if (pr < 0) "…" else "$pr%", fontSize = 12.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + if (pr < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + else LinearProgressIndicator(progress = { pr / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) { + SecondaryButton("Import .wcp", onClick = onImport) + val currentOriginal = originals.any { it.protonVersion == currentBuild } + if (!activeIsOriginal && currentOriginal) FocusText("Restore this Proton's original", pal.signal) { + ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(currentBuild) } + } + } + } + + confirm?.let { (body, action) -> + AlertDialog( + onDismissRequest = { confirm = null }, + title = { Text(confirmTitle) }, + text = { Text(body, fontSize = 13.sp) }, + // Opens on Cancel, so a stray A press on a controller never swaps or deletes anything. + confirmButton = { FocusText(if (confirmTitle.startsWith("Delete")) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", + if (confirmTitle.startsWith("Delete")) colors.error else pal.signal) { confirm = null; action() } }, + dismissButton = { + val cancelFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } + FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null } + }, + ) + } +} + +@Composable +private fun CompTab(text: String, selected: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent) + .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) + .controllerConfirm(onClick = onClick), + ) { + Text( + text, fontSize = 14.sp, fontWeight = FontWeight.Bold, + color = if (selected) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + Box(Modifier.width(48.dp).height(3.dp).clip(RoundedCornerShape(2.dp)).background(if (selected) pal.signal else Color.Transparent)) + } +} + +@Composable +private fun ComponentRow(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(10.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) + .border(2.dp, if (hot) pal.signal else Color.Transparent, shape), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f).then(modifier) + .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) + .controllerConfirm(onClick = onSelect) + .padding(horizontal = 11.dp, vertical = 8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape), + ) { if (item.selected) Box(Modifier.size(10.dp).background(pal.signal, CircleShape)) } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), + ) + Text( + item.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, + color = if (item.tag == "ORIGINAL") Color(0xFFF2C66D) else colors.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)) + .background(if (item.tag == "ORIGINAL") Color(0x22F2C66D) else Color.White.copy(alpha = 0.07f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Text(item.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (item.removable) { + val delSrc = remember { MutableInteractionSource() } + val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(end = 6.dp).size(38.dp).clip(RoundedCornerShape(9.dp)) + .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(9.dp)) + .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) + .controllerConfirm(onClick = onDelete), + ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index 6f75b9b67..aaeb90bbd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 7d7ed8444..681af24aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index f15d0cc49..da5aef9ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material3.Icon @@ -91,18 +92,19 @@ import androidx.compose.ui.text.style.TextOverflow import com.droiddeck.launcher.core.FexPreset import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenMode import kotlinx.coroutines.flow.collect -private val drawerPageTitles = listOf("Display", "Controls", "Settings") +private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings") /** One icon per drawer page, in page order (QAM-style tabs). */ -private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Settings) -private val drawerPageEntries = listOf("hud", "touch", "suspend") +private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings) +private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend") private class DrawerFocus { private val requesters = HashMap() - private val last = arrayOfNulls(3) + private val last = arrayOfNulls(DRAWER_PAGES) var focused by mutableStateOf(null) private set @@ -240,6 +242,12 @@ class DrawerActions( val onShareLogs: () -> Unit, val onStop: () -> Unit, val onClose: () -> Unit, + /** Components tab: every Proton with what it uses; null until first read. */ + val components: ComponentsManager.Snapshot? = null, + /** Re-reads the Protons (and runs swaps that waited for a game to close). */ + val onComponentsRefresh: () -> Unit = {}, + /** Swaps [value] ("orig:" or a stored package file) into a Proton's component. */ + val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> }, ) @OptIn(ExperimentalComposeUiApi::class) @@ -251,7 +259,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } - val pageScroll = remember { List(3) { ScrollState(0) } } + val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } val inputModeManager = LocalInputModeManager.current @@ -261,6 +269,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } BackHandler(enabled = open && confirmStop) { confirmStop = false } LaunchedEffect(page) { host.open = null; appToChooseDisplay = null } + LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() } LaunchedEffect(open, controllerActive) { if (open && !controllerActive) focusManager.clearFocus(force = true) } @@ -393,11 +402,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), ) { DrawerOutlineButton("LB ‹", modifier = Modifier.height(48.dp).then(focus.track(page, "prev"))) { - host.open = null; onPageChange((page + 2) % 3) + host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES) } DrawerPageTabs(page = page, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) } DrawerOutlineButton("› RB", modifier = Modifier.height(48.dp).then(focus.track(page, "next"))) { - host.open = null; onPageChange((page + 1) % 3) + host.open = null; onPageChange((page + 1) % DRAWER_PAGES) } } @@ -430,6 +439,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } + DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) } 1 -> { SettingsGroup("Controls") { ChoiceRow(host, "touch", "Touch", null, @@ -676,6 +686,75 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: ( } } +/** Display, Controls, Components, Settings. */ +const val DRAWER_PAGES = 4 +private const val DRAWER_PAGE_COMPONENTS = 2 + +/** + * The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap + * into the Proton a running game uses waits until that game closes; downloading, importing and + * deleting stay on the Components page in the app. + */ +@Composable +private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { + val colors = MaterialTheme.colorScheme + val snap = a.components + if (snap == null) { + SettingsGroup("Components") { + Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp)) + } + return + } + val running = snap.protons.filter { it.inUseByGame } + var pick by rememberSaveable { mutableStateOf(null) } + val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull() + SettingsGroup("Running now") { + if (running.isEmpty()) SettingsRow("No game is running on a Proton", "Swaps apply the next time a game starts.") {} + else for (r in running) { + val c = r.components + SettingsRow( + r.proton.name, + ComponentsManager.COMPONENTS.joinToString(" · ") { "${ComponentsManager.LABEL[it]} ${c.getValue(it).inUse}" }, + ) {} + } + } + if (view == null) { + SettingsGroup("Components") { + Text("No Proton is installed yet.", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp)) + } + return + } + val p = view.proton + SettingsGroup("Components") { + ChoiceRow( + host, "cmp-proton", "Proton", p.version + if (view.inUseByGame) " · in use by the running game" else "", + snap.protons.map { it.proton.id to it.proton.name }, p.id, + chipModifier = track("cmp-proton"), onPick = { pick = it }, + ) + val build = ComponentsManager.safeName(p.version) + for (comp in ComponentsManager.COMPONENTS) { + val st = view.components.getValue(comp) + val originals = view.originals.filter { it.comp == comp } + val options = originals.map { "orig:${it.protonVersion}" to (if (it.protonVersion == build) "Original" else "Original · ${it.protonVersion}") } + + snap.packages.filter { it.comp == comp }.map { it.file to it.version } + if (options.isEmpty()) continue + val current = st.activeFile ?: "orig:$build" + ChoiceRow( + host, "cmp-$comp", ComponentsManager.LABEL.getValue(comp), + st.queued?.let { "Next: $it · after the game closes" } ?: st.inUse, + options, current, + note = if (view.inUseByGame) "A game is running on this Proton: the change waits until it closes." else "Applies the next time a game starts.", + chipModifier = track("cmp-$comp"), + onPick = { v -> if (v != current || st.queued != null) a.onComponentSwap(p.id, comp, v) }, + ) + } + } + Text( + "Downloads, importing and deleting are on the Components page in the app.", + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp), + ) +} + /** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */ private const val DRAWER_TAB_SPACING_DP = 64f private const val DRAWER_TAB_LEAN_DP = 12f