Fix runtime removal and first-run Steam setup (#185)

* Fix runtime removal races and show removal progress

* Bootstrap Steam from the selected client channel

* Show runtime progress below its Setup row

* Always stage current session helpers and verify APK assets

* Initialize Steam UI scale once using the handheld default
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-03 22:03:26 -04:00
1 parent f5e204eb1d
commit db2e1d7da2
27 files changed
+978 -90

No files matched your search

+1
View File
@@ -320,6 +320,7 @@ jobs:
run: |
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
+10
View File
@@ -176,6 +176,16 @@ android {
}
}
// Session scripts must follow this checkout even when the native asset bundle is cached.
// A stale local bundle can omit a newly required helper on a fresh runtime install.
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
from(rootProject.file('tools/linuxfs/overlay')) {
include 'usr/local/bin/bannerlator-*', 'usr/bin/**'
}
into(layout.projectDirectory.dir('src/main/assets/linuxfs'))
}
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
@@ -68,6 +68,7 @@ import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.input.SecondScreenDisplay
@@ -96,6 +97,7 @@ class MainActivity : ComponentActivity() {
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var removalPending by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
private var stage by mutableStateOf("")
@@ -298,7 +300,7 @@ class MainActivity : ComponentActivity() {
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
@@ -343,6 +345,10 @@ class MainActivity : ComponentActivity() {
override fun startActivity(intent: Intent?) {
if (intent?.component?.className == SessionActivity::class.java.name) {
when {
busy || LinuxRuntimeInstaller.isBusy() -> {
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
return
}
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
return
@@ -370,6 +376,7 @@ class MainActivity : ComponentActivity() {
/** The page is all blue: the session opens on the same blue, with no animation of its own. */
private fun launchFlooded(f: PendingFlood) {
if (busy || LinuxRuntimeInstaller.isBusy()) { flood = null; floodProgress = 0f; return }
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
overridePendingTransition(0, 0)
@@ -487,7 +494,7 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.ui.FloodBehind({ floodProgress }) {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version,
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
shortcutPicker = shortcutPicker,
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
@@ -733,7 +740,7 @@ class MainActivity : ComponentActivity() {
title = "Remove Linux runtime",
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
confirm = "Remove",
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
@@ -930,7 +937,7 @@ class MainActivity : ComponentActivity() {
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
private fun saveAction(label: String, work: () -> String) {
if (saveBusy != null) return
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
@@ -995,7 +1002,7 @@ class MainActivity : ComponentActivity() {
protonId = components.compProton,
comp = components.compComp,
checking = components.compChecking,
busy = components.compBusy,
busy = if (busy) stage else components.compBusy,
downloads = components.compDownloads,
requestInitialFocus = focusComponentsContent,
onProton = { components.compProton = it },
@@ -1035,7 +1042,7 @@ class MainActivity : ComponentActivity() {
busyId = protons.protonBusyId,
stage = protons.protonStage,
percent = protons.protonPercent,
runtimeReady = ready,
runtimeReady = ready && !busy,
sessionRunning = SessionState.running,
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
@@ -1057,7 +1064,7 @@ class MainActivity : ComponentActivity() {
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (pkgStage != null || SessionState.running) return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
Thread({
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
@@ -1074,7 +1081,7 @@ class MainActivity : ComponentActivity() {
private fun removePackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (pkgStage != null || SessionState.running) return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
Thread({
DesktopCatalog.remove(this, entry)
@@ -1363,12 +1370,13 @@ class MainActivity : ComponentActivity() {
}
private fun refresh() {
if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) }
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
ready = LinuxRuntime.isInstalled(this)
removalPending = LinuxRuntimeInstaller.hasRemovalPending(this)
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
@@ -1407,9 +1415,9 @@ class MainActivity : ComponentActivity() {
}
private fun onRuntimeButton() {
if (busy) return
if (runtimeChangesBlocked()) return
val release = available
if (installed != null && release?.version == installed) {
if (removalPending || installed != null && release?.version == installed) {
// Nothing to install: offer the one destructive thing this screen can do.
showRemove = true
return
@@ -1427,6 +1435,7 @@ class MainActivity : ComponentActivity() {
/** Starts a session; with no runtime on a non-Adreno, the same warning Setup gives comes first, before any download. */
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
if (busy || LinuxRuntimeInstaller.isBusy()) return false
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
@@ -1465,21 +1474,35 @@ class MainActivity : ComponentActivity() {
}
}
private fun runtimeChangesBlocked(): Boolean =
busy || LinuxRuntimeInstaller.isBusy() || SessionState.running ||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
ready = false
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { removal.run(it) }
}
private fun install(release: LinuxRuntimeInstaller.Release) {
// The service keeps the process alive if the user switches away; this screen joins the
// same install (or starts it, if it gets there first) to show the progress.
com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release)
followInstall { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
followRuntimeOperation { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
}
/**
* Shows an install's progress until it ends. [run] either starts one or joins the one already
* Shows a runtime operation's progress until it ends. [run] either starts one or joins the one already
* running (null: nothing was), which is how a launcher rebuilt mid-install picks it back up.
*/
private fun followInstall(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = "Starting…"
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
@@ -1488,9 +1511,12 @@ class MainActivity : ComponentActivity() {
ui.post {
busy = false
if (ok != null) failed = !ok
if (ok == false) LinuxRuntimeInstaller.removalError()?.let {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
}
}, "install").start()
}, "runtime-operation").start()
}
private fun checkCatalog() {
@@ -266,8 +266,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
private var runtimeRemovalBlocked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
finish()
return
}
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
// A Flatpak app or AppImage is named by its arguments, which the name lookup reads here.
@@ -812,7 +820,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor("the Linux runtime", release.size / 1_000_000))
return if (ok) null else "The Linux runtime did not install. Check the connection and press Play again."
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
}
/** Null when the desktop package is in, else the loading screen's closing line. */
@@ -1747,6 +1755,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onStart() {
super.onStart()
if (runtimeRemovalBlocked) return
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
@@ -1755,6 +1764,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onStop() {
if (runtimeRemovalBlocked) { super.onStop(); return }
started = false
displayManager.unregisterDisplayListener(displayListener)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
@@ -1786,6 +1796,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
if (runtimeRemovalBlocked) return
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
@@ -1805,6 +1816,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
// Do not carry transient session UI across an app/display transition. In particular, the
// drawer's dim layer can otherwise remain over Steam when this activity returns.
@@ -1830,6 +1842,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (runtimeRemovalBlocked) return
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
@@ -1868,6 +1881,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
if (runtimeRemovalBlocked) { super.onDestroy(); return }
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
watching = false
@@ -88,6 +88,7 @@ public final class LinuxRuntime {
/** The rootfs is present with gamescope and the session script the launcher hands control to. */
public static boolean isInstalled(Context context) {
if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false;
File root = rootDir(context);
return new File(root, "usr/bin/gamescope").isFile()
&& new File(root, SESSION_SCRIPT.substring(1)).isFile()
@@ -64,7 +64,7 @@ public final class LinuxRuntimeInstaller {
}
/**
* One install per process. The launcher and the session screen can both start one, and a
* One runtime operation per process. The launcher and session can both install, and a
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
* install would share the first one's archive and staging directory and wreck both.
*/
@@ -74,11 +74,13 @@ public final class LinuxRuntimeInstaller {
volatile String stage = "Starting\u2026";
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
Job(boolean removal) { this.removal = removal; }
}
private static final Object JOB_LOCK = new Object();
private static Job running;
private static boolean removing;
private static volatile String removalError;
private LinuxRuntimeInstaller() {}
@@ -127,9 +129,9 @@ public final class LinuxRuntimeInstaller {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (removing) return false;
if (running != null && running.removal) return false;
owner = running == null;
if (owner) running = new Job();
if (owner) { running = new Job(false); removalError = null; }
job = running;
if (listener != null) job.listeners.add(listener);
}
@@ -152,13 +154,31 @@ public final class LinuxRuntimeInstaller {
/** True while an install is running in this process, whoever started it. */
public static boolean isInstalling() {
synchronized (JOB_LOCK) {
return running != null;
return running != null && !running.removal;
}
}
public static boolean isBusy() {
synchronized (JOB_LOCK) { return running != null; }
}
public static boolean isRemoving() {
synchronized (JOB_LOCK) { return running != null && running.removal; }
}
public static String removalError() { return removalError; }
public static boolean hasRemovalPending(Context context) {
return removalDirectory(LinuxRuntime.rootDir(context)).exists();
}
private static File removalDirectory(File root) {
return new File(root.getParentFile(), root.getName() + ".removing");
}
/**
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
* it ends and returns its result, or returns null at once when nothing is installing.
* Follows the runtime operation already running, including removal, after a screen rebuild.
* Blocks until it ends and returns its result, or null when there is no current operation.
*/
public static Boolean attach(ProgressListener listener) {
Job job;
@@ -186,6 +206,11 @@ public final class LinuxRuntimeInstaller {
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
try {
// A failed/interrupted explicit removal must finish before a fresh installation.
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (listener != null) listener.onProgress("Downloading", 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
@@ -212,18 +237,18 @@ public final class LinuxRuntimeInstaller {
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
FileUtils.delete(staging);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
if (!extract(archive, staging, listener)) {
FileUtils.delete(staging);
RuntimeFileTree.delete(staging, null);
return false;
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
FileUtils.delete(old);
RuntimeFileTree.delete(old, null);
if (root.isDirectory() && !root.renameTo(old)) {
FileUtils.delete(staging);
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -232,16 +257,15 @@ public final class LinuxRuntimeInstaller {
// own empty /root is dropped first so the rename has somewhere to land. If this fails
// the update is abandoned and the previous runtime is put back untouched - shipping a
// working system with the user's games gone is the worse outcome.
File keptFrom = new File(old, USER_DATA);
if (keptFrom.isDirectory()) {
File keptTo = new File(staging, USER_DATA);
FileUtils.delete(keptTo);
if (!keptFrom.renameTo(keptTo)) {
Log.w(TAG, "could not carry " + USER_DATA + " across the update; rolling back");
FileUtils.delete(staging);
old.renameTo(root);
return false;
}
try {
RuntimeFileTree.carryHome(old, staging);
} catch (IOException e) {
Log.w(TAG, "could not carry root across the update; rolling back", e);
// Restore the live name even if cleaning the failed staging tree is denied.
old.renameTo(root);
try { RuntimeFileTree.delete(staging, null); }
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
return false;
}
if (!staging.renameTo(root)) {
@@ -250,7 +274,7 @@ public final class LinuxRuntimeInstaller {
if (old.isDirectory()) old.renameTo(root);
return false;
}
FileUtils.delete(old);
RuntimeFileTree.delete(old, null);
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
Log.e(TAG, "install", e);
@@ -260,13 +284,13 @@ public final class LinuxRuntimeInstaller {
}
}
private static void recoverInterruptedSwap(File root, File staging, File old) {
static void recoverInterruptedSwap(File root, File staging, File old) throws IOException {
File target = root.isDirectory() ? root : old;
File stagedHome = new File(staging, USER_DATA);
File home = new File(target, USER_DATA);
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
FileUtils.delete(home);
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
RuntimeFileTree.carryHome(staging, target);
Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
}
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
Log.w(TAG, "restored the previous runtime after an interrupted update");
@@ -278,20 +302,76 @@ public final class LinuxRuntimeInstaller {
return dir.isDirectory() && names != null && names.length == 0;
}
/** Removes the runtime; returns false without touching it while an install is running. */
public static boolean uninstall(Context context) {
/** Reserve removal before returning to the UI, so Play cannot race the worker. */
public static Removal beginUninstall(Context context) {
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
return beginUninstall(LinuxRuntime.rootDir(context));
}
static Removal beginUninstall(File root) {
synchronized (JOB_LOCK) {
if (running != null || removing) return false;
removing = true;
if (running != null) return null;
removalError = null;
running = new Job(true);
running.stage = "Removing Linux runtime";
return new Removal(root, running);
}
try {
FileUtils.delete(LinuxRuntime.rootDir(context));
return true;
} finally {
synchronized (JOB_LOCK) {
removing = false;
}
public static final class Removal {
private final File root;
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Removal(File root, Job job) { this.root = root; this.job = job; }
public boolean run(ProgressListener listener) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
report("Removing Linux runtime", -1);
final long[] removed = {0};
final long[] lastShown = {0};
java.util.function.LongConsumer progress = ignored -> {
removed[0]++;
long now = System.nanoTime();
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
lastShown[0] = now;
report("Removing Linux runtime · " + removed[0] + " entries", -1);
}
};
File pending = removalDirectory(root);
// Invalidate the installation before traversing it. A process killed halfway
// through leaves a named removal to resume, never a launchable partial runtime.
if (root.exists()) {
RuntimeFileTree.delete(pending, progress);
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
} else if (!pending.isDirectory() && !pending.mkdirs()) {
throw new IOException("Cannot prepare the runtime for removal");
}
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
job.ok = true;
report("Linux runtime removed", 100);
return true;
} catch (IOException e) {
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
Log.w(TAG, "runtime removal incomplete", e);
report(removalError, -1);
return false;
} finally {
synchronized (JOB_LOCK) { running = null; }
job.done.countDown();
}
}
private void report(String stage, int percent) {
job.stage = stage;
job.percent = percent;
for (ProgressListener listener : job.listeners) listener.onProgress(stage, percent);
}
}
/**
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.runtime;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.function.LongConsumer;
/** Checked removal of an app-owned runtime tree, including read-only directories. */
final class RuntimeFileTree {
private RuntimeFileTree() {}
static void delete(File root, LongConsumer progress) throws IOException {
delete(root.toPath(), progress, new long[]{0});
}
private static void delete(Path path, LongConsumer progress, long[] removed) throws IOException {
BasicFileAttributes attrs;
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
catch (NoSuchFileException e) { return; }
if (attrs.isDirectory()) {
// Change directory permissions before opening it, so mode 000 also works. Links
// are inspected with lstat and unlinked directly; never chmod or visit their target.
makeWritable(path.toFile());
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
for (Path child : children) delete(child, progress, removed);
}
}
Files.delete(path);
if (progress != null) progress.accept(++removed[0]);
}
private static void makeWritable(File dir) throws IOException {
if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true))
throw new IOException("Cannot make directory removable: " + dir);
}
static void carryHome(File old, File staging) throws IOException {
File home = new File(old, "root");
if (!home.isDirectory()) return;
makeWritable(old);
makeWritable(staging);
File target = new File(staging, "root");
delete(target, null);
if (!home.renameTo(target)) throw new IOException("Cannot carry root across the update");
}
}
@@ -25,22 +25,25 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification("Starting…", -1))
if (release == null) {
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = Int.MIN_VALUE
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
// Every percent at most once: a notification per read would flood the system.
if (percent != shown) {
shown = percent
var shown = ""
val progress = LinuxRuntimeInstaller.ProgressListener { stage, percent ->
// Install percentages and throttled removal counts, including stage transitions.
if ("$stage:$percent" != shown) {
shown = "$stage:$percent"
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
}
}
if (removal) LinuxRuntimeInstaller.attach(progress)
else LinuxRuntimeInstaller.install(applicationContext, release!!, progress)
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
@@ -50,9 +53,9 @@ class RuntimeInstallService : Service() {
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime downloads and installs"
description = "Shows while the Linux runtime is installed or removed"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
@@ -62,7 +65,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle("Installing the Linux runtime")
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -75,6 +78,7 @@ class RuntimeInstallService : Service() {
companion object {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -90,6 +94,11 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun keepRemovalAlive(context: Context) {
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_REMOVE)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -35,6 +35,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
"usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat",
"usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install",
"usr/local/bin/bannerlator-steam-ui-scale" to "usr/local/bin/bannerlator-steam-ui-scale",
"usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library",
"usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists",
"usr/local/bin/bannerlator-proton-extra" to "usr/local/bin/bannerlator-proton-extra",
@@ -470,8 +470,9 @@ object SessionPrefs {
}
/**
* The Steam client branch forced on the command line: "publicbeta" (every session so far) or
* "steamdeck_publicbeta" (Armada's). Deck mode always takes the Deck branch, whatever was chosen:
* The Steam client branch used for the first download and forced on the command line:
* "steamdeck_publicbeta" by default, or "publicbeta" when chosen with Deck mode off.
* Deck mode always takes the Deck branch, whatever was chosen:
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
@@ -480,7 +481,7 @@ object SessionPrefs {
*/
fun steamChannel(context: Context): String =
if (steamDeckMode(context)) "steamdeck_publicbeta"
else prefs(context).getString("steamChannel", null) ?: "publicbeta"
else prefs(context).getString("steamChannel", null) ?: "steamdeck_publicbeta"
fun setSteamChannel(context: Context, id: String) {
prefs(context).edit().putString("steamChannel", id).apply()
@@ -273,17 +273,17 @@ private fun Content(
if (ready.isNotEmpty() || addAfterReady) {
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
Rise(4) {
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { adding = true })
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
}
}
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
if (!addAfterReady) {
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
Rise(6) {
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { adding = true })
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
}
}
if (adding) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
}
selected.startsWith("user:") -> {
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
@@ -320,7 +320,7 @@ private fun Content(
SecondaryButton(stringResource(R.string.setup_tool_roms), onClick = a.onRoms)
if (pkg != null) SecondaryButton(
if (pkg.kind == "appimage") stringResource(R.string.store_remove) else stringResource(R.string.common_hide),
enabled = s.packageBusyId == null && !s.sessionRunning,
enabled = !s.busy && s.packageBusyId == null && !s.sessionRunning,
) { a.onRemovePackage(pkg.id) }
}
}
@@ -340,7 +340,7 @@ private fun Content(
Actions {
PrimaryButton(
if (s.packageBusyId == pkg.id) stringResource(R.string.store_installing) else stringResource(R.string.content_install_named, e.name),
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
enabled = !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
) { a.onInstallPackage(pkg.id) }
if (s.sessionRunning) ActionChip(stringResource(R.string.content_stop_to_install), ok = false)
else if (!s.ready) ActionChip(stringResource(R.string.content_runtime_required), ok = false)
@@ -600,7 +600,7 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
if (app.repo != null) UpdateButton(app, s)
SecondaryButton(
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
enabled = !s.sessionRunning && UserAppsState.working == null,
enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null,
) {
if (!confirm) confirm = true
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
@@ -628,7 +628,7 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
val idle = UserAppsState.working == null && UserAppsState.checking == null
when (val found = UserAppsState.updates[app.key]) {
is UserApps.UpdateCheck.Available -> SecondaryButton(
stringResource(R.string.user_apps_update_to, found.release.tag), enabled = idle && !s.sessionRunning,
stringResource(R.string.user_apps_update_to, found.release.tag), enabled = !s.busy && idle && !s.sessionRunning,
) { UserAppsState.update(ctx, app, found.release) }
else -> {
SecondaryButton(
@@ -302,13 +302,14 @@ internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit
/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */
@Composable
internal fun BusyChip(s: FrontEndState) {
if (s.busy) ActionChip(if (s.percent >= 0) stringResource(R.string.games_runtime_busy_percent, s.percent) else stringResource(R.string.games_runtime_busy), ok = false)
if (s.busy) ActionChip(if (s.percent >= 0) stringResource(R.string.games_runtime_busy_percent, s.percent) else s.stage, ok = false)
}
/** Whether Steam can start, said where Play is rather than only in Setup. */
@Composable
internal fun RuntimeChip(s: FrontEndState) = when {
s.busy -> Chip(if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage, ok = false)
s.removalPending -> Chip(stringResource(R.string.runtime_removal_incomplete), ok = false)
!s.ready -> Chip(stringResource(R.string.games_runtime_first_play), ok = false)
s.available != null && s.available != s.installed -> Chip(stringResource(R.string.games_runtime_update), ok = false)
else -> Chip(stringResource(R.string.games_runtime_ready), ok = true)
@@ -50,7 +50,6 @@ import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -121,14 +120,6 @@ internal fun SideRail(
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
}
}
AnimatedVisibility(s.busy, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) {
val barWidth = if (iconOnly) 44.dp else 60.dp
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth))
else LinearProgressIndicator(modifier = Modifier.width(barWidth))
Text(if (s.percent >= 0) stringResource(R.string.rail_percent, s.percent) else if (iconOnly) "…" else stringResource(R.string.setup_check_busy), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
}
}
var lastRunning by remember { mutableStateOf("") }
if (s.running != null) lastRunning = s.running
AnimatedVisibility(
@@ -112,6 +112,8 @@ class FrontEndState(
val packageStage: String? = null,
val packagePercent: Int = -1,
val sessionRunning: Boolean = false,
val removalPending: Boolean = false,
val runtimeActionsBlocked: Boolean = false,
val backActionsInverted: Boolean = false,
val buildLabel: String = "local",
val oscMode: String = SessionPrefs.OSC_AUTO,
@@ -148,6 +148,7 @@ internal fun SetupPanel(
}
val runtime = when {
s.busy -> stringResource(R.string.common_working)
s.removalPending -> stringResource(R.string.setup_retry_removal)
!s.ready -> stringResource(R.string.setup_install)
s.available != null && s.available != s.installed -> stringResource(R.string.common_update)
else -> stringResource(R.string.setup_manage)
@@ -213,12 +214,25 @@ internal fun SetupPanel(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
stringResource(R.string.setup_runtime),
when {
s.busy -> if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage
s.busy -> null
s.removalPending -> stringResource(R.string.runtime_removal_incomplete)
!s.ready -> stringResource(R.string.setup_runtime_missing)
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
},
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
divider = !s.busy,
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) }
if (s.busy) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
Text(
if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage,
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
)
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
stringResource(R.string.setup_limit),
+3
View File
@@ -935,6 +935,9 @@
<string name="game_link_missing">This game is no longer in your DroidDeck library.</string>
<string name="game_link_session_busy">Stop the current session before launching a Steam game.</string>
<string name="game_link_failed">Could not send the game to Steam. Try launching it again.</string>
<string name="setup_retry_removal">Retry removal</string>
<string name="runtime_removing">Removing</string>
<string name="runtime_removal_incomplete">Removal incomplete</string>
<string name="game_file_export">Export game file</string>
<string name="game_file_sync">Sync all game files to folder</string>
<string name="game_file_stop_sync">Stop syncing game files</string>
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.runtime
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.attribute.PosixFilePermissions
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class RuntimeRemovalTest {
private lateinit var dir: File
@Before fun setUp() { dir = Files.createTempDirectory("runtime-removal").toFile() }
@After fun tearDown() {
dir.setWritable(true)
RuntimeFileTree.delete(dir, null)
}
private fun runtime() = File(dir, "linuxfs").apply {
File(this, "usr/bin").mkdirs()
File(this, "usr/bin/gamescope").writeText("runtime")
File(this, "root/.local/share/Steam").mkdirs()
File(this, "root/.local/share/Steam/game").writeText("game")
File(this, ".version").writeText("r9")
}
@Test fun removesReadOnlyTreesWithoutFollowingLinks() {
val root = runtime()
val outside = File(dir, "outside").apply { mkdirs() }
val data = File(outside, "keep").apply { writeText("preserved") }
Files.createSymbolicLink(File(root, "outside").toPath(), outside.toPath())
Files.createSymbolicLink(File(root, "dangling").toPath(), File(dir, "missing").toPath())
Files.setPosixFilePermissions(outside.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
root.walkBottomUp().filter { !Files.isSymbolicLink(it.toPath()) && it.isDirectory }
.forEach { Files.setPosixFilePermissions(it.toPath(), PosixFilePermissions.fromString("r-xr-xr-x")) }
val counts = mutableListOf<Long>()
RuntimeFileTree.delete(root) { counts.add(it) }
assertFalse(Files.exists(root.toPath(), NOFOLLOW_LINKS))
assertEquals("preserved", data.readText())
assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), Files.getPosixFilePermissions(outside.toPath()))
assertTrue(counts.size > 5)
assertEquals((1L..counts.size.toLong()).toList(), counts)
}
@Test fun removesUnreadableDirectories() {
val root = runtime()
Files.setPosixFilePermissions(File(root, "usr").toPath(), emptySet())
RuntimeFileTree.delete(root, null)
assertFalse(root.exists())
}
@Test fun updatesCarryTheHomeAcrossReadOnlyRuntimeRoots() {
val old = runtime()
val staging = File(dir, "linuxfs.new").apply { File(this, "root").mkdirs() }
for (tree in listOf(old, staging)) {
Files.setPosixFilePermissions(tree.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
}
RuntimeFileTree.carryHome(old, staging)
assertEquals("game", File(staging, "root/.local/share/Steam/game").readText())
assertFalse(File(old, "root").exists())
}
@Test fun recoversTheHomeAfterAnInterruptedReadOnlyRuntimeSwap() {
val root = File(dir, "linuxfs")
val old = File(dir, "linuxfs.old").apply { File(this, "root").mkdirs() }
val staging = File(dir, "linuxfs.new").apply { File(this, "root").mkdirs() }
File(staging, "root/save").writeText("user data")
for (tree in listOf(old, staging))
Files.setPosixFilePermissions(tree.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
LinuxRuntimeInstaller.recoverInterruptedSwap(root, staging, old)
assertEquals("user data", File(root, "root/save").readText())
assertFalse(File(staging, "root").exists())
}
@Test fun reservationBlocksPlayAndInstallBeforeTheWorkerStartsAndFollowersSeeProgress() {
val root = runtime()
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
assertTrue(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.isRemoving())
assertFalse(LinuxRuntimeInstaller.isInstalling())
assertFalse(LinuxRuntime.isInstalled(null))
assertNull(LinuxRuntimeInstaller.beginUninstall(root))
assertFalse(LinuxRuntimeInstaller.install(null, null, null))
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val result = AtomicReference<Boolean>()
val worker = Thread {
result.set(removal.run { _, _ -> entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)) })
}
worker.start()
try {
assertTrue(entered.await(5, TimeUnit.SECONDS))
val following = CountDownLatch(1)
val stages = mutableListOf<String>()
val followerResult = AtomicReference<Boolean>()
val follower = Thread {
followerResult.set(LinuxRuntimeInstaller.attach { stage, _ -> stages.add(stage); following.countDown() })
}
follower.start()
assertTrue(following.await(5, TimeUnit.SECONDS))
release.countDown()
worker.join(5000)
follower.join(5000)
assertEquals(true, result.get())
assertEquals(true, followerResult.get())
assertEquals("Linux runtime removed", stages.last())
assertFalse(root.exists())
assertFalse(File(dir, "linuxfs.removing").exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
} finally { release.countDown(); worker.join(5000) }
}
@Test fun retriesAnInterruptedRemovalAndClearsUpdateStaging() {
val pending = File(dir, "linuxfs.removing").apply { mkdirs() }
File(pending, "leftover").writeText("old")
for (name in listOf("linuxfs.new", "linuxfs.old")) {
val tree = File(dir, name).apply { mkdirs() }
File(tree, "game").writeText("staged")
}
val removal = LinuxRuntimeInstaller.beginUninstall(File(dir, "linuxfs"))!!
assertTrue(removal.run(null))
assertTrue(dir.listFiles()!!.isEmpty())
}
@Test fun aPartialRemovalStaysQuarantinedAndRetryable() {
val root = runtime()
try {
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
assertFalse(removal.run { stage, _ ->
if (stage.contains("entries"))
Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
})
assertFalse(root.exists())
assertTrue(File(dir, "linuxfs.removing").exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
assertNotNull(LinuxRuntimeInstaller.removalError())
} finally { dir.setWritable(true) }
assertTrue(LinuxRuntimeInstaller.beginUninstall(root)!!.run(null))
assertFalse(File(dir, "linuxfs.removing").exists())
}
@Test fun reportsFailureWhenTheRuntimeCannotBeQuarantinedAndAllowsRetry() {
val root = runtime()
Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("r-xr-xr-x"))
try {
val removal = LinuxRuntimeInstaller.beginUninstall(root)!!
val stages = mutableListOf<String>()
assertFalse(removal.run { stage, _ -> stages.add(stage) })
assertTrue(root.exists())
assertFalse(LinuxRuntimeInstaller.isBusy())
assertTrue(LinuxRuntimeInstaller.removalError()!!.contains("Retry removal"))
assertFalse(stages.contains("Linux runtime removed"))
} finally { dir.setWritable(true) }
assertTrue(LinuxRuntimeInstaller.beginUninstall(root)!!.run(null))
assertNull(LinuxRuntimeInstaller.removalError())
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.SessionActivity
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class RuntimeRemovalUiTest {
private lateinit var dir: File
@Before fun setUp() {
dir = Files.createTempDirectory("runtime-removal-ui").toFile()
SessionState.running = false
SessionState.phase = SessionPhase.IDLE
}
@After fun tearDown() {
SessionState.running = false
SessionState.phase = SessionPhase.IDLE
RuntimeFileTree.delete(dir, null)
}
@Test fun directSessionLaunchDuringRemovalFinishesWithoutStartingTheGuest() {
val removal = LinuxRuntimeInstaller.beginUninstall(File(dir, "linuxfs"))!!
try {
val activity = Robolectric.buildActivity(SessionActivity::class.java).create()
assertTrue(activity.get().isFinishing)
// Even lifecycle callbacks delivered after finish must not touch uninitialised UI.
activity.start().resume().pause().stop().destroy()
assertFalse(SessionState.running)
assertEquals(SessionPhase.IDLE, SessionState.phase)
} finally { assertTrue(removal.run(null)) }
}
@Test fun removalIsRefusedWhileASessionIsRunningOrStarting() {
val context = RuntimeEnvironment.getApplication()
SessionState.running = true
assertNull(LinuxRuntimeInstaller.beginUninstall(context))
SessionState.running = false
SessionState.phase = SessionPhase.STARTING_COMPOSITOR
assertNull(LinuxRuntimeInstaller.beginUninstall(context))
assertFalse(LinuxRuntimeInstaller.isBusy())
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SteamChannelTest {
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun freshSettingsDefaultToDeckBetaWithOrWithoutDeckMode() {
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
SessionPrefs.setSteamDeckMode(context, false)
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
}
@Test fun explicitChannelIsPreservedWhenDeckModeIsOff() {
SessionPrefs.setSteamDeckMode(context, false)
SessionPrefs.setSteamChannel(context, "publicbeta")
assertEquals("publicbeta", SessionPrefs.steamChannel(context))
SessionPrefs.setSteamChannel(context, "steamdeck_publicbeta")
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
}
@Test fun deckModeUsesDeckBetaAndRestoresSavedSelectionWhenDisabled() {
SessionPrefs.setSteamChannel(context, "publicbeta")
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
SessionPrefs.setSteamDeckMode(context, false)
assertEquals("publicbeta", SessionPrefs.steamChannel(context))
}
}
+1
View File
@@ -292,6 +292,7 @@ mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
cd "${repo_root}"
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}"
python3 tools/release/check_session_assets.py "app/build/outputs/apk/${build_variant}/app-${build_variant}.apk"
cp -p "${bundle_backup}" "${bundle_asset}"
bundle_replaced=0
@@ -323,6 +323,9 @@ case $mode in
bannerlator-steam-library "$steam_root" || true
step "setting up the ARM64 Proton compatibility tool"
bannerlator-steam-compat "$steam_root" || true
if [ -z "${BL_DESKTOP:-}" ] && [ "${BL_STEAM_UI:-}" != desktop ]; then
bannerlator-steam-ui-scale "$steam_root" "${BL_WIDTH:-1280}" "${BL_HEIGHT:-720}" || true
fi
# The user's own games, as non-Steam shortcuts under that tool; the list is written by the app
# each session (empty = the app's earlier entries are removed, the client's own are kept).
if [ -n "${BL_ADDED_GAMES:-}" ]; then
@@ -667,14 +670,13 @@ BUSCONF
[ -n "$_stale" ] && echo "== steam: cleared what an earlier session left:$_stale"
unset _stale _f
fi
# The client branch. publicbeta is what every session has run on; steamdeck_publicbeta is the
# channel Armada bootstraps its ARM64 client from. Whatever wrote package/beta, the branch on
# Use the same configured branch as the first-run download. Whatever wrote package/beta, the branch on
# disk has to agree with the one forced here - a disagreement is the restart loop above, and
# it is silent apart from rc=42 in this log.
# Checked before every start, not once: the client rewrites package/beta during its own run,
# and a restart below (rc=42, or the one after the ARM64 Proton lands) would otherwise start it
# again on the branch it just wrote.
steam_channel=${BL_STEAM_CHANNEL:-publicbeta}
steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta}
steam_beta=$steam_root/package/beta
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
@@ -3,7 +3,12 @@
# The client is not redistributable, so the runtime ships this script instead of the client.
set -eu
cdn=https://client-update.fastly.steamstatic.com
manifest=steam_client_publicbeta_linuxarm64
steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta}
case "$steam_channel" in
publicbeta|steamdeck_publicbeta) ;;
*) echo "bannerlator-steam-install: unsupported Steam channel: $steam_channel" >&2; exit 1 ;;
esac
manifest=steam_client_${steam_channel}_linuxarm64
steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam
stamp=$steam_root/package/bannerlator-installed
[ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0
@@ -41,8 +46,7 @@ find "$steam_root" -depth -name '*\\*' | while read -r odd; do
mv -f "$odd" "$fixed"
fi
done
echo publicbeta > "$steam_root/package/beta"
echo "$version" > "$stamp"
echo "$steam_channel" > "$steam_root/package/beta"
ln -sfn "$steam_root/steamrtarm64" "$steam_root/steamrtarm32"
chmod +x "$steam_root/steamrtarm64/steam" 2>/dev/null || true
# What Valve's steam.sh sets up before it runs the client (it has no arm64 branch itself).
@@ -59,3 +63,4 @@ ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdk64"
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"
@@ -0,0 +1,136 @@
#!/usr/bin/python3
"""Initialize Steam's own Gamescope UI scale once; subsequent choices belong to Steam."""
import math
import os
from pathlib import Path
import re
import sys
import tempfile
DISPLAY = "External: gamescope|||Windowed"
TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]')
class Block:
def __init__(self, pairs, end):
self.pairs = pairs
self.end = end
def get(self, name):
matches = [value for key, value in self.pairs if key.lower() == name.lower()]
if len(matches) > 1:
raise ValueError("duplicate UI setting")
return matches[0] if matches else None
def parse(text):
tokens = []
position = 0
for match in TOKEN.finditer(text):
if match.start() != position:
raise ValueError("invalid config.vdf")
position = match.end()
token = match.group()
if not token.isspace() and not token.startswith('//'):
tokens.append((token, match.start()))
if position != len(text):
raise ValueError("invalid config.vdf")
def block(index, nested=False):
pairs = []
while index < len(tokens):
token, offset = tokens[index]
if token == '}':
if not nested:
raise ValueError("unexpected closing brace")
return Block(pairs, offset), index + 1
if not token.startswith('"') or index + 1 == len(tokens):
raise ValueError("missing config value")
key = token[1:-1]
value = tokens[index + 1][0]
index += 2
if value == '{':
value, index = block(index, True)
elif value.startswith('"'):
value = value[1:-1]
else:
raise ValueError("invalid config value")
pairs.append((key, value))
if nested:
raise ValueError("unclosed config block")
return Block(pairs, len(text)), index
return block(0)[0]
def initial_scale(width, height):
# The Thor setting chosen on device at 1280x720 was 140% of its automatic scale.
# Adjust with canvas area so choosing a different session resolution keeps the UI usable.
if not 320 <= width <= 16384 or not 320 <= height <= 16384:
raise ValueError("invalid session size")
return 1.3281567096710205 * math.sqrt(width * height / (1280 * 720))
def seed_text(text, scale):
root = parse(text).get('InstallConfigStore')
if not isinstance(root, Block):
raise ValueError("missing InstallConfigStore")
ui = root.get('UI')
display = ui.get('display') if isinstance(ui, Block) else None
if ui is not None and not isinstance(ui, Block):
raise ValueError("invalid UI settings")
if display is not None and not isinstance(display, Block):
raise ValueError("invalid display settings")
if display is not None:
# Current describes the active display and is written even with automatic scaling.
# Named display entries are the user's saved choices, including a return to Auto.
if any(name.lower() != 'current' and isinstance(value, Block)
and value.get('ScaleFactor') is not None for name, value in display.pairs):
return text, False
entry = f'"{DISPLAY}"\n{{\n"ScaleFactor" "{scale:.9f}"\n}}\n'
if display is not None:
at, indent = display.end, 3
elif ui is not None:
entry = '"display"\n{\n' + entry + '}\n'
at, indent = ui.end, 2
else:
entry = '"UI"\n{\n"display"\n{\n' + entry + '}\n}\n'
at, indent = root.end, 1
# Insert only the new block; leave every existing byte, comment and credential untouched.
insertion = '\n' + '\n'.join('\t' * indent + line for line in entry.splitlines()) + '\n'
return text[:at] + insertion + text[at:], True
def atomic_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
with os.fdopen(fd, 'w') as output:
output.write(text)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def initialize(steam_root, width, height):
config = Path(steam_root) / 'config/config.vdf'
marker = config.with_name('droiddeck-ui-scale-initialized')
if marker.exists():
return False
text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n'
updated, changed = seed_text(text, initial_scale(width, height))
if changed:
atomic_write(config, updated)
print(f'== steam: initial handheld UI scale {initial_scale(width, height):.3f}')
atomic_write(marker, '1\n')
return changed
if __name__ == '__main__':
try:
initialize(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))
except (OSError, ValueError, RecursionError) as error:
print('== steam: left UI scale unchanged: ' + str(error), file=sys.stderr)
sys.exit(1)
+31
View File
@@ -0,0 +1,31 @@
"""Reject an APK with missing or stale session helpers, including cached local bundles."""
from pathlib import Path
import sys
from zipfile import ZipFile
OVERLAY = Path(__file__).resolve().parents[1] / 'linuxfs/overlay'
def check(apk, overlay=OVERLAY):
sources = list((overlay / 'usr/local/bin').glob('bannerlator-*'))
sources += [path for path in (overlay / 'usr/bin').rglob('*') if path.is_file()]
errors = []
with ZipFile(apk) as package:
for source in sources:
asset = 'assets/linuxfs/' + source.relative_to(overlay).as_posix()
try:
content = package.read(asset)
except KeyError:
errors.append('missing ' + asset)
continue
if content != source.read_bytes():
errors.append('stale ' + asset)
return errors
if __name__ == '__main__':
errors = check(sys.argv[1])
if errors:
sys.exit('\n'.join(errors))
print('APK session helpers match source')
+39
View File
@@ -0,0 +1,39 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest
from zipfile import ZipFile
spec = importlib.util.spec_from_file_location(
'check_session_assets', Path(__file__).resolve().parents[1] / 'release/check_session_assets.py')
assets = importlib.util.module_from_spec(spec)
spec.loader.exec_module(assets)
class SessionAssetsTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.overlay = self.root / 'overlay'
self.helper = self.overlay / 'usr/local/bin/bannerlator-login1'
self.helper.parent.mkdir(parents=True)
self.helper.write_bytes(b'current helper')
self.apk = self.root / 'app.apk'
self.name = 'assets/linuxfs/usr/local/bin/bannerlator-login1'
def test_missing_required_helper_rejects_cached_bundle(self):
with ZipFile(self.apk, 'w') as package:
package.writestr('classes.dex', b'classes')
self.assertEqual(['missing ' + self.name], assets.check(self.apk, self.overlay))
def test_stale_helper_rejects_cached_bundle(self):
with ZipFile(self.apk, 'w') as package:
package.writestr(self.name, b'old helper')
self.assertEqual(['stale ' + self.name], assets.check(self.apk, self.overlay))
def test_current_helper_passes(self):
with ZipFile(self.apk, 'w') as package:
package.writestr(self.name, self.helper.read_bytes())
self.assertEqual([], assets.check(self.apk, self.overlay))
+115
View File
@@ -0,0 +1,115 @@
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
import zipfile
SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/bannerlator-steam-install'
class SteamInstallTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.home = self.root / 'home'
self.home.mkdir()
self.data = self.root / 'data'
self.steam = self.data / 'Steam'
self.cdn = self.root / 'cdn'
self.cdn.mkdir()
self.requests = self.root / 'requests'
self.bin = self.root / 'bin'
self.bin.mkdir()
# Only the network is faked: exercise real manifest parsing, checksums and zip extraction.
curl = self.bin / 'curl'
curl.write_text('''#!/bin/bash
set -eu
printf '%s\\n' "$2" >> "$TEST_REQUESTS"
cp "$TEST_CDN/${2##*/}" "$4"
''')
curl.chmod(0o755)
# macOS has shasum; the guest and Linux CI have sha256sum.
if shutil.which('sha256sum') is None:
sha = self.bin / 'sha256sum'
sha.write_text('#!/bin/bash\nexec shasum -a 256 "$@"\n')
sha.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))
self.env.pop('BL_STEAM_CHANNEL', None)
for channel in ('publicbeta', 'steamdeck_publicbeta'):
archive = self.cdn / (channel + '.zip')
with zipfile.ZipFile(archive, 'w') as package:
package.writestr('steamrtarm64/steam', channel)
package.writestr('linuxarm64/wrapper', 'wrapper')
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
(self.cdn / ('steam_client_' + channel + '_linuxarm64')).write_text(
'"linuxarm64"\n{\n\t"version" "123456"\n'
'\t"client_linuxarm64_linuxarm64"\n\t{\n'
f'\t\t"file" "{archive.name}"\n\t\t"sha2" "{digest}"\n'
'\t}\n}\n')
def install(self, channel=None):
env = self.env.copy()
if channel is not None:
env['BL_STEAM_CHANNEL'] = channel
return subprocess.run(['bash', str(SCRIPT)], env=env, text=True, capture_output=True)
def assert_installed(self, channel):
result = self.install(channel)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text())
self.assertEqual('123456\n', (self.steam / 'package/bannerlator-installed').read_text())
client = self.steam / 'steamrtarm64/steam'
self.assertTrue(os.access(client, os.X_OK))
self.assertEqual(channel, client.read_text())
self.assertEqual((self.steam / 'linuxarm64').resolve(), (self.home / '.steam/sdkarm64').resolve())
self.assertEqual([
'https://client-update.fastly.steamstatic.com/steam_client_' + channel + '_linuxarm64',
'https://client-update.fastly.steamstatic.com/' + channel + '.zip',
], self.requests.read_text().splitlines())
def test_unset_channel_downloads_deck_beta(self):
result = self.install()
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual('steamdeck_publicbeta\n', (self.steam / 'package/beta').read_text())
self.assertIn('/steam_client_steamdeck_publicbeta_linuxarm64\n', self.requests.read_text())
def test_explicit_deck_beta_downloads_matching_client(self):
self.assert_installed('steamdeck_publicbeta')
def test_explicit_public_beta_downloads_matching_client(self):
self.assert_installed('publicbeta')
def test_existing_install_is_left_for_steam_to_update(self):
self.assert_installed('steamdeck_publicbeta')
requests = self.requests.read_text()
result = self.install('publicbeta')
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(requests, self.requests.read_text())
self.assertEqual('steamdeck_publicbeta\n', (self.steam / 'package/beta').read_text())
def test_failed_download_has_no_ready_marker_and_can_retry(self):
archive = self.cdn / 'publicbeta.zip'
original = archive.read_bytes()
archive.write_bytes(b'corrupt download')
self.assertNotEqual(0, self.install('publicbeta').returncode)
self.assertFalse((self.steam / 'package/bannerlator-installed').exists())
archive.write_bytes(original)
self.assertEqual(0, self.install('publicbeta').returncode)
self.assertEqual('publicbeta\n', (self.steam / 'package/beta').read_text())
def test_invalid_channel_fails_before_any_download(self):
result = self.install('../unknown')
self.assertNotEqual(0, result.returncode)
self.assertIn('unsupported Steam channel', result.stderr)
self.assertFalse(self.requests.exists())
if __name__ == '__main__':
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
from pathlib import Path
import runpy
import tempfile
import unittest
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
MODULE = runpy.run_path(str(BIN / 'bannerlator-steam-ui-scale'))
initialize = MODULE['initialize']
seed_text = MODULE['seed_text']
parse = MODULE['parse']
initial_scale = MODULE['initial_scale']
DISPLAY = MODULE['DISPLAY']
class SteamUiScaleTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.config = self.root / 'config/config.vdf'
self.config.parent.mkdir()
self.marker = self.config.with_name('droiddeck-ui-scale-initialized')
def scale(self, text):
return parse(text).get('InstallConfigStore').get('UI').get('display').get(DISPLAY).get('ScaleFactor')
def test_matches_the_thor_setting_and_scales_with_session_resolution(self):
self.assertAlmostEqual(1.3281567096710205, initial_scale(1280, 720), places=6)
self.assertAlmostEqual(2 * initial_scale(1280, 720), initial_scale(2560, 1440))
def test_fresh_client_gets_native_display_setting(self):
self.assertTrue(initialize(self.root, 1280, 720))
self.assertAlmostEqual(1.328156709, float(self.scale(self.config.read_text())))
self.assertTrue(self.marker.exists())
def test_current_automatic_record_does_not_count_as_a_user_choice(self):
self.config.write_text('''"InstallConfigStore" {
"UI" { "display" { "Current" { "ScaleFactor" "0.948683321475982666" } } }
}''')
self.assertTrue(initialize(self.root, 1280, 720))
self.assertAlmostEqual(initial_scale(1280, 720), float(self.scale(self.config.read_text())))
def test_existing_manual_scale_is_preserved_exactly(self):
text = f'"InstallConfigStore" {{ "UI" {{ "display" {{ "{DISPLAY}" {{ "ScaleFactor" "1.7" }} }} }} }}'
self.config.write_text(text)
self.assertFalse(initialize(self.root, 1280, 720))
self.assertEqual(text, self.config.read_text())
self.assertTrue(self.marker.exists())
def test_user_changes_after_initialization_survive_restart_and_resolution_change(self):
initialize(self.root, 1280, 720)
text = self.config.read_text().replace(self.scale(self.config.read_text()), '1.6')
self.config.write_text(text)
self.assertFalse(initialize(self.root, 1920, 1080))
self.assertEqual(text, self.config.read_text())
def test_user_returning_to_auto_does_not_get_the_default_again(self):
initialize(self.root, 1280, 720)
text = '"InstallConfigStore" { "UI" { "display" { "Current" { "ScaleFactor" "0.95" } } } }'
self.config.write_text(text)
self.assertFalse(initialize(self.root, 1280, 720))
self.assertEqual(text, self.config.read_text())
def test_existing_choices_for_other_displays_are_also_preserved(self):
text = '"InstallConfigStore" { "UI" { "display" { "External: other" { "ScaleFactor" "-1" } } } }'
self.assertEqual((text, False), seed_text(text, 1.328))
def test_unrelated_settings_comments_and_escaped_strings_keep_their_bytes(self):
text = r'''// Keep this comment
"InstallConfigStore" {
"Software" { "Valve" { "Steam" { "keep" "quoted \"text\" and { braces }" } } }
"UI" { // comment inside UI
"other" "unchanged"
}
}'''
updated, changed = seed_text(text, 1.328)
self.assertTrue(changed)
self.assertAlmostEqual(1.328, float(self.scale(updated)))
self.assertIn('"other" "unchanged"', updated)
self.assertIn('"keep" "quoted \\\"text\\\" and { braces }"', updated)
self.assertIn('// comment inside UI', updated)
at = parse(text).get('InstallConfigStore').get('UI').end
self.assertEqual(text, updated[:at] + updated[at + len(updated) - len(text):])
def test_invalid_config_is_never_rewritten_or_marked_initialized(self):
for text in ('"InstallConfigStore" {', 'garbage', '"InstallConfigStore" { "UI" "bad" }'):
self.config.write_text(text)
with self.assertRaises(ValueError):
initialize(self.root, 1280, 720)
self.assertEqual(text, self.config.read_text())
self.assertFalse(self.marker.exists())
def test_invalid_output_size_is_rejected(self):
with self.assertRaises(ValueError):
initialize(self.root, 0, 720)
self.assertFalse(self.config.exists())
self.assertFalse(self.marker.exists())