mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user