From 715032062d6246d4ed4fb30ba131191e9cec2a31 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sun, 11 Oct 2026 15:32:07 -0400 Subject: [PATCH] feat: DroidDeck-Proton as a Proton source, and update checks for every source Setup > Proton versions lists DroidDeck-Proton (The412Banner/DroidDeck-Proton) beside GE-Proton and proton-cachyos: same aarch64 tarball pattern as GE, verified against its sha512 file, installed by droiddeck-proton-extra (also as "droiddeck" in the session queue) and adopted by steam-compatibility like any other extra, so it shows as "DroidDeck-Proton (Compatible)". Its folder has a space; nothing in the install, adopt, esync or components paths splits on it. Update checks: each source's newest full release is looked up at app start and when the page opens, at most every 6 h (offline keeps the last answer). A newer release shows "Update available -> " with Update; it installs beside the old build, which stays until removed ("Remove old ... versions"), since games are mapped to it by name. Installed builds are ordered by their version numbers (11-9 before 11-10), not by name, and the tag each was installed from is recorded. Prereleases and drafts are skipped. --- .../com/droiddeck/launcher/MainActivity.kt | 6 +- .../java/com/droiddeck/launcher/ProtonMenu.kt | 27 ++- .../launcher/session/ProtonExtras.kt | 172 ++++++++++++++--- .../droiddeck/launcher/ui/ComponentsPage.kt | 55 ++++-- app/src/main/res/values/strings.xml | 11 +- .../launcher/session/ProtonExtrasTest.kt | 179 ++++++++++++++++++ .../usr/local/bin/droiddeck-proton-extra | 20 +- .../overlay/usr/local/bin/droiddeck-session | 6 +- tools/tests/test_esync_packs.py | 19 ++ tools/tests/test_game_environment.py | 34 ++++ tools/tests/test_proton_extra.py | 107 +++++++++++ 11 files changed, 575 insertions(+), 61 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt create mode 100644 tools/tests/test_proton_extra.py diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 58d8599f5..187c7f54a 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -994,6 +994,8 @@ class MainActivity : ComponentActivity() { updates.onResume() // Auto keeps this GPU's recommended driver pair installed and set: once per app start. drivers.ensureAuto(force = false) + // Newer GE-Proton / proton-cachyos / DroidDeck-Proton releases: looked up every few hours at most. + protons.checkUpdates() // 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({ @@ -1145,6 +1147,7 @@ class MainActivity : ComponentActivity() { components.refreshComponents(snapshotFirst = true) drivers.refreshDrivers() protons.refreshProtons() + protons.checkUpdates() } private fun importComponent(uri: Uri) { @@ -1203,7 +1206,8 @@ class MainActivity : ComponentActivity() { protonActions = com.droiddeck.launcher.ui.ProtonsActions( onInstall = { id -> protons.installProton(id) }, onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, - onRemove = { id -> protons.removeProton(id) }, + onRemove = { id, dir -> protons.removeProton(id, dir) }, + onRemoveOlder = { id -> protons.removeProton(id, older = true) }, ), protonOptions = { com.droiddeck.launcher.ui.ProtonOptions( diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt index 8bf520818..e73babbc1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -10,14 +10,16 @@ import com.droiddeck.launcher.ui.ProtonRow import com.droiddeck.launcher.session.SessionState /** - * The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or - * removal in progress. The launcher screen keeps one and shows it. + * The optional ARM64 Protons (GE-Proton, proton-cachyos, DroidDeck-Proton): the rows the page lists, + * an install or removal in progress, and whether a newer release is out. The launcher screen keeps + * one and shows it. */ internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) { var protonRows by mutableStateOf>(emptyList()) var protonBusyId by mutableStateOf(null) var protonStage by mutableStateOf(null) var protonPercent by mutableIntStateOf(-1) + private val checking = java.util.concurrent.atomic.AtomicBoolean(false) fun installProton(id: String) { val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return @@ -40,14 +42,15 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va }, "install-proton-$id").start() } - fun removeProton(id: String) { + /** Removes the build in [dir], or with [older] every build of it but the newest. */ + fun removeProton(id: String, dir: String? = null, older: Boolean = false) { val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return if (protonBusyId != null || SessionState.running) return protonBusyId = id - protonStage = activity.getString(R.string.main_removing_named, tool.name) + protonStage = activity.getString(R.string.main_removing_named, dir ?: tool.name) protonPercent = -1 Thread({ - ProtonExtras.remove(activity, tool) + if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir) ui.post { protonBusyId = null protonStage = null @@ -57,6 +60,18 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va } fun refreshProtons() { - protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) } + protonRows = ProtonExtras.tools.map { + val all = ProtonExtras.installedAll(activity, it) + ProtonRow(it.id, it.name, all.lastOrNull(), ProtonExtras.queued(activity, it), all, ProtonExtras.update(activity, it)) + } + } + + /** Looks for newer releases, at most every few hours (see ProtonExtras.checkLatest); quiet offline. */ + fun checkUpdates(force: Boolean = false) { + if (!checking.compareAndSet(false, true)) return + Thread({ + val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) } + if (asked) ui.post { refreshProtons() } + }, "proton-updates").start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index 268210888..c51cf01f4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -10,12 +10,15 @@ import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.runtime.LinuxRuntime import java.io.File import org.json.JSONArray +import org.json.JSONObject /** Immediate download and installation of the optional ARM64 Proton builds. */ object ProtonExtras { private const val TAG = "ProtonExtras" private const val NEED_BYTES = 4L * 1024 * 1024 * 1024 private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15" + /** How long a release check stands before the next one goes to GitHub. */ + const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000 @Volatile var installInProgress = false private set @@ -23,23 +26,122 @@ object ProtonExtras { class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex) /** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */ - private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long, + internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long, val sha256: String? = null) val tools = listOf( Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")), Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")), + Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")), ) private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root") private fun requests(context: Context) = File(home(context), ".bl-proton-extra") private fun toolsDir(context: Context) = File(home(context), ".local/share/Steam/compatibilitytools.d") + /** The release tag each build was installed from, by directory name. */ + private fun releasesFile(context: Context) = File(context.filesDir, "proton-extras.json") + /** The newest release seen per source, and when it was looked up. */ + private fun latestFile(context: Context) = File(context.filesDir, "proton-latest.json") - /** The installed build's directory name (e.g. GE-Proton11-7), or null. */ - fun installed(context: Context, tool: Tool): String? = - toolsDir(context).listFiles() + /** The installed build's directory name (e.g. GE-Proton11-7), the newest when there are several, or null. */ + fun installed(context: Context, tool: Tool): String? = installedIn(toolsDir(context), tool).lastOrNull() + + /** Every installed build's directory name, oldest first. */ + fun installedAll(context: Context, tool: Tool): List = installedIn(toolsDir(context), tool) + + internal fun installedIn(dir: File, tool: Tool): List = + dir.listFiles() ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile } - ?.maxByOrNull { it.name }?.name + ?.map { it.name } + ?.sortedWith { a, b -> compareVersions(a, b) } + ?: emptyList() + + // Architecture names carry digits (aarch64, x86_64) that are not part of a version. + private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64") + private val NUMBER = Regex("\\d+") + + /** The numbers in a release tag or build folder, in order: GE-Proton11-10 is 11, 10. */ + internal fun versionKey(name: String): List = + NUMBER.findAll(ARCH.replace(name, " ")).mapNotNull { it.value.toLongOrNull() }.toList() + + /** Orders by those numbers, so 11-10 is after 11-9; the name itself only breaks a tie. */ + internal fun compareVersions(a: String, b: String): Int { + val x = versionKey(a) + val y = versionKey(b) + for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return x[i].compareTo(y[i]) + return if (x.size != y.size) x.size.compareTo(y.size) else a.compareTo(b) + } + + /** [latest] when it is newer than every installed release, else null. Nothing installed is not an update. */ + internal fun updateFor(installed: List, latest: String?): String? { + if (latest == null || installed.isEmpty()) return null + val newest = installed.maxWithOrNull { a, b -> compareVersions(a, b) } ?: return null + val x = versionKey(latest) + val y = versionKey(newest) + for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return if (x[i] > y[i]) latest else null + return if (x.size > y.size) latest else null + } + + /** The release each installed build came from: the tag recorded at install, else its folder name. */ + fun installedTags(context: Context, tool: Tool): List { + val recorded = readJson(releasesFile(context)) + return installedAll(context, tool).map { recorded.optString(it).ifEmpty { it } } + } + + /** A newer release than the installed one, from the last check (no network), or null. */ + fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool)) + + fun cachedLatest(context: Context, tool: Tool): String? = + readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null } + + /** Its own lock, not the install's: a check must not wait behind a download. */ + private val checkLock = Any() + /** A failed look-up (offline) is not repeated on every resume, only after this long. */ + private const val RETRY_MS = 15L * 60 * 1000 + private var lastAttempt = 0L + + /** + * Looks up each source's newest release unless it was looked up within [CHECK_EVERY_MS] (or + * [force]). Offline or rate limited, the last answer stands. True when anything was asked. + */ + fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean { + synchronized(checkLock) { + val file = latestFile(context) + val cache = readJson(file) + val due = tools.filter { tool -> + val at = cache.optJSONObject(tool.id)?.optLong("at", 0L) ?: 0L + force || (now - at) !in 0 until CHECK_EVERY_MS + } + if (due.isEmpty() || !force && (now - lastAttempt) in 0 until RETRY_MS) return false + lastAttempt = now + for (tool in due) { + val tag = findLatestAsset(tool)?.tag ?: continue + cache.put(tool.id, JSONObject().put("tag", tag).put("at", now)) + } + runCatching { file.writeText(cache.toString()) } + return true + } + } + + private fun record(context: Context, tool: Tool, dir: String?, tag: String) { + runCatching { + val latest = readJson(latestFile(context)) + val known = latest.optJSONObject(tool.id)?.optString("tag") + if (known.isNullOrEmpty() || compareVersions(tag, known) >= 0) { + latest.put(tool.id, JSONObject().put("tag", tag).put("at", System.currentTimeMillis())) + latestFile(context).writeText(latest.toString()) + } + if (dir != null) { + val releases = readJson(releasesFile(context)) + val present = toolsDir(context).list()?.toSet() ?: emptySet() + releases.keys().asSequence().toList().filter { it !in present }.forEach { releases.remove(it) } + releasesFile(context).writeText(releases.put(dir, tag).toString()) + } + }.onFailure { Log.w(TAG, "record ${tool.id} $tag", it) } + } + + private fun readJson(file: File): JSONObject = + runCatching { JSONObject(FileUtils.readString(file) ?: "{}") }.getOrDefault(JSONObject()) /** A request left by an earlier app version, still consumed by the session shim. */ fun queued(context: Context, tool: Tool): Boolean = @@ -52,9 +154,15 @@ object ProtonExtras { else file.writeText(lines.joinToString("\n") + "\n") } - /** Deletes the installed build; Steam discovers its absence on its next start. */ - fun remove(context: Context, tool: Tool) { - installed(context, tool)?.let { File(toolsDir(context), it).deleteRecursively() } + /** Deletes one installed build ([dir], else the newest); Steam discovers its absence on its next start. */ + fun remove(context: Context, tool: Tool, dir: String? = null) { + val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return + File(toolsDir(context), name).deleteRecursively() + } + + /** Deletes every build but the newest. */ + fun removeOlder(context: Context, tool: Tool) { + installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() } } /** Downloads, verifies and registers the latest build without waiting for another session. */ @@ -145,9 +253,12 @@ object ProtonExtras { hostEnv["PROOT_TMP_DIR"] = context.cacheDir.path LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { hostEnv["LD_LIBRARY_PATH"] = it } val child = process.start() + var placed: String? = null child.inputStream.bufferedReader().useLines { lines -> lines.forEach { line -> Log.i(TAG, line) + // "droiddeck-proton-extra: installed ", the folder as the tarball names it. + line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it } if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) { onProgress(installing, -1) } @@ -158,6 +269,7 @@ object ProtonExtras { else { archive.delete() unqueue(context, tool) + record(context, tool, placed, asset.tag) if (EsyncPacks.enabled(context)) { onProgress(context.getString(R.string.esync_fetching), -1) try { @@ -177,32 +289,36 @@ object ProtonExtras { private fun findLatestAsset(tool: Tool): Asset? { val releases = Downloader.downloadString(RELEASES.format(tool.repo)) ?: return null return try { - val array = JSONArray(releases) - for (i in 0 until array.length()) { - val release = array.getJSONObject(i) - if (release.optBoolean("draft", false)) continue - val assets = release.optJSONArray("assets") ?: continue - val entries = (0 until assets.length()).map { assets.getJSONObject(it) } - val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue - val name = archive.optString("name") - val stem = name.substringBefore(".tar") - val checksum = entries.firstOrNull { - it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true) - }?.optString("browser_download_url")?.takeIf { it.startsWith("http") } - return Asset( - release.optString("tag_name"), name, - archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum, - archive.optLong("size", 0L), - Hashes.githubSha256(archive.optString("digest")), - ) - } - null + pickAsset(tool, JSONArray(releases)) } catch (e: Exception) { Log.w(TAG, "release metadata for ${tool.id}", e) null } } + /** The newest full release in GitHub's list (newest first) that carries [tool]'s ARM64 archive. */ + internal fun pickAsset(tool: Tool, array: JSONArray): Asset? { + for (i in 0 until array.length()) { + val release = array.getJSONObject(i) + if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue + val assets = release.optJSONArray("assets") ?: continue + val entries = (0 until assets.length()).map { assets.getJSONObject(it) } + val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue + val name = archive.optString("name") + val stem = name.substringBefore(".tar") + val checksum = entries.firstOrNull { + it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true) + }?.optString("browser_download_url")?.takeIf { it.startsWith("http") } + return Asset( + release.optString("tag_name"), name, + archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum, + archive.optLong("size", 0L), + Hashes.githubSha256(archive.optString("digest")), + ) + } + return null + } + private fun requestLines(context: Context): List = FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index e80156695..7eb708117 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -316,7 +316,7 @@ fun ComponentsPage( val availableTitle: @Composable (String) -> String = { l -> when (l) { GPU_TAB -> stringResource(R.string.comp_available_count, gpu.pairs.count { !it.installed && it.complete && (it.suits || showAllPairs) }) - PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null }) + PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null || it.update != null }) else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size) } } @@ -355,7 +355,7 @@ fun ComponentsPage( views.forEachIndexed { i, v -> val key = "proton:${v.proton.id}" val swaps = v.components.values.count { it.activeFile != null } - val extra = protons.rows.firstOrNull { it.installed == v.proton.dir.name } + val extra = protons.rows.firstOrNull { v.proton.dir.name in it.installedAll } val removable = extra != null && protons.busyId == null && protons.runtimeReady && !protons.sessionRunning ChoiceLine( v.proton.name, @@ -365,8 +365,8 @@ fun ComponentsPage( modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt), onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } }, onDelete = { - if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), "", ctx.getString(R.string.store_remove), true, v.proton.name) { - protonActions.onRemove(extra.id) + if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), ctx.getString(R.string.proton_remove_body), ctx.getString(R.string.store_remove), true, v.proton.name) { + protonActions.onRemove(extra.id, v.proton.dir.name) } }, ) @@ -416,14 +416,22 @@ fun ComponentsPage( } } PROTON_TAB -> { - val rows = protons.rows.filter { it.installed == null } + // Not installed, and installed with a newer release out: an update goes in beside the + // build it replaces, which stays until it is removed (games are mapped to it by name). + val rows = protons.rows.filter { it.installed == null || it.update != null } if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_new)) for (row in rows) GetLine( - row.name, if (row.queued) stringResource(R.string.proton_queued) else stringResource(R.string.proton_not_installed), + row.name, + when { + row.update != null -> stringResource(R.string.proton_update_available, row.update) + row.queued -> stringResource(R.string.proton_queued) + else -> stringResource(R.string.proton_not_installed) + }, progress = if (protons.busyId == row.id) protons.percent else null, stage = if (protons.busyId == row.id) protons.stage else null, enabled = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning, onCancel = if (row.queued && protons.busyId == null && !protons.sessionRunning) ({ protonActions.onCancel(row.id) }) else null, + action = if (row.update != null) stringResource(R.string.common_update) else null, ) { protonActions.onInstall(row.id) } } else -> { @@ -461,7 +469,21 @@ fun ComponentsPage( } } } - PROTON_TAB -> {} + PROTON_TAB -> { + val idle = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning + for (row in protons.rows.filter { it.installedAll.size > 1 }) { + val key = "older:${row.id}" + val older = row.installedAll.dropLast(1) + Box(Modifier.trackRow(key, rowsAt)) { + FocusText(stringResource(R.string.proton_remove_older, row.name), if (idle) pal.signal else colors.onSurfaceVariant) { + if (!idle) return@FocusText + ask(key, false, ctx.getString(R.string.proton_remove_older, row.name) + "?", + ctx.getString(R.string.proton_remove_older_body, older.joinToString(", ")), + ctx.getString(R.string.store_remove), true, row.name) { protonActions.onRemoveOlder(row.id) } + } + } + } + } else -> { SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport) val st = compState(l) @@ -908,7 +930,7 @@ private fun ChoiceLine( @Composable private fun GetLine( name: String, detail: String, progress: Int?, enabled: Boolean, - badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, onGet: () -> Unit, + badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, action: String? = null, onGet: () -> Unit, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current @@ -923,7 +945,7 @@ private fun GetLine( if (progress != null) ProgressBit(progress) else { if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel) - SmallButton(stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) + SmallButton(action ?: stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) @@ -1014,8 +1036,17 @@ class ProtonsState( class ProtonsActions( val onInstall: (String) -> Unit = {}, val onCancel: (String) -> Unit = {}, - val onRemove: (String) -> Unit = {}, + /** The source's id and the installed folder to delete. */ + val onRemove: (String, String) -> Unit = { _, _ -> }, + /** Deletes every build of the source but the newest. */ + val onRemoveOlder: (String) -> Unit = {}, ) -/** A Proton DroidDeck can install, and the folder it is installed as (null when it is not). */ -class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean) +/** + * A Proton DroidDeck can install: the folder its newest build is installed as (null when it is + * not), every installed build oldest first, and a newer release's tag when one is out. + */ +class ProtonRow( + val id: String, val name: String, val installed: String?, val queued: Boolean, + val installedAll: List = listOfNotNull(installed), val update: String? = null, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4537ab369..d621d8310 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,7 +431,7 @@ Applies the next time a game starts Import .zip Reading Protons… - No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Setup. + No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton, CachyOS or DroidDeck-Proton from Setup. Original · %1$s %1$s · this build %1$s · earlier build @@ -1029,6 +1029,13 @@ Installed %1$s Pending from an earlier request Not installed + + Update available → %1$s + + Remove old %1$s versions + + Removes %1$s. Games set to these versions must be switched to another Proton. + Games set to this version must be switched to another Proton. Install now Cancel queued Android keyboard @@ -1758,7 +1765,7 @@ Downloading the Steam client · %1$s - + A compatibility tool install is already running Stop the active session before installing compatibility tools Could not find an ARM64 release for %1$s diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt new file mode 100644 index 000000000..e551d3052 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt @@ -0,0 +1,179 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ProtonExtrasTest { + @get:Rule val temp = TemporaryFolder() + + private fun tool(id: String) = ProtonExtras.tools.single { it.id == id } + + private fun asset(name: String, digest: String? = null) = JSONObject() + .put("name", name) + .put("browser_download_url", "https://github.com/o/r/releases/download/t/$name") + .put("size", 625_775_670L) + .apply { if (digest != null) put("digest", digest) } + + private fun release(tag: String, vararg assets: JSONObject, prerelease: Boolean = false, draft: Boolean = false) = JSONObject() + .put("tag_name", tag).put("prerelease", prerelease).put("draft", draft) + .put("assets", JSONArray().apply { assets.forEach { put(it) } }) + + private fun droiddeckRelease(ver: String, prerelease: Boolean = false) = release( + "DroidDeck-Proton-$ver", + asset("DroidDeck-Proton-$ver-aarch64.build-info.json"), + asset("DroidDeck-Proton-$ver-aarch64.sha512sum"), + asset("DroidDeck-Proton-$ver-aarch64.tar.gz"), + prerelease = prerelease, + ) + + private fun installTool(parent: File, name: String) = File(parent, name).apply { + mkdirs() + File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + } + + @Test + fun theThreeSourcesInOrder() { + assertEquals(listOf("ge", "cachyos", "droiddeck"), ProtonExtras.tools.map { it.id }) + val droiddeck = tool("droiddeck") + assertEquals("DroidDeck-Proton", droiddeck.name) + assertEquals("DroidDeck-Proton", droiddeck.prefix) + assertEquals("The412Banner/DroidDeck-Proton", droiddeck.repo) + assertTrue(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.tar.gz")) + assertTrue(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-8-aarch64.tar.xz")) + assertFalse(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.sha512sum")) + assertFalse(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.build-info.json")) + assertFalse(tool("cachyos").assetPattern.containsMatchIn("proton-cachyos-11.0-20261005-slr-x86_64.tar.xz")) + } + + @Test + fun theTarballAndItsSha512FileArePicked() { + val picked = ProtonExtras.pickAsset(tool("droiddeck"), JSONArray().put(droiddeckRelease("11-7.5")))!! + assertEquals("DroidDeck-Proton-11-7.5", picked.tag) + assertEquals("DroidDeck-Proton-11-7.5-aarch64.tar.gz", picked.name) + assertEquals("https://github.com/o/r/releases/download/t/DroidDeck-Proton-11-7.5-aarch64.sha512sum", picked.sha512) + assertEquals(625_775_670L, picked.size) + assertNull(picked.sha256) + } + + @Test + fun githubsDigestIsKeptBesideTheSha512File() { + val sum = "ab".repeat(32) + val release = release("GE-Proton11-7", asset("GE-Proton11-7-x86_64.tar.gz"), asset("GE-Proton11-7-aarch64.sha512sum"), + asset("GE-Proton11-7-aarch64.tar.gz", "sha256:$sum")) + val picked = ProtonExtras.pickAsset(tool("ge"), JSONArray().put(release))!! + assertEquals("GE-Proton11-7-aarch64.tar.gz", picked.name) + assertEquals(sum, picked.sha256) + assertTrue(picked.sha512!!.endsWith("GE-Proton11-7-aarch64.sha512sum")) + } + + @Test + fun prereleasesDraftsAndReleasesWithoutAnArm64BuildAreSkipped() { + val releases = JSONArray() + .put(droiddeckRelease("11-8", prerelease = true)) + .put(release("DroidDeck-Proton-11-7.9", asset("DroidDeck-Proton-11-7.9-aarch64.tar.gz"), draft = true)) + .put(release("DroidDeck-Proton-11-7.8", asset("DroidDeck-Proton-11-7.8-x86_64.tar.gz"))) + .put(droiddeckRelease("11-7.5")) + assertEquals("DroidDeck-Proton-11-7.5", ProtonExtras.pickAsset(tool("droiddeck"), releases)?.tag) + assertNull(ProtonExtras.pickAsset(tool("droiddeck"), JSONArray().put(droiddeckRelease("11-8", prerelease = true)))) + } + + @Test + fun versionsCompareByTheirNumbers() { + val sorted = listOf("GE-Proton11-10", "GE-Proton10-25", "GE-Proton11-9", "GE-Proton11-1") + .sortedWith { a, b -> ProtonExtras.compareVersions(a, b) } + assertEquals(listOf("GE-Proton10-25", "GE-Proton11-1", "GE-Proton11-9", "GE-Proton11-10"), sorted) + assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10") < 0) + assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7.10", "DroidDeck-Proton 11-8") < 0) + assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7", "DroidDeck-Proton 11-7.1") < 0) + assertTrue(ProtonExtras.compareVersions("cachyos-11.0-20260703-slr", "cachyos-11.0-20261005-slr") < 0) + assertTrue(ProtonExtras.compareVersions("cachyos-10.0-sunset-slr", "cachyos-11.0-20260601-slr") < 0) + // The architecture in a folder name is not part of its version. + assertEquals(listOf(11L, 7L), ProtonExtras.versionKey("GE-Proton11-7-aarch64")) + assertEquals(listOf(11L, 0L, 20261005L), ProtonExtras.versionKey("proton-cachyos-11.0-20261005-slr-arm64")) + assertEquals(listOf(11L, 7L, 5L), ProtonExtras.versionKey("DroidDeck-Proton 11-7.5")) + assertEquals(ProtonExtras.versionKey("DroidDeck-Proton-11-7.5"), ProtonExtras.versionKey("DroidDeck-Proton 11-7.5")) + } + + @Test + fun installedBuildsAreFoundByPrefixNewestLast() { + val tools = temp.newFolder("compatibilitytools.d") + for (name in listOf("GE-Proton11-10", "GE-Proton11-9", "DroidDeck-Proton 11-7.10", "DroidDeck-Proton 11-7.5", + "droiddeck-proton-arm64", "droiddeck-proton-11-arm64", "proton-cachyos-11.0-20261005-slr-arm64")) installTool(tools, name) + File(tools, "DroidDeck-Proton 11-6").mkdirs() // no manifest: half unpacked, not installed + assertEquals(listOf("GE-Proton11-9", "GE-Proton11-10"), ProtonExtras.installedIn(tools, tool("ge"))) + // Ours, not DroidDeck's own droiddeck-proton-* tools: the prefix is matched with its case. + assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedIn(tools, tool("droiddeck"))) + assertEquals(listOf("proton-cachyos-11.0-20261005-slr-arm64"), ProtonExtras.installedIn(tools, tool("cachyos"))) + assertEquals(emptyList(), ProtonExtras.installedIn(File(tools, "missing"), tool("ge"))) + } + + @Test + fun anUpdateIsANewerReleaseThanTheNewestInstalledBuild() { + assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.6")) + assertEquals("DroidDeck-Proton-11-7.10", ProtonExtras.updateFor(listOf("DroidDeck-Proton-11-7.9"), "DroidDeck-Proton-11-7.10")) + assertNull(ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.5")) + assertNull(ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton-11-7.6"), "DroidDeck-Proton-11-7.6")) + assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-10"), "GE-Proton11-9")) + assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-7-aarch64"), "GE-Proton11-7")) + assertEquals("GE-Proton11-10", ProtonExtras.updateFor(listOf("GE-Proton11-7-aarch64", "GE-Proton11-9"), "GE-Proton11-10")) + assertEquals("cachyos-11.0-20261101-slr", ProtonExtras.updateFor(listOf("proton-cachyos-11.0-20261005-slr-arm64"), "cachyos-11.0-20261101-slr")) + assertNull(ProtonExtras.updateFor(listOf("proton-cachyos-11.0-20261005-slr-arm64"), "cachyos-10.0-sunset-slr")) + assertNull(ProtonExtras.updateFor(emptyList(), "GE-Proton11-10")) + assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-7"), null)) + } + + @Test + fun aRecentCheckIsReusedAndComparedWithTheInstalledBuild() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + installTool(tools, "DroidDeck-Proton 11-7.5") + installTool(tools, "GE-Proton11-7") + val now = 1_800_000_000_000L + val latest = JSONObject() + .put("ge", JSONObject().put("tag", "GE-Proton11-7").put("at", now - 60_000)) + .put("cachyos", JSONObject().put("tag", "cachyos-11.0-20261005-slr").put("at", now - 60_000)) + .put("droiddeck", JSONObject().put("tag", "DroidDeck-Proton-11-7.6").put("at", now - ProtonExtras.CHECK_EVERY_MS + 60_000)) + File(context.filesDir, "proton-latest.json").writeText(latest.toString()) + // Every source was looked up within the last six hours: nothing goes to GitHub. + assertFalse(ProtonExtras.checkLatest(context, now = now)) + assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.update(context, tool("droiddeck"))) + assertNull(ProtonExtras.update(context, tool("ge"))) + assertNull(ProtonExtras.update(context, tool("cachyos"))) + assertEquals(listOf("DroidDeck-Proton 11-7.5"), ProtonExtras.installedTags(context, tool("droiddeck"))) + // The tag recorded at install stands for the folder. + File(context.filesDir, "proton-extras.json").writeText(JSONObject().put("DroidDeck-Proton 11-7.5", "DroidDeck-Proton-11-7.6").toString()) + assertEquals(listOf("DroidDeck-Proton-11-7.6"), ProtonExtras.installedTags(context, tool("droiddeck"))) + assertNull(ProtonExtras.update(context, tool("droiddeck"))) + } + + @Test + fun removingDeletesOneBuildOrEveryOlderOne() { + val context = RuntimeEnvironment.getApplication() + val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d") + for (name in listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.6", "DroidDeck-Proton 11-7.10", "GE-Proton11-7")) installTool(tools, name) + ProtonExtras.remove(context, tool("droiddeck"), "DroidDeck-Proton 11-7.6") + assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) + // A folder of another source is never removed through this one. + ProtonExtras.remove(context, tool("droiddeck"), "GE-Proton11-7") + assertTrue(File(tools, "GE-Proton11-7").isDirectory) + assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) + ProtonExtras.removeOlder(context, tool("droiddeck")) + assertEquals(listOf("DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck"))) + assertEquals("DroidDeck-Proton 11-7.10", ProtonExtras.installed(context, tool("droiddeck"))) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra index 415e49a18..68997cc5e 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra @@ -4,14 +4,15 @@ # # droiddeck-proton-extra ge [--tag GE-Proton11-7] # droiddeck-proton-extra cachyos [--tag cachyos-11.0-20260703-slr] +# droiddeck-proton-extra droiddeck [--tag DroidDeck-Proton-11-7.5] # droiddeck-proton-extra # droiddeck-proton-extra /path/to/an/aarch64/tarball # droiddeck-proton-extra --list what is installed now # -# Both projects publish native ARM64 builds - GloriousEggroll as "*-aarch64.tar.gz", CachyOS as -# "*-arm64.tar.xz" - and both are ordinary Proton trees: a python entry point beside a files/ -# directory holding Wine, DXVK and VKD3D. That is the same shape as the depot the Steam client -# downloads, so the runtime can run them the same way. +# All three projects publish native ARM64 builds - GloriousEggroll and DroidDeck-Proton as +# "*-aarch64.tar.gz", CachyOS as "*-arm64.tar.xz" - and all are ordinary Proton trees: a python +# entry point beside a files/ directory holding Wine, DXVK and VKD3D. That is the same shape as the +# depot the Steam client downloads, so the runtime can run them the same way. # # What it cannot use is their toolmanifest. Like Valve's ARM64 depot they declare # require_tool_appid, which makes Steam stack pressure-vessel underneath - and pressure-vessel @@ -30,11 +31,12 @@ import urllib.request RELEASES = "https://api.github.com/repos/%s/releases" # Each source: the GitHub repo, and how its ARM64 asset is named. Ordered newest-release-first by -# the API, so "latest usable" is the first release that actually carries an ARM64 asset - the +# the API, so "latest usable" is the first full release that actually carries an ARM64 asset - the # projects do not build one for every tag. SOURCES = { "ge": ("GloriousEggroll/proton-ge-custom", re.compile(r"aarch64\.tar\.(gz|xz)$")), "cachyos": ("CachyOS/proton-cachyos", re.compile(r"arm64\.tar\.(gz|xz)$")), + "droiddeck": ("The412Banner/DroidDeck-Proton", re.compile(r"aarch64\.tar\.(gz|xz)$")), } USER_AGENT = "droiddeck-proton-extra" # Room for the tarball and the tree it unpacks to. GE-Proton's aarch64 build is ~650 MB packed and @@ -54,10 +56,10 @@ def fetch_json(url): def pick_asset(source): - """(release tag, asset url, sha512 url or None) for the newest release with an ARM64 build.""" + """(release tag, asset url, sha512 url or None) for the newest full release with an ARM64 build.""" repo, pattern = SOURCES[source] for release in fetch_json(RELEASES % repo + "?per_page=15"): - if release.get("draft"): + if release.get("draft") or release.get("prerelease"): continue assets = {a["name"]: a["browser_download_url"] for a in release.get("assets", ())} for name in sorted(assets): @@ -144,7 +146,7 @@ def installed(tools): def main(): argv = sys.argv[1:] if not argv: - raise SystemExit(__doc__ or "usage: droiddeck-proton-extra ge|cachyos|") + raise SystemExit(__doc__ or "usage: droiddeck-proton-extra ge|cachyos|droiddeck|") steam_root = argv.pop(0) tools = os.path.join(steam_root, "compatibilitytools.d") os.makedirs(tools, exist_ok=True) @@ -176,7 +178,7 @@ def main(): log("using %s" % what) else: raise SystemExit( - "droiddeck-proton-extra: expected ge, cachyos, a URL or a tarball path, got %r" % what) + "droiddeck-proton-extra: expected ge, cachyos, droiddeck, a URL or a tarball path, got %r" % what) free = shutil.disk_usage(tools).free if free < NEED_BYTES: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 94d9a6937..b06664260 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -376,9 +376,9 @@ case $mode in # its main library already - and the ARM64 Proton that lets it launch the Windows ones. # A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts, # so it is in the Compatibility dropdown the first time the user looks. One per line, each the - # arguments to droiddeck-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL. - # Hundreds of megabytes each, so it is only ever done on request, and the line is dropped once - # it has succeeded - a failed download stays and is retried next session. + # arguments to droiddeck-proton-extra: "ge", "cachyos", "droiddeck", "ge --tag GE-Proton11-7", + # or a URL. Hundreds of megabytes each, so it is only ever done on request, and the line is + # dropped once it has succeeded - a failed download stays and is retried next session. if [ -s "$HOME/.bl-proton-extra" ]; then _bl_left=$HOME/.bl-proton-extra.left : > "$_bl_left" diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py index a793f1bee..e836eaad1 100644 --- a/tools/tests/test_esync_packs.py +++ b/tools/tests/test_esync_packs.py @@ -746,6 +746,16 @@ class SelectTest(SyncTestCase): self.assertFalse((root / "wanted.tsv").exists()) self.assertFalse((root / "dist").exists()) + def test_droiddeck_proton_has_esync_built_in(self): + root = self.store() + tool = make_tool(self.tools, "DroidDeck-Proton 11-7.5", "1791000000 DroidDeck-Proton-11-7.5", builtin=True, ge=True, fsync=True) + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertNotIn("BL_SYNC_PACK", env) + self.assertEqual(said, "droiddeck-esync: DroidDeck-Proton 11-7.5 has esync built in\n") + self.assertFalse((root / "wanted.tsv").exists()) + self.assertFalse((root / "dist").exists()) + def test_wanted_rows_are_deduplicated(self): root = self.store() tool = make_tool(self.tools, "GE-Proton11-7", GE_LINE, ge=True) @@ -906,6 +916,15 @@ class ReconcileTest(SyncTestCase): self.assertTrue(self.dist_of("ge-GE-Proton11-7-r1", ge).is_dir()) self.assertIn("ge-GE-Proton11-7-r1", (root / "tools.tsv").read_text()) + def test_droiddeck_proton_is_scanned_but_our_own_tools_are_not(self): + steam = self.tmp / "steam" + tools = steam / "compatibilitytools.d" + ours = make_tool(tools, "DroidDeck-Proton 11-7.5", "1791000000 DroidDeck-Proton-11-7.5", salt=b"dd", builtin=True, ge=True) + make_tool(tools, "droiddeck-proton-arm64", salt=b"own") + make_tool(tools, "droiddeck-proton-11-arm64", salt=b"own11") + with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}): + self.assertEqual(SYNC["find_tools"](str(steam)), [str(ours)]) + def test_steam_compatibility_reports_sync(self): steam = self.tmp / "steam" make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)") diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 1fa4cd739..817038c7a 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -257,6 +257,40 @@ class GameEnvironmentTest(unittest.TestCase): self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text()) self.assertIn("GE-Proton11-7", [entry["name"] for entry in COMPAT["tool_catalog"](str(home), None)]) + def test_droiddeck_proton_is_adopted_and_labelled_like_any_other_extra(self): + # Its folder has a space ("DroidDeck-Proton 11-7.5") and its internal name starts with + # "DroidDeck-Proton-", which lower-cases to our own tools' prefix: neither may matter. + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + (home / "label").write_text("Compatible\n") + COMPAT["display_name"].__globals__["LABEL_FILE"] = str(home / "label") + tools = home / "compatibilitytools.d" + extra = tools / "DroidDeck-Proton 11-7.5" + extra.mkdir(parents=True) + (extra / "proton").write_text("") + (extra / "toolmanifest.vdf").write_text('"manifest" { "version" "2" "commandline" "/proton %verb%" "require_tool_appid" "4185400" }') + (extra / "compatibilitytool.vdf").write_text( + '"compatibilitytools" { "compat_tools" { "DroidDeck-Proton-11-7.5" { "install_path" "." ' + '"display_name" "DroidDeck-Proton 11-7.5" "from_oslist" "windows" "to_oslist" "linux" } } }') + COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None) + protect = COMPAT["adopt_extras"](str(tools)) + self.assertEqual(protect, {"DroidDeck-Proton-11-7.5": "DroidDeck-Proton 11-7.5"}) + compat = (extra / "compatibilitytool.vdf").read_text() + self.assertIn('"DroidDeck-Proton-11-7.5"', compat) + self.assertIn('"display_name" "DroidDeck-Proton 11-7.5 (Compatible)"', compat) + self.assertNotIn("require_tool_appid", (extra / "toolmanifest.vdf").read_text()) + self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text()) + catalog = COMPAT["tool_catalog"](str(home), None) + self.assertIn({"name": "DroidDeck-Proton-11-7.5", "display": "DroidDeck-Proton 11-7.5 (Compatible)", + "dir": "DroidDeck-Proton 11-7.5", "valve": False}, catalog) + config = home / "config.vdf" + config.write_text(config_text([("0", COMPAT["TOOL"]), ("42", "DroidDeck-Proton-11-7.5")])) + COMPAT["register_default"](str(config), ["42", "43"], protect=protect) + self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5", "43": COMPAT["TOOL"]}) + # Once removed it is no longer runnable, and its games go back to the default. + changes, _ = COMPAT["plan_mapping"]({"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5"}, ["42"], [COMPAT["TOOL"]], COMPAT["TOOL"], {}) + self.assertEqual(changes, {"42": COMPAT["TOOL"]}) + def test_both_proton_wrappers_call_environment_launcher(self): for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"): subprocess.run(["bash", "-n"], input=script, text=True, check=True) diff --git a/tools/tests/test_proton_extra.py b/tools/tests/test_proton_extra.py new file mode 100644 index 000000000..dab3e2c95 --- /dev/null +++ b/tools/tests/test_proton_extra.py @@ -0,0 +1,107 @@ +import contextlib +import hashlib +import io +import os +from pathlib import Path +import runpy +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +EXTRA = runpy.run_path(str(BIN / "droiddeck-proton-extra")) +GLOBALS = EXTRA["pick_asset"].__globals__ + + +def asset(name): + return {"name": name, "browser_download_url": "https://github.com/o/r/releases/download/t/" + name} + + +def droiddeck_release(ver, prerelease=False, draft=False): + stem = "DroidDeck-Proton-%s-aarch64" % ver + return {"tag_name": "DroidDeck-Proton-" + ver, "prerelease": prerelease, "draft": draft, + "assets": [asset(stem + ".build-info.json"), asset(stem + ".sha512sum"), asset(stem + ".tar.gz")]} + + +class ProtonExtraTest(unittest.TestCase): + def test_sources_and_their_arm64_assets(self): + self.assertEqual(list(EXTRA["SOURCES"]), ["ge", "cachyos", "droiddeck"]) + repo, pattern = EXTRA["SOURCES"]["droiddeck"] + self.assertEqual(repo, "The412Banner/DroidDeck-Proton") + self.assertTrue(pattern.search("DroidDeck-Proton-11-7.5-aarch64.tar.gz")) + self.assertTrue(pattern.search("DroidDeck-Proton-11-8-aarch64.tar.xz")) + self.assertFalse(pattern.search("DroidDeck-Proton-11-7.5-aarch64.sha512sum")) + self.assertFalse(pattern.search("DroidDeck-Proton-11-7.5-aarch64.build-info.json")) + + def test_the_newest_full_release_with_its_sha512_file(self): + releases = [droiddeck_release("11-8", prerelease=True), droiddeck_release("11-7.9", draft=True), droiddeck_release("11-7.5")] + with mock.patch.dict(GLOBALS, {"fetch_json": lambda url: releases}): + tag, url, sums = EXTRA["pick_asset"]("droiddeck") + self.assertEqual(tag, "DroidDeck-Proton-11-7.5") + self.assertTrue(url.endswith("/DroidDeck-Proton-11-7.5-aarch64.tar.gz")) + self.assertTrue(sums.endswith("/DroidDeck-Proton-11-7.5-aarch64.sha512sum")) + with mock.patch.dict(GLOBALS, {"fetch_json": lambda url: [droiddeck_release("11-8", prerelease=True)]}): + self.assertEqual(EXTRA["pick_asset"]("droiddeck"), (None, None, None)) + + def test_a_tagged_release_is_fetched_by_its_tag(self): + asked = [] + with mock.patch.dict(GLOBALS, {"fetch_json": lambda url: asked.append(url) or droiddeck_release("11-7.5")}): + tag, url, sums = EXTRA["release_asset"]("droiddeck", "DroidDeck-Proton-11-7.5") + self.assertEqual(asked, ["https://api.github.com/repos/The412Banner/DroidDeck-Proton/releases/tags/DroidDeck-Proton-11-7.5"]) + self.assertTrue(url.endswith(".tar.gz") and sums.endswith(".sha512sum")) + + def test_a_ge_style_sha512_file_verifies_the_download(self): + with tempfile.TemporaryDirectory() as tmp: + tarball = Path(tmp) / "DroidDeck-Proton-11-7.5-aarch64.tar.gz" + tarball.write_bytes(b"tarball") + line = "%s %s\n" % (hashlib.sha512(b"tarball").hexdigest(), tarball.name) + + class Reply(io.BytesIO): + def __exit__(self, *exc): + return False + + with mock.patch.object(GLOBALS["urllib"].request, "urlopen", lambda req, timeout: Reply(line.encode())), \ + contextlib.redirect_stdout(io.StringIO()) as said: + EXTRA["verify"](str(tarball), "https://example/sums") + self.assertIn("checksum ok", said.getvalue()) + self.assertTrue(tarball.exists()) + bad = "%s %s\n" % ("0" * 128, tarball.name) + with mock.patch.object(GLOBALS["urllib"].request, "urlopen", lambda req, timeout: Reply(bad.encode())), \ + contextlib.redirect_stdout(io.StringIO()), self.assertRaises(SystemExit): + EXTRA["verify"](str(tarball), "https://example/sums") + self.assertFalse(tarball.exists()) + + def test_a_top_folder_with_a_space_is_installed_under_its_own_name(self): + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "src" / "DroidDeck-Proton 11-7.5" + (src / "files/bin").mkdir(parents=True) + (src / "proton").write_text("#!/usr/bin/python3\n") + (src / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }') + (src / "compatibilitytool.vdf").write_text( + '"compatibilitytools" { "compat_tools" { "DroidDeck-Proton-11-7.5" { "install_path" "." ' + '"display_name" "DroidDeck-Proton 11-7.5" } } }') + (src / "version").write_text("1791000000 DroidDeck-Proton-11-7.5\n") + tarball = Path(tmp) / "DroidDeck-Proton-11-7.5-aarch64.tar.gz" + with tarfile.open(tarball, "w:gz") as tar: + tar.add(src, arcname=src.name) + steam = Path(tmp) / "Steam" + adopted = [] + real = subprocess.call + # tar runs for real; steam-compatibility (the adoption step) is only recorded. + fake = lambda args, **kw: adopted.append(args) or 0 if args[0] == "steam-compatibility" else real(args, **kw) + with mock.patch.object(GLOBALS["subprocess"], "call", fake), contextlib.redirect_stdout(io.StringIO()) as said: + with mock.patch.object(GLOBALS["sys"], "argv", ["droiddeck-proton-extra", str(steam), str(tarball)]): + EXTRA["main"]() + tools = steam / "compatibilitytools.d" + self.assertEqual(sorted(os.listdir(tools)), [".droiddeck-download", "DroidDeck-Proton 11-7.5"]) + self.assertTrue((tools / "DroidDeck-Proton 11-7.5" / "toolmanifest.vdf").is_file()) + self.assertEqual(adopted, [["steam-compatibility", str(steam)]]) + # The app reads the folder from this line, space and all. + self.assertIn("droiddeck-proton-extra: installed DroidDeck-Proton 11-7.5\n", said.getvalue()) + self.assertEqual(EXTRA["installed"](str(tools)), ["DroidDeck-Proton 11-7.5"]) + + +if __name__ == "__main__": + unittest.main()