diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a16556d3..647ab24d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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' diff --git a/app/build.gradle b/app/build.gradle index 025b1740..963e1db0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 0f177933..99fc649e 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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(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(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?>(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() { diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9e68726c..4a3b5599 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 2f292255..de3f45d2 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index 4643e9b4..0d4f0595 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -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); + } } /** diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java new file mode 100644 index 00000000..5fd8d361 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java @@ -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 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"); + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index 31a26fd3..356dada4 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index d404c8d4..d863ece3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 79274ab8..e836dd1e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 712257f5..7e4c4341 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 90917377..33226c3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index 3e1e89c7..34f50331 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -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( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 8931de7e..a7ecdf4d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index af7a1470..dbed8dd2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 169b7f2f..aa3a04fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -935,6 +935,9 @@ This game is no longer in your DroidDeck library. Stop the current session before launching a Steam game. Could not send the game to Steam. Try launching it again. + Retry removal + Removing + Removal incomplete Export game file Sync all game files to folder Stop syncing game files diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt new file mode 100644 index 00000000..ed2ce086 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalTest.kt @@ -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() + 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() + 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() + val followerResult = AtomicReference() + 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() + 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()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt new file mode 100644 index 00000000..eb63a65a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/RuntimeRemovalUiTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamChannelTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamChannelTest.kt new file mode 100644 index 00000000..8a419595 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamChannelTest.kt @@ -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)) + } +} diff --git a/tools/build_local.sh b/tools/build_local.sh index a2efcbe8..bab37c8c 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 322f1455..3f036e91 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -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" & diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install index de657042..ae02ad1e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install @@ -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" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-ui-scale new file mode 100755 index 00000000..0248d8a6 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-ui-scale @@ -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) diff --git a/tools/release/check_session_assets.py b/tools/release/check_session_assets.py new file mode 100644 index 00000000..b71f42e4 --- /dev/null +++ b/tools/release/check_session_assets.py @@ -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') diff --git a/tools/tests/test_session_assets.py b/tools/tests/test_session_assets.py new file mode 100644 index 00000000..5f7a73a5 --- /dev/null +++ b/tools/tests/test_session_assets.py @@ -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)) diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py new file mode 100644 index 00000000..76b9f577 --- /dev/null +++ b/tools/tests/test_steam_install.py @@ -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() diff --git a/tools/tests/test_steam_ui_scale.py b/tools/tests/test_steam_ui_scale.py new file mode 100644 index 00000000..08a6be77 --- /dev/null +++ b/tools/tests/test_steam_ui_scale.py @@ -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())