mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd36f6f691 | ||
|
|
2ce979d74d | ||
|
|
98e9043dd0 | ||
|
|
0402561fdd |
No files matched your search
@@ -102,11 +102,10 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
if (line.startsWith("== STEP ")) {
|
||||
stepAt = at
|
||||
stepText = line.substringAfter("== STEP ").substringAfter(' ')
|
||||
val m = INSTALL_COUNT.find(line)
|
||||
if (m != null) {
|
||||
clientDownload(line)?.let { (what, percent) ->
|
||||
clientDownloadAt = at
|
||||
clientDownload = m.groupValues[1]
|
||||
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
|
||||
clientDownload = what
|
||||
clientPercent = percent
|
||||
}
|
||||
} else {
|
||||
val m = UPDATE_PROGRESS.find(line)
|
||||
@@ -152,6 +151,21 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Steam client download line says to show and how far it is: megabytes when the
|
||||
* runtime counts them ("<part> (2/17), 45 of 360 MB"), else the part and its number.
|
||||
*/
|
||||
internal fun clientDownload(line: String): Pair<String, Int>? {
|
||||
val count = INSTALL_COUNT.find(line) ?: return null
|
||||
val mb = INSTALL_MEGABYTES.find(line.substring(count.range.last + 1))
|
||||
val done = mb?.groupValues?.get(1)?.toLongOrNull()
|
||||
val total = mb?.groupValues?.get(2)?.toLongOrNull()
|
||||
if (done != null && total != null && total > 0) {
|
||||
return "$done / $total MB" to (done * 100 / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
return count.groupValues[1] to count.groupValues[2].toInt() * 100 / maxOf(1, count.groupValues[3].toInt())
|
||||
}
|
||||
|
||||
/** The script's install lines are worth reading as they are; its other notes are not. */
|
||||
internal fun readableScriptStep(scriptStep: String): Boolean {
|
||||
val t = scriptStep.lowercase()
|
||||
@@ -161,5 +175,6 @@ class LoadingState(context: Context, private val steam: Boolean = true) {
|
||||
private const val TAIL_BYTES = 48L * 1024
|
||||
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
|
||||
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
|
||||
private val INSTALL_MEGABYTES = Regex("""^, (\d+) of (\d+) MB""")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,12 @@ class SessionService : Service() {
|
||||
private val steamDownloadMonitor = SteamDownloadMonitor()
|
||||
private var downloadMonitorTask: Runnable? = null
|
||||
private var downloadActive = false
|
||||
/**
|
||||
* The runtime is fetching the Steam client itself (a first run, a repair), before anything is on
|
||||
* screen. That download keeps the session awake whatever the downloads setting says: paused, a
|
||||
* first run never gets as far as showing Steam.
|
||||
*/
|
||||
private var clientInstallActive = false
|
||||
private var suspendDownloadNow = false
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
private var steamDownloadsInBackground = false
|
||||
@@ -229,6 +235,7 @@ class SessionService : Service() {
|
||||
suspendDownloadNow = false
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
clientInstallActive = false
|
||||
steamSleepToken = null
|
||||
completeNativeSleep()
|
||||
suspendOperationPending = false
|
||||
@@ -550,8 +557,9 @@ class SessionService : Service() {
|
||||
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
|
||||
},
|
||||
)
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
// Watching first: a first run's client download must be known before anything is paused.
|
||||
updateDownloadMonitoring()
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,8 +1339,7 @@ class SessionService : Service() {
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
val downloadsBlockingSuspend = downloadsKeepAwake() && hidden && !suspendDownloadNow
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
|
||||
completeNativeSleep()
|
||||
@@ -1709,8 +1716,7 @@ class SessionService : Service() {
|
||||
.setContentText(getString(
|
||||
when {
|
||||
SessionState.suspended -> R.string.session_notification_paused
|
||||
downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
|
||||
downloadsKeepAwake() -> R.string.session_notification_downloads
|
||||
else -> R.string.session_notification
|
||||
}
|
||||
))
|
||||
@@ -1718,8 +1724,7 @@ class SessionService : Service() {
|
||||
.apply {
|
||||
if (SessionState.suspended) {
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
|
||||
} else if (downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
|
||||
} else if (downloadsKeepAwake()) {
|
||||
val suspend = PendingIntent.getService(
|
||||
this@SessionService, 3,
|
||||
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
|
||||
@@ -1739,31 +1744,51 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
/** A download is under way that holds the session awake while it is out of sight. */
|
||||
private fun downloadsKeepAwake(): Boolean =
|
||||
downloadActive && (steamDownloadsInBackground || clientInstallActive) &&
|
||||
SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
|
||||
/**
|
||||
* Steam's own downloads with the downloads setting on, and the Steam client's download until the
|
||||
* session shows its first frame whatever the setting: once Steam is on screen it was fetched.
|
||||
*/
|
||||
private fun downloadWatchWanted(): Boolean =
|
||||
SessionState.running && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER &&
|
||||
(steamDownloadsInBackground || !SessionState.firstFrameSeen)
|
||||
|
||||
private fun updateDownloadMonitoring() {
|
||||
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
|
||||
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
if (!enabled) {
|
||||
if (!downloadWatchWanted()) {
|
||||
val wasActive = downloadActive
|
||||
downloadMonitorTask?.let(mainHandler::removeCallbacks)
|
||||
downloadMonitorTask = null
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
clientInstallActive = false
|
||||
if (wasActive) refreshNotification()
|
||||
return
|
||||
}
|
||||
if (downloadMonitorTask != null) return
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (downloadMonitorTask !== this || !SessionState.running ||
|
||||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
|
||||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
|
||||
if (downloadMonitorTask !== this) return
|
||||
if (!downloadWatchWanted()) {
|
||||
downloadMonitorTask = null
|
||||
updateDownloadMonitoring()
|
||||
updateSuspendPolicy()
|
||||
return
|
||||
}
|
||||
val root = LinuxRuntime.rootDir(this@SessionService)
|
||||
val wasActive = downloadActive
|
||||
downloadActive = steamDownloadMonitor.poll(
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
val wasInstalling = clientInstallActive
|
||||
clientInstallActive = !SessionState.firstFrameSeen && SteamDownloadMonitor.clientInstallPending(root)
|
||||
downloadActive = clientInstallActive || steamDownloadsInBackground && steamDownloadMonitor.poll(
|
||||
root,
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
)
|
||||
if (wasInstalling != clientInstallActive) {
|
||||
SessionEvents.record("steam.client_download", mapOf("active" to clientInstallActive))
|
||||
}
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
|
||||
|
||||
@@ -126,13 +126,26 @@ internal class SteamDownloadMonitor {
|
||||
private fun value(text: String, key: String): String? =
|
||||
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
|
||||
|
||||
private companion object {
|
||||
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
companion object {
|
||||
/**
|
||||
* The runtime has yet to fetch the Steam client: a first run, or a repair asked for or begun
|
||||
* (droiddeck-steam-install drops its stamp as it starts one). That download runs before
|
||||
* anything is on screen, and a paused session pauses it with the rest.
|
||||
*/
|
||||
fun clientInstallPending(runtimeRoot: File): Boolean {
|
||||
val home = File(runtimeRoot, "root")
|
||||
val steam = File(home, ".local/share/Steam")
|
||||
val stamped = File(steam, "package/droiddeck-installed").isFile ||
|
||||
File(steam, "package/bannerlator-installed").isFile
|
||||
return !(stamped && File(steam, "steamrtarm64/steam").isFile) || File(home, ".bl-steam-repair").exists()
|
||||
}
|
||||
|
||||
private const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
|
||||
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
private const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
private const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
private val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
private val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
private val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LoadingStateTest {
|
||||
@Test fun clientDownloadCountsMegabytesWhenTheRuntimeSaysThem() {
|
||||
assertEquals(
|
||||
"45 / 360 MB" to 12,
|
||||
LoadingState.clientDownload("== STEP 17:38:20 downloading Steam: bins_linuxarm64_linuxarm64 (2/17), 45 of 360 MB"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aStalledDownloadStillShowsWhereItIs() {
|
||||
assertEquals(
|
||||
"45 / 360 MB" to 12,
|
||||
LoadingState.clientDownload(
|
||||
"== STEP 17:39:20 downloading Steam: bins_linuxarm64_linuxarm64 (2/17), 45 of 360 MB" +
|
||||
" - interrupted (curl exit 28), trying again in 5 s",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun anOlderRuntimeShowsThePartAndItsNumber() {
|
||||
assertEquals(
|
||||
"bins_linuxarm64_linuxarm64" to 11,
|
||||
LoadingState.clientDownload("== STEP 17:38:17 downloading Steam: bins_linuxarm64_linuxarm64 (2/17)"),
|
||||
)
|
||||
// Under a megabyte in all: nothing to count yet.
|
||||
assertEquals("a_all" to 50, LoadingState.clientDownload("== STEP 00:00:00 downloading Steam: a_all (1/2), 0 of 0 MB"))
|
||||
}
|
||||
|
||||
@Test fun otherStepsAreNotClientDownloads() {
|
||||
assertNull(LoadingState.clientDownload("== STEP 17:38:14 checking the Steam client (a first run downloads it)"))
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,39 @@ class SteamDownloadMonitorTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun theClientIsPendingUntilItIsInstalledAndStamped() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-client-").toFile()
|
||||
try {
|
||||
val steam = File(root, "root/.local/share/Steam")
|
||||
assertTrue(SteamDownloadMonitor.clientInstallPending(root))
|
||||
File(steam, "steamrtarm64").mkdirs()
|
||||
File(steam, "steamrtarm64/steam").writeText("client")
|
||||
// Unpacked but not finished: droiddeck-steam-install stamps the client last.
|
||||
assertTrue(SteamDownloadMonitor.clientInstallPending(root))
|
||||
File(steam, "package").mkdirs()
|
||||
File(steam, "package/droiddeck-installed").writeText("1791686101\n")
|
||||
assertFalse(SteamDownloadMonitor.clientInstallPending(root))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anOlderStampCountsAndARepairRequestIsPending() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-client-").toFile()
|
||||
try {
|
||||
val steam = File(root, "root/.local/share/Steam")
|
||||
File(steam, "steamrtarm64").mkdirs()
|
||||
File(steam, "steamrtarm64/steam").writeText("client")
|
||||
File(steam, "package").mkdirs()
|
||||
File(steam, "package/bannerlator-installed").writeText("1\n")
|
||||
assertFalse(SteamDownloadMonitor.clientInstallPending(root))
|
||||
File(root, "root/.bl-steam-repair").writeText("1\n")
|
||||
assertTrue(SteamDownloadMonitor.clientInstallPending(root))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun manifest(root: File, appId: String, downloaded: Long, total: Long, flags: Int): File {
|
||||
val file = File(root, "steamapps/appmanifest_$appId.acf")
|
||||
check(file.parentFile?.mkdirs() != false)
|
||||
|
||||
@@ -400,7 +400,9 @@ case $mode in
|
||||
esac
|
||||
;;
|
||||
steam)
|
||||
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
|
||||
# A first run downloads the client (about 360 MB in 2026): minutes on a slow connection, with
|
||||
# the progress in megabytes on the loading screen.
|
||||
step "checking the Steam client (a first run downloads it)"
|
||||
droiddeck-steam-install || exit $?
|
||||
step "Steam client ready"
|
||||
steam_root=$XDG_DATA_HOME/Steam
|
||||
|
||||
@@ -12,6 +12,9 @@ stamp=$steam_root/package/droiddeck-installed
|
||||
legacy_stamp=$steam_root/package/bannerlator-installed
|
||||
unconfirmed=$steam_root/package/droiddeck-unconfirmed
|
||||
repair_request=$HOME/.bl-steam-repair
|
||||
# The parts of an unfinished download: a session that ends, or loses its connection, part way
|
||||
# through picks up where it stopped instead of fetching the whole client again.
|
||||
parts=$steam_root/package/droiddeck-download
|
||||
if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then
|
||||
mv -f "$legacy_stamp" "$stamp" || true
|
||||
fi
|
||||
@@ -24,6 +27,11 @@ elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \
|
||||
fi
|
||||
if [ -n "$repair" ]; then
|
||||
echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)"
|
||||
# A repair unpacks every part again - once: an interrupted one keeps what it has redone.
|
||||
if [ ! -f "$parts/.repair" ]; then
|
||||
rm -rf "$parts"
|
||||
mkdir -p "$parts" && : > "$parts/.repair"
|
||||
fi
|
||||
rm -f "$stamp" "$legacy_stamp" "$steam_root/.crash" "$steam_root/steam.pid" \
|
||||
"$steam_root"/package/steam_client_*.manifest "$steam_root"/package/steam_client_*.installed
|
||||
for part in "$steam_root"/package/*.zip.*; do
|
||||
@@ -41,26 +49,105 @@ fi
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl -fsSL "$cdn/$manifest" -o "$work/manifest"
|
||||
|
||||
# A sleep that starts no process (droiddeck-session's nap): read -t on a pipe nothing writes to.
|
||||
nap() {
|
||||
[ -n "${nap_fd:-}" ] || exec {nap_fd}<> <(:)
|
||||
read -r -t "$1" -u "$nap_fd" _ 2>/dev/null || :
|
||||
}
|
||||
size_of() {
|
||||
local n=0
|
||||
[ -f "$1" ] && n=$(wc -c < "$1")
|
||||
echo $((n))
|
||||
}
|
||||
# A connection can stall without closing (Wi-Fi power save, a phone changing networks), and
|
||||
# curl's default then is to wait for ever with nothing on screen. A transfer under 4 KB/s for a
|
||||
# minute is dropped and resumed from the bytes already on disk.
|
||||
limits=(--connect-timeout 30 --speed-limit 4096 --speed-time 60)
|
||||
# Seconds between attempts that made no progress; the download fails after the last one.
|
||||
read -r -a delays <<< "${BL_STEAM_RETRY_DELAYS:-5 10 20 30 30}"
|
||||
|
||||
step() { echo "== STEP $(date -u +%H:%M:%S) $*"; }
|
||||
# The line the app's loading screen reads: the part, its number, and megabytes overall. A third
|
||||
# argument is said after it (a stall), and is always said; otherwise only a change is.
|
||||
progress() {
|
||||
local mb=$(( (done_bytes + $(size_of "$1")) / 1000000 ))
|
||||
[ -z "${3:-}" ] && [ "$mb" = "${shown_mb:-}" ] && [ "$2" = "${shown_part:-}" ] && return 0
|
||||
shown_mb=$mb shown_part=$2
|
||||
step "downloading Steam: $2 ($i/$total), $mb of $((total_bytes / 1000000)) MB${3:+ - $3}"
|
||||
}
|
||||
|
||||
# fetch URL FILE SIZE SHA256 PART: FILE complete and checked, resuming whatever is already there.
|
||||
fetch() {
|
||||
local url=$1 dest=$2 size=$3 sum=$4 part=$5 failures=0 bad=0 finished=0 before rc fd wait
|
||||
while :; do
|
||||
# Whole once it is as long as the manifest says, or (no size given) once curl finished it.
|
||||
if [ -f "$dest" ] && { [ "$finished" = 1 ] || { [ "$size" -gt 0 ] && [ "$(size_of "$dest")" -ge "$size" ]; }; }; then
|
||||
echo "$sum $dest" | sha256sum -c --quiet 2>/dev/null && return 0
|
||||
# Bytes that do not check out are fetched whole again, a few times at most.
|
||||
rm -f "$dest"
|
||||
finished=0 bad=$((bad + 1))
|
||||
[ "$bad" -lt 3 ] || { echo "droiddeck-steam-install: $part does not match its checksum" >&2; return 1; }
|
||||
fi
|
||||
before=$(size_of "$dest")
|
||||
# curl's status comes back through the pipe (|| keeps set -e from ending the subshell before it
|
||||
# is said); until then a progress line every 5 s.
|
||||
exec {fd}< <(curl -fsSL "${limits[@]}" -C - -o "$dest" "$url" && echo 0 || echo $?)
|
||||
until read -r -t 5 -u "$fd" rc; do
|
||||
if [ $? -le 128 ]; then rc=1; break; fi
|
||||
progress "$dest" "$part"
|
||||
done
|
||||
exec {fd}<&-
|
||||
if [ "$rc" = 0 ]; then finished=1; continue; fi
|
||||
# 33: the server would not resume. Start the part over.
|
||||
[ "$rc" = 33 ] && rm -f "$dest"
|
||||
if [ "$(size_of "$dest")" -gt "$before" ]; then failures=0; else failures=$((failures + 1)); fi
|
||||
if [ "$failures" -gt "${#delays[@]}" ]; then
|
||||
echo "droiddeck-steam-install: could not download $part (curl exit $rc); what arrived is kept for the next try" >&2
|
||||
return 1
|
||||
fi
|
||||
wait=${delays[$((failures > 0 ? failures - 1 : 0))]}
|
||||
progress "$dest" "$part" "interrupted (curl exit $rc), trying again in ${wait} s"
|
||||
nap "$wait"
|
||||
done
|
||||
}
|
||||
|
||||
curl -fsSL "${limits[@]}" --retry 5 --retry-connrefused --retry-delay 5 "$cdn/$manifest" -o "$work/manifest"
|
||||
version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
|
||||
# Every component block: its name, then the plain zip's file name and sha256.
|
||||
# Every component block: its name, then the plain zip's file name, sha256 and size.
|
||||
awk -F'"' '
|
||||
/^\t"/ && NF >= 3 && $4 == "" { block = $2 }
|
||||
$2 == "file" { file[block] = $4 }
|
||||
$2 == "sha2" { sha[block] = $4 }
|
||||
END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] }
|
||||
$2 == "size" { size[block] = $4 }
|
||||
END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b], size[b] + 0 }
|
||||
' "$work/manifest" | sort > "$work/parts"
|
||||
[ -s "$work/parts" ] || { echo "droiddeck-steam-install: no client components in $manifest" >&2; exit 1; }
|
||||
total=$(grep -c . "$work/parts" 2>/dev/null || echo 0)
|
||||
total_bytes=$(awk '{ n += $4 } END { print n + 0 }' "$work/parts")
|
||||
|
||||
mkdir -p "$steam_root/package"
|
||||
while read -r block file sum; do
|
||||
mkdir -p "$parts"
|
||||
# What an earlier attempt left for another client version is no use to this one. A part's file
|
||||
# name carries its own hash, so a name still listed is the same part.
|
||||
for left in "$parts"/*; do
|
||||
[ -e "$left" ] || continue
|
||||
name=${left##*/}
|
||||
name=${name%.unpacked}
|
||||
grep -qF " $name " "$work/parts" || rm -rf "$left"
|
||||
done
|
||||
done_bytes=0
|
||||
while read -r block file sum size; do
|
||||
i=$((${i:-0} + 1))
|
||||
echo "== STEP $(date -u +%H:%M:%S) downloading Steam: $block ($i/$total)"
|
||||
curl -fsSL "$cdn/$file" -o "$work/$file"
|
||||
echo "$sum $work/$file" | sha256sum -c --quiet
|
||||
unzip -qo "$work/$file" -d "$steam_root"
|
||||
rm -f "$work/$file"
|
||||
if [ -f "$parts/$file.unpacked" ]; then
|
||||
done_bytes=$((done_bytes + size))
|
||||
continue
|
||||
fi
|
||||
progress "$parts/$file" "$block"
|
||||
fetch "$cdn/$file" "$parts/$file" "$size" "$sum" "$block" || exit 1
|
||||
unzip -qo "$parts/$file" -d "$steam_root"
|
||||
: > "$parts/$file.unpacked"
|
||||
rm -f "$parts/$file"
|
||||
done_bytes=$((done_bytes + size))
|
||||
done < "$work/parts"
|
||||
# Some entries are zipped with Windows separators; unzip keeps the backslash in the file name.
|
||||
find "$steam_root" -depth -name '*\\*' | while read -r odd; do
|
||||
@@ -90,6 +177,7 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64"
|
||||
ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64"
|
||||
ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32"
|
||||
echo "$version" > "$stamp"
|
||||
rm -rf "$parts"
|
||||
if [ -n "$repair" ]; then
|
||||
echo repaired > "$unconfirmed"
|
||||
rm -f "$repair_request"
|
||||
|
||||
@@ -26,13 +26,43 @@ class SteamInstallTest(unittest.TestCase):
|
||||
self.bin = self.root / 'bin'
|
||||
self.bin.mkdir()
|
||||
# Only the network is faked: exercise real manifest parsing, checksums and zip extraction.
|
||||
# TEST_PLAN lines "<file> <action>" are used up in order by requests for that file:
|
||||
# "cut <bytes>" delivers up to that offset then drops the connection, "fail <status>"
|
||||
# delivers nothing, "norange" refuses to resume.
|
||||
curl = self.bin / 'curl'
|
||||
curl.write_text('''#!/bin/bash
|
||||
set -eu
|
||||
printf '%s\\n' "$2" >> "$TEST_REQUESTS"
|
||||
cp "$TEST_CDN/${2##*/}" "$4"
|
||||
curl.write_text('''#!/usr/bin/env python3
|
||||
import os, sys
|
||||
args = sys.argv[1:]
|
||||
url = next(a for a in args if a.startswith('https://'))
|
||||
out = args[args.index('-o') + 1]
|
||||
name = url.rsplit('/', 1)[1]
|
||||
offset = os.path.getsize(out) if '-C' in args and os.path.exists(out) else 0
|
||||
with open(os.environ['TEST_REQUESTS'], 'a') as log:
|
||||
log.write(url + '\\n')
|
||||
with open(os.environ['TEST_REQUESTS'] + '.offsets', 'a') as log:
|
||||
log.write(f'{name} {offset}\\n')
|
||||
action = []
|
||||
plan = os.environ.get('TEST_PLAN')
|
||||
if plan and os.path.exists(plan):
|
||||
lines = open(plan).read().splitlines()
|
||||
for n, line in enumerate(lines):
|
||||
if line.split()[0] == name:
|
||||
action = line.split()[1:]
|
||||
del lines[n]
|
||||
open(plan, 'w').write(''.join(l + '\\n' for l in lines))
|
||||
break
|
||||
if action[:1] == ['fail']:
|
||||
sys.exit(int(action[1]))
|
||||
if action[:1] == ['norange'] and offset:
|
||||
sys.exit(33)
|
||||
data = open(os.path.join(os.environ['TEST_CDN'], name), 'rb').read()
|
||||
end = int(action[1]) if action[:1] == ['cut'] else len(data)
|
||||
with open(out, 'ab' if offset else 'wb') as target:
|
||||
target.write(data[offset:end])
|
||||
sys.exit(28 if end < len(data) else 0)
|
||||
''')
|
||||
curl.chmod(0o755)
|
||||
self.plan = self.root / 'plan'
|
||||
# macOS has shasum; the guest and Linux CI have sha256sum.
|
||||
if shutil.which('sha256sum') is None:
|
||||
sha = self.bin / 'sha256sum'
|
||||
@@ -44,7 +74,8 @@ cp "$TEST_CDN/${2##*/}" "$4"
|
||||
sha1.chmod(0o755)
|
||||
self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.data),
|
||||
PATH=str(self.bin) + os.pathsep + os.environ['PATH'],
|
||||
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests))
|
||||
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests),
|
||||
TEST_PLAN=str(self.plan), BL_STEAM_RETRY_DELAYS='0 0 0')
|
||||
for channel in ('steamdeck_publicbeta',):
|
||||
archive = self.cdn / (channel + '.zip')
|
||||
with zipfile.ZipFile(archive, 'w') as package:
|
||||
@@ -157,5 +188,123 @@ cp "$TEST_CDN/${2##*/}" "$4"
|
||||
self.assertFalse(self.requests.exists())
|
||||
|
||||
|
||||
def publish(self, **parts):
|
||||
"""The CDN's client as these parts (block name -> byte count of incompressible content),
|
||||
listed with sizes as Valve's manifest lists them."""
|
||||
files = {}
|
||||
blocks = ''
|
||||
for block, length in parts.items():
|
||||
archive = self.cdn / f'{block}.zip.{hashlib.sha1(block.encode()).hexdigest()}'
|
||||
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_STORED) as package:
|
||||
package.writestr(f'parts/{block}', os.urandom(length))
|
||||
if block.endswith('_linuxarm64_linuxarm64'):
|
||||
package.writestr('steamrtarm64/steam', 'steamdeck_publicbeta')
|
||||
data = archive.read_bytes()
|
||||
files[block] = archive.name
|
||||
blocks += (f'\t"{block}"\n\t{{\n\t\t"file"\t\t"{archive.name}"\n\t\t"size"\t\t"{len(data)}"\n'
|
||||
f'\t\t"sha2"\t\t"{hashlib.sha256(data).hexdigest()}"\n\t}}\n')
|
||||
(self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text(
|
||||
'"linuxarm64"\n{\n\t"version"\t\t"123456"\n' + blocks + '}\n')
|
||||
return files
|
||||
|
||||
def two_parts(self):
|
||||
return self.publish(a_all=1_200_000, client_linuxarm64_linuxarm64=1_300_000)
|
||||
|
||||
def plan_for(self, name, *actions):
|
||||
with self.plan.open('a') as plan:
|
||||
plan.writelines(f'{name} {action}\n' for action in actions)
|
||||
|
||||
def offsets(self, name):
|
||||
log = Path(str(self.requests) + '.offsets')
|
||||
return [int(line.split()[1]) for line in log.read_text().splitlines() if line.split()[0] == name]
|
||||
|
||||
def requested(self):
|
||||
return [line.rsplit('/', 1)[1] for line in self.requests.read_text().splitlines()]
|
||||
|
||||
@property
|
||||
def download(self):
|
||||
return self.steam / 'package/droiddeck-download'
|
||||
|
||||
def test_progress_is_counted_in_megabytes_across_parts(self):
|
||||
self.two_parts()
|
||||
result = self.install()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn('downloading Steam: a_all (1/2), 0 of 2 MB\n', result.stdout)
|
||||
self.assertIn('downloading Steam: client_linuxarm64_linuxarm64 (2/2), 1 of 2 MB\n', result.stdout)
|
||||
self.assertTrue((self.steam / 'parts/a_all').is_file())
|
||||
self.assertFalse(self.download.exists())
|
||||
|
||||
def test_a_dropped_connection_resumes_from_what_arrived(self):
|
||||
files = self.two_parts()
|
||||
client = files['client_linuxarm64_linuxarm64']
|
||||
self.plan_for(client, 'cut 500000')
|
||||
result = self.install()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual([0, 500000], self.offsets(client))
|
||||
self.assertIn('interrupted (curl exit 28), trying again in 0 s', result.stdout)
|
||||
self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text())
|
||||
|
||||
def test_a_failed_session_keeps_what_it_finished_and_fetched(self):
|
||||
files = self.two_parts()
|
||||
client = files['client_linuxarm64_linuxarm64']
|
||||
self.plan_for(client, 'cut 400000', *['fail 6'] * 4)
|
||||
result = self.install()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn('could not download client_linuxarm64_linuxarm64 (curl exit 6)', result.stderr)
|
||||
self.assertFalse((self.steam / 'package/droiddeck-installed').exists())
|
||||
self.assertTrue((self.download / (files['a_all'] + '.unpacked')).is_file())
|
||||
self.assertFalse((self.download / files['a_all']).exists())
|
||||
self.assertEqual(400000, (self.download / client).stat().st_size)
|
||||
self.requests.unlink()
|
||||
Path(str(self.requests) + '.offsets').unlink()
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
# The next session asks only for the rest of the part it did not finish.
|
||||
self.assertEqual(['steam_client_steamdeck_publicbeta_linuxarm64', client], self.requested())
|
||||
self.assertEqual([400000], self.offsets(client))
|
||||
self.assertFalse(self.download.exists())
|
||||
|
||||
def test_a_server_that_will_not_resume_starts_the_part_over(self):
|
||||
files = self.two_parts()
|
||||
client = files['client_linuxarm64_linuxarm64']
|
||||
self.plan_for(client, 'cut 400000', 'norange')
|
||||
result = self.install()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual([0, 400000, 0], self.offsets(client))
|
||||
|
||||
def test_bytes_that_do_not_check_out_are_fetched_again(self):
|
||||
files = self.two_parts()
|
||||
client = files['client_linuxarm64_linuxarm64']
|
||||
self.download.mkdir(parents=True)
|
||||
(self.download / client).write_bytes(b'x' * (self.cdn / client).stat().st_size)
|
||||
result = self.install()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual([0], self.offsets(client))
|
||||
|
||||
def test_parts_left_for_another_client_version_are_dropped(self):
|
||||
files = self.two_parts()
|
||||
self.download.mkdir(parents=True)
|
||||
(self.download / 'a_all.zip.0123').write_bytes(b'older')
|
||||
(self.download / 'b_all.zip.4567.unpacked').write_bytes(b'')
|
||||
self.plan_for(files['client_linuxarm64_linuxarm64'], *['fail 7'] * 4)
|
||||
self.assertNotEqual(0, self.install().returncode)
|
||||
self.assertEqual([files['a_all'] + '.unpacked'], sorted(p.name for p in self.download.iterdir()))
|
||||
|
||||
def test_a_repair_unpacks_every_part_again_once(self):
|
||||
files = self.two_parts()
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
(self.home / '.bl-steam-repair').write_text('1\n')
|
||||
self.requests.unlink()
|
||||
self.plan_for(files['client_linuxarm64_linuxarm64'], *['fail 7'] * 4)
|
||||
self.assertNotEqual(0, self.install().returncode)
|
||||
self.assertIn(files['a_all'], self.requested())
|
||||
self.requests.unlink()
|
||||
# Still a repair, but what the interrupted one redid is not done a third time.
|
||||
self.assertEqual(0, self.install().returncode)
|
||||
self.assertEqual(['steam_client_steamdeck_publicbeta_linuxarm64', files['client_linuxarm64_linuxarm64']],
|
||||
self.requested())
|
||||
self.assertFalse((self.home / '.bl-steam-repair').exists())
|
||||
self.assertFalse(self.download.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user