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 <ver> (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 -> <tag>" 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.
This commit is contained in:
The412Banner committed 2026-10-11 15:32:07 -04:00
1 parent dde08414fc
commit 715032062d
11 files changed
+575 -61

No files matched your search

@@ -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(
@@ -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<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(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()
}
}
@@ -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<String> = installedIn(toolsDir(context), tool)
internal fun installedIn(dir: File, tool: Tool): List<String> =
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<Long> =
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<String>, 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<String> {
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 <folder>", 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<String> =
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
}
@@ -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<String> = listOfNotNull(installed), val update: String? = null,
)
+9 -2
View File
@@ -431,7 +431,7 @@
<string name="comp_applies_next_game">Applies the next time a game starts</string>
<string name="comp_import_zip">Import .zip</string>
<string name="comp_reading">Reading Protons…</string>
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Setup.</string>
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton, CachyOS or DroidDeck-Proton from Setup.</string>
<string name="comp_original">Original · %1$s</string>
<string name="comp_this_build">%1$s · this build</string>
<string name="comp_earlier_build">%1$s · earlier build</string>
@@ -1029,6 +1029,13 @@
<string name="proton_installed">Installed %1$s</string>
<string name="proton_queued">Pending from an earlier request</string>
<string name="proton_not_installed">Not installed</string>
<!-- %1$s is the newer release's tag -->
<string name="proton_update_available">Update available → %1$s</string>
<!-- %1$s is the Proton's name (GE-Proton, proton-cachyos, DroidDeck-Proton) -->
<string name="proton_remove_older">Remove old %1$s versions</string>
<!-- %1$s lists the folders that are removed -->
<string name="proton_remove_older_body">Removes %1$s. Games set to these versions must be switched to another Proton.</string>
<string name="proton_remove_body">Games set to this version must be switched to another Proton.</string>
<string name="proton_install_now">Install now</string>
<string name="proton_cancel_queued">Cancel queued</string>
<string name="pckb_android">Android keyboard</string>
@@ -1758,7 +1765,7 @@
<!-- %1$s is the client's own download progress, as it prints it -->
<string name="loadstate_steam_client">Downloading the Steam client · %1$s</string>
<!-- Installing GE-Proton / proton-cachyos (%1$s is the tool's name, %2$s its release tag) -->
<!-- Installing GE-Proton / proton-cachyos / DroidDeck-Proton (%1$s is the tool's name, %2$s its release tag) -->
<string name="pextra_busy">A compatibility tool install is already running</string>
<string name="pextra_stop_session">Stop the active session before installing compatibility tools</string>
<string name="pextra_no_release">Could not find an ARM64 release for %1$s</string>
@@ -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<String>(), 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")))
}
}
@@ -4,14 +4,15 @@
#
# droiddeck-proton-extra <steam root> ge [--tag GE-Proton11-7]
# droiddeck-proton-extra <steam root> cachyos [--tag cachyos-11.0-20260703-slr]
# droiddeck-proton-extra <steam root> droiddeck [--tag DroidDeck-Proton-11-7.5]
# droiddeck-proton-extra <steam root> <url of an aarch64 tarball>
# droiddeck-proton-extra <steam root> /path/to/an/aarch64/tarball
# droiddeck-proton-extra <steam root> --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 <steam root> ge|cachyos|<url>")
raise SystemExit(__doc__ or "usage: droiddeck-proton-extra <steam root> ge|cachyos|droiddeck|<url>")
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:
@@ -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"
+19
View File
@@ -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)")
+34
View File
@@ -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)
+107
View File
@@ -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()